refactor(import): generalize import package across agents (Claude first) · Entire

Home

Log in

refactor(import): generalize import package across agents (Claude first)

5733491→main·

computermode·3w ago·16 files·+691 added/-776 removed

Renames cmd/entire/cli/importclaude -> cmd/entire/cli/agentimport with an Importer seam (Discover + SplitTurns) and a static set of supported agents, so other agents can be added without touching the orchestration. Claude Code is the only implementation today.

Also drops the enable-time import prompt; importing is a hidden command only (entire import , discoverable via entire labs).

Co-Authored-By: Claude Opus 4.8 noreply@anthropic.com

Sessions

e58fc114f6a9View transcript

?\ Generalize Agent Import Across PlatformsClaude Code·Opus 4.8·2 steps

Changes

16

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

// Package agentimport imports a coding agent's pre-existing local transcripts
// into Entire as read-only, commit-less checkpoints on the v1 metadata branch.
//
// The orchestration (discovery loop, idempotent per-turn IDs, redaction, and
// the checkpoint write) is agent-agnostic and lives here. Each agent plugs in
// an Importer that knows where its transcripts live and how to split one into
// per-user-prompt turns. Claude Code is the only implementation today
// (see claude.go); others register themselves the same way.
package agentimport

import (
    "context"
    "crypto/sha256"
    "encoding/hex"
    "fmt"
    "os"
    "sort"
    "time"

"github.com/go-git/go-git/v6"

"github.com/entireio/cli/cmd/entire/cli/agent/types"
    cp "github.com/entireio/cli/cmd/entire/cli/checkpoint"
    "github.com/entireio/cli/cmd/entire/cli/checkpoint/id"
    "github.com/entireio/cli/cmd/entire/cli/session"
    "github.com/entireio/cli/redact"
)

// importVersion is the importer schema version stamped into provenance.
const importVersion = 1

// LookbackDays bounds how far back import reaches. Fixed this pass (no flag).
const LookbackDays = 30

// SessionFile is one discovered agent transcript for a repo.
type SessionFile struct {
    Path      string // absolute path to the transcript file
    SessionID string // agent session id (used in checkpoint metadata + provenance)
}

// Turn is one user-prompt turn extracted from a session transcript. Line
// offsets are in raw-line space (newline-counted), matching transcript slicing
// and the agent token-usage helpers.
type Turn struct {
    LineStart, LineEnd int
    UUID, ParentUUID   string
    Prompt, Model      string
    CreatedAt          time.Time
    Tokens             *types.TokenUsage
    ContentHash        string
}

// Importer is the per-agent seam: it locates an agent's transcripts for a repo
// and splits one into per-turn units. Everything else (idempotency, redaction,
// writing) is handled generically by Run.
type Importer interface {
    // Name is the registry key and the provenance source (e.g. "claude-code").
    Name() string
    // AgentType is the display name stored in checkpoint metadata (e.g. "Claude Code").
    AgentType() types.AgentType
    // Discover returns the agent's transcript files for the repo within the
    // lookback window. overridePath replaces the default transcript dir;
    // sessionFilter, when non-empty, keeps only matching session IDs.
    Discover(repoRoot, overridePath string, now time.Time, sessionFilter []string) ([]SessionFile, error)
    // SplitTurns splits one session's raw transcript bytes into per-turn units.
    SplitTurns(sf SessionFile, full []byte) ([]Turn, error)
}

// importers is the static set of supported agents. Adding an agent is a new
// Importer implementation appended here — no init() / runtime registration.
var importers = []Importer{
    claudeImporter{},
}

// Get returns the importer registered under name.
//
//nolint:ireturn // Importer is the intended polymorphic seam returned to callers.
func Get(name string) (Importer, bool) {
    for _, imp := range importers {
        if imp.Name() == name {
            return imp, true
        }
    }
    return nil, false
}

// All returns every supported importer, sorted by name.
func All() []Importer {
    out := append([]Importer(nil), importers...)
    sort.Slice(out, func(i, j int) bool { return out[i].Name() < out[j].Name() })
    return out
}

// Options configures an import run.
type Options struct {
    RepoRoot      string
    OverridePath  string
    SessionFilter []string
    Now           time.Time
    DryRun        bool
}

// Result summarizes an import run.
type Result struct {
    SessionsScanned int
    TurnsImported   int
    TurnsSkipped    int
}

// DeriveCheckpointID produces a stable 12-hex checkpoint ID for an imported
// turn. Re-importing the same (sessionID, turnUUID) yields the same ID, which
// is how import stays idempotent.
func DeriveCheckpointID(sessionID, turnUUID string) id.CheckpointID {
    sum := sha256.Sum256([]byte(sessionID + "/" + turnUUID))
    return id.MustCheckpointID(hex.EncodeToString(sum[:6])) // 6 bytes = 12 lowercase hex chars
}

// Run imports the given agent's transcripts (within the lookback window) as
// read-only checkpoints on the v1 metadata branch (Kind "imported"). It is
// idempotent: turns whose deterministic ID already exists are skipped.
func Run(ctx context.Context, repo *git.Repository, imp Importer, opts Options) (Result, error) {
    var res Result
    files, err := imp.Discover(opts.RepoRoot, opts.OverridePath, opts.Now, opts.SessionFilter)
    if err != nil {
        return res, fmt.Errorf("discover %s sessions: %w", imp.Name(), err)
    }

stores, err := cp.Open(ctx, repo, cp.OpenOptions{})
    if err != nil {
        return res, fmt.Errorf("open checkpoint store: %w", err)
    }
    existing := make(map[string]bool)
    if infos, listErr := stores.Persistent.List(ctx); listErr == nil {
        for _, in := range infos {
            existing[in.CheckpointID.String()] = true
        }
    }

for _, sf := range files {
        res.SessionsScanned++
        full, readErr := os.ReadFile(sf.Path)
        if readErr != nil {
            return res, fmt.Errorf("read %s: %w", sf.Path, readErr)
        }
        turns, splitErr := imp.SplitTurns(sf, full)
        if splitErr != nil {
            return res, fmt.Errorf("split %s session %s: %w", imp.Name(), sf.SessionID, splitErr)
        }
        for _, turn := range turns {
            cid := DeriveCheckpointID(sf.SessionID, turn.UUID)
            if existing[cid.String()] {
                res.TurnsSkipped++
                continue
            }
            if opts.DryRun {
                res.TurnsImported++ // counts what would import
                continue
            }
            if err := writeTurn(ctx, stores, imp, cid, sf, full, turn); err != nil {
                return res, err
            }
            existing[cid.String()] = true
            res.TurnsImported++
        }
    }
    return res, nil
}

func writeTurn(ctx context.Context, stores *cp.Stores, imp Importer, cid id.CheckpointID, sf SessionFile, full []byte, turn Turn) error {
    red, err := redact.JSONLBytes(full)
    if err != nil {
        return fmt.Errorf("redact transcript: %w", err)
    }
    prov := &cp.Provenance{
        Source: imp.Name(), TranscriptPath: sf.Path, SessionID: sf.SessionID,
        TurnUUID: turn.UUID, ParentUUID: turn.ParentUUID,
        LineStart: turn.LineStart, LineEnd: turn.LineEnd,
        ContentHash: turn.ContentHash, ImportVersion: importVersion,
    }
    if err := stores.Persistent.Write(ctx, cp.Session(cp.WriteOptions{
        CheckpointID:              cid,
        SessionID:                 sf.SessionID,
        CreatedAt:                 turn.CreatedAt,
        Strategy:                  "import",
        Kind:                      string(session.KindImported),
        Agent:                     imp.AgentType(),
        Model:                     turn.Model,
        Transcript:                red,
        Prompts:                   []string{turn.Prompt},
        CheckpointsCount:          1,
        CheckpointTranscriptStart: turn.LineStart,
        TokenUsage:                turn.Tokens,
        Provenance:                prov,
    })); err != nil {
        return fmt.Errorf("write imported checkpoint %s: %w", cid, err)
    }
    return nil
}

Acmd/entire/cli/agentimport/agentimport.go+198

package agentimport

import ( "context" "os" "path/filepath" "strings" "testing" "time"

"github.com/go-git/go-git/v6" "github.com/go-git/go-git/v6/plumbing/object"

cp "github.com/entireio/cli/cmd/entire/cli/checkpoint" "github.com/entireio/cli/cmd/entire/cli/testutil" )

func TestDeriveCheckpointID_StableAndDistinct(t *testing.T) { t.Parallel() a := DeriveCheckpointID("sess", "turn-1") b := DeriveCheckpointID("sess", "turn-1") c := DeriveCheckpointID("sess", "turn-2") if a != b { t.Errorf("not deterministic: %s != %s", a, b) } if a == c { t.Errorf("collision across turns: %s == %s", a, c) } if a.IsEmpty() { t.Error("derived id is empty") } }

func TestRegistry_HasClaude(t *testing.T) { t.Parallel() imp, ok := Get("claude-code") if !ok { t.Fatal("claude-code importer not registered") } if imp.Name() != "claude-code" { t.Fatalf("unexpected name %q", imp.Name()) } if len(All()) == 0 { t.Fatal("All() returned no importers") } }

func initRepoWithCommit(t *testing.T) (*git.Repository, string) { t.Helper() repoDir := t.TempDir() testutil.InitRepo(t, repoDir) repo, err := git.PlainOpen(repoDir) if err != nil { t.Fatal(err) } wt, err := repo.Worktree() if err != nil { t.Fatal(err) } testutil.WriteFile(t, repoDir, "f.txt", "x") if _, err := wt.Add("f.txt"); err != nil { t.Fatal(err) } if _, err := wt.Commit("init", &git.CommitOptions{ Author: &object.Signature{Name: "Test", Email: "test@test.com"}, }); err != nil { t.Fatal(err) } return repo, repoDir }

func writeFixtureSession(t *testing.T, dir, name string) { t.Helper() content := strings.Join([]string{ {"type":"user","uuid":"u1","timestamp":"2026-06-20T00:00:00Z","message":{"role":"user","content":"first"}}, {"type":"assistant","uuid":"a1","message":{"id":"m1","model":"claude-x","content":[{"type":"text","text":"ok"}],"usage":{"output_tokens":5}}}, {"type":"user","uuid":"u2","timestamp":"2026-06-20T00:01:00Z","message":{"role":"user","content":"second"}}, }, "\n") + "\n" if err := os.WriteFile(filepath.Join(dir, name), []byte(content), 0o644); err != nil { t.Fatal(err) } }

func TestRun_ImportsAndIsIdempotent(t *testing.T) { t.Parallel() repo, repoDir := initRepoWithCommit(t) claudeDir := t.TempDir() writeFixtureSession(t, claudeDir, "sess1.jsonl")

opts := Options{RepoRoot: repoDir, OverridePath: claudeDir, Now: time.Date(2026, 6, 25, 0, 0, 0, 0, time.UTC)} imp := claudeImporter{}

res, err := Run(context.Background(), repo, imp, opts) if err != nil { t.Fatal(err) } if res.TurnsImported != 2 { t.Fatalf("want 2 imported, got %+v", res) }

res2, err := Run(context.Background(), repo, imp, opts) if err != nil { t.Fatal(err) } if res2.TurnsImported != 0 || res2.TurnsSkipped != 2 { t.Fatalf("re-run not idempotent: %+v", res2) }

stores, err := cp.Open(context.Background(), repo, cp.OpenOptions{}) if err != nil { t.Fatal(err) } infos, err := stores.Persistent.List(context.Background()) if err != nil { t.Fatal(err) } if len(infos) != 2 { t.Fatalf("expected 2 imported checkpoints on v1, got %+v", infos) } for _, in := range infos { if !in.Imported { t.Fatalf("checkpoint %s missing Imported flag: %+v", in.CheckpointID, in) } } }

func TestRun_DryRunWritesNothing(t *testing.T) { t.Parallel() repo, repoDir := initRepoWithCommit(t) claudeDir := t.TempDir() writeFixtureSession(t, claudeDir, "sess1.jsonl")

res, err := Run(context.Background(), repo, claudeImporter{}, Options{ RepoRoot: repoDir, OverridePath: claudeDir, DryRun: true, Now: time.Date(2026, 6, 25, 0, 0, 0, 0, time.UTC), }) if err != nil { t.Fatal(err) } if res.TurnsImported != 2 { t.Fatalf("dry-run should count 2 turns, got %+v", res) }

stores, err := cp.Open(context.Background(), repo, cp.OpenOptions{}) if err != nil { t.Fatal(err) } infos, err := stores.Persistent.List(context.Background()) if err != nil { t.Fatal(err) } if len(infos) != 0 { t.Fatalf("dry-run must not write, got %+v", infos) } }


Acmd/entire/cli/agentimport/agentimport\_test.go+155

package agentimport

import (
    "crypto/sha256"
    "encoding/hex"
    "encoding/json"
    "fmt"
    "os"
    "path/filepath"
    "slices"
    "strings"
    "time"

"github.com/entireio/cli/cmd/entire/cli/agent"
    "github.com/entireio/cli/cmd/entire/cli/agent/claudecode"
    "github.com/entireio/cli/cmd/entire/cli/agent/types"
    "github.com/entireio/cli/cmd/entire/cli/transcript"
)

// claudeImporter imports Claude Code transcripts (~/.claude/projects/<slug>/*.jsonl).
type claudeImporter struct{}

func (claudeImporter) Name() string { return "claude-code" }

func (claudeImporter) AgentType() types.AgentType { return agent.AgentTypeClaudeCode }

// Discover returns Claude transcript files for the repo modified within the
// lookback window. overridePath replaces the default ~/.claude/projects/<slug>
// dir; sessionFilter, when non-empty, keeps only matching session IDs (the
// file stem).
func (claudeImporter) Discover(repoRoot, overridePath string, now time.Time, sessionFilter []string) ([]SessionFile, error) {
    dir := overridePath
    if dir == "" {
        ag := &claudecode.ClaudeCodeAgent{}
        d, err := ag.GetSessionDir(repoRoot)
        if err != nil {
            return nil, fmt.Errorf("resolve claude session dir: %w", err)
        }
        dir = d
    }
    entries, err := os.ReadDir(dir)
    if err != nil {
        if os.IsNotExist(err) {
            return nil, nil // no transcripts for this repo
        }
        return nil, fmt.Errorf("read claude session dir: %w", err)
    }
    cutoff := now.AddDate(0, 0, -LookbackDays)
    var out []SessionFile
    for _, e := range entries {
        if e.IsDir() || !strings.HasSuffix(e.Name(), ".jsonl") {
            continue
        }
        stem := strings.TrimSuffix(e.Name(), ".jsonl")
        if len(sessionFilter) > 0 && !slices.Contains(sessionFilter, stem) {
            continue
        }
        info, err := e.Info()
        if err != nil {
            continue
        }
        if info.ModTime().Before(cutoff) {
            continue
        }
        out = append(out, SessionFile{Path: filepath.Join(dir, e.Name()), SessionID: stem})
    }
    slices.SortFunc(out, func(a, b SessionFile) int { return strings.Compare(a.Path, b.Path) })
    return out, nil
}

// SplitTurns produces one Turn per user-prompt line. Token usage for each turn
// is computed on the slice [LineStart, LineEnd) so turns don't double-count\
// later turns. tool_result lines (Type == "user" but no text content) do not\
// start a turn.\
func (claudeImporter) SplitTurns(sf SessionFile, full []byte) ([]Turn, error) {\
    subagentsDir := filepath.Join(filepath.Dir(sf.Path), sf.SessionID, "subagents")\
    rawLines := splitRawLines(full)\
\
    // Identify user-prompt turn starts in raw-line space.\
    var starts []int\
    for i, raw := range rawLines {\
        if isUserPromptLine(raw) {\
            starts = append(starts, i)\
        }\
    }\
\
    ag := &claudecode.ClaudeCodeAgent{}\
    turns := make([]Turn, 0, len(starts))\
    for k, start := range starts {\
        end := len(rawLines)\
        if k+1 < len(starts) {\
            end = starts[k+1]\
        }\
\
        // Bound token usage to [start, end): truncate to the first `end` lines,\
        // then let the agent helper slice from `start`.\
        truncated := joinLines(rawLines[:end])\
        tokens, err := ag.CalculateTotalTokenUsage(truncated, start, subagentsDir)\
        if err != nil {\
            return nil, fmt.Errorf("token usage for turn %d: %w", k, err)\
        }\
\
        var rec struct {\
            UUID       string          `json:"uuid"`\
            Message    json.RawMessage `json:"message"`\
            ParentUUID string          `json:"parentUuid"`\
            Timestamp  string          `json:"timestamp"`\
        }\
        if err := json.Unmarshal(rawLines[start], &rec); err != nil {\
            // Already validated as a user-prompt line in isUserPromptLine; skip\
            // defensively if it somehow fails to parse here.\
            continue\
        }\
        ts, parseErr := time.Parse(time.RFC3339, rec.Timestamp)\
        if parseErr != nil {\
            ts = time.Time{}\
        }\
\
        slice := joinLines(rawLines[start:end])\
        sum := sha256.Sum256(slice)\
\
        turns = append(turns, Turn{\
            LineStart:   start,\
            LineEnd:     end,\
            UUID:        rec.UUID,\
            ParentUUID:  rec.ParentUUID,\
            Prompt:      transcript.ExtractUserContent(rec.Message),\
            Model:       modelInRange(rawLines, start, end),\
            CreatedAt:   ts,\
            Tokens:      tokens,\
            ContentHash: "sha256:" + hex.EncodeToString(sum[:]),\
        })\
    }\
    return turns, nil\
}\
\
// claudeExtraFields are line fields not modeled by transcript.Line.\
type claudeExtraFields struct {\
    ParentUUID string `json:"parentUuid"`\
    Timestamp  string `json:"timestamp"`\
    Message    struct {\
        Model string `json:"model"`\
    } `json:"message"`\
}\
\
// modelInRange returns the model from the first assistant message within\
// [start, end), or "" when none carries one. The model lives on assistant\
// lines, not the user-prompt line.\
func modelInRange(rawLines [][]byte, start, end int) string {\
    for i := start; i < end && i < len(rawLines); i++ {\
        var line transcript.Line\
        if err := json.Unmarshal(rawLines[i], &line); err != nil {\
            continue\
        }\
        if line.Type != "assistant" {\
            continue\
        }\
        var ex claudeExtraFields\
        if err := json.Unmarshal(rawLines[i], &ex); err != nil {\
            continue\
        }\
        if ex.Message.Model != "" {\
            return ex.Message.Model\
        }\
    }\
    return ""\
}\
\
// isUserPromptLine reports whether a raw JSONL line is a genuine user-prompt\
// turn start: type "user" (or role "user") with non-empty extractable text.\
// tool_result lines are type "user" but carry no text, so they return false.\
func isUserPromptLine(raw []byte) bool {\
    var line transcript.Line\
    if err := json.Unmarshal(raw, &line); err != nil {\
        return false\
    }\
    typ := line.Type\
    if typ == "" {\
        typ = line.Role\
    }\
    if typ != "user" {\
        return false\
    }\
    return transcript.ExtractUserContent(line.Message) != ""\
}\
\
// splitRawLines splits content into raw lines in the same index space as\
// transcript.SliceFromLine (newline-counted). Trailing empty segment from a\
// final newline is dropped.\
func splitRawLines(content []byte) [][]byte {\
    if len(content) == 0 {\
        return nil\
    }\
    parts := strings.Split(string(content), "\n")\
    if len(parts) > 0 && parts[len(parts)-1] == "" {\
        parts = parts[:len(parts)-1]\
    }\
    out := make([][]byte, len(parts))\
    for i, p := range parts {\
        out[i] = []byte(p)\
    }\
    return out\
}\
\
// joinLines reassembles raw lines into newline-terminated bytes.\
func joinLines(lines [][]byte) []byte {\
    if len(lines) == 0 {\
        return nil\
    }\
    strs := make([]string, len(lines))\
    for i, l := range lines {\
        strs[i] = string(l)\
    }\
    return []byte(strings.Join(strs, "\n") + "\n")\
}\
```\
\
Acmd/entire/cli/agentimport/claude.go+215\
\
```\
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\
\
package agentimport\
\
import (\
    "os"\
    "path/filepath"\
    "strings"\
    "testing"\
    "time"\
)\
\
func TestClaudeDiscover_LookbackAndFilter(t *testing.T) {\
    t.Parallel()\
    dir := t.TempDir()\
    now := time.Date(2026, 6, 25, 12, 0, 0, 0, time.UTC)\
    writeAged := func(name string, age time.Duration) {\
        p := filepath.Join(dir, name)\
        if err := os.WriteFile(p, []byte("{}\n"), 0o644); err != nil {\
            t.Fatal(err)\
        }\
        mt := now.Add(-age)\
        if err := os.Chtimes(p, mt, mt); err != nil {\
            t.Fatal(err)\
        }\
    }\
    writeAged("recent.jsonl", 5*24*time.Hour)\
    writeAged("old.jsonl", 60*24*time.Hour)\
    writeAged("skip.txt", 1*time.Hour)\
\
    imp := claudeImporter{}\
    got, err := imp.Discover("", dir, now, nil)\
    if err != nil {\
        t.Fatal(err)\
    }\
    if len(got) != 1 || got[0].SessionID != "recent" {\
        t.Fatalf("lookback filter wrong: %v", got)\
    }\
\
    writeAged("abc123.jsonl", 1*24*time.Hour)\
    got, err = imp.Discover("", dir, now, []string{"abc123"})\
    if err != nil {\
        t.Fatal(err)\
    }\
    if len(got) != 1 || got[0].SessionID != "abc123" {\
        t.Fatalf("session filter wrong: %v", got)\
    }\
}\
\
func TestClaudeDiscover_MissingDirIsEmpty(t *testing.T) {\
    t.Parallel()\
    got, err := claudeImporter{}.Discover("", filepath.Join(t.TempDir(), "nope"), time.Now(), nil)\
    if err != nil {\
        t.Fatalf("missing dir should not error: %v", err)\
    }\
    if len(got) != 0 {\
        t.Fatalf("expected empty, got %v", got)\
    }\
}\
\
func TestClaudeSplitTurns_TwoPromptsBoundedByNext(t *testing.T) {\
    t.Parallel()\
    full := []byte(strings.Join([]string{\
        `{"type":"user","uuid":"u1","parentUuid":"","timestamp":"2026-06-20T00:00:00Z","message":{"role":"user","content":"first"}}`,\
        `{"type":"assistant","uuid":"a1","message":{"id":"m1","model":"claude-x","content":[{"type":"text","text":"ok"}],"usage":{"input_tokens":10,"output_tokens":5}}}`,\
        `{"type":"user","uuid":"u2","parentUuid":"a1","timestamp":"2026-06-20T00:01:00Z","message":{"role":"user","content":"second"}}`,\
        `{"type":"assistant","uuid":"a2","message":{"id":"m2","model":"claude-x","content":[{"type":"text","text":"done"}],"usage":{"input_tokens":20,"output_tokens":7}}}`,\
    }, "\n") + "\n")\
\
    turns, err := claudeImporter{}.SplitTurns(SessionFile{Path: filepath.Join(t.TempDir(), "s.jsonl"), SessionID: "s"}, full)\
    if err != nil {\
        t.Fatal(err)\
    }\
    if len(turns) != 2 {\
        t.Fatalf("want 2 turns, got %d", len(turns))\
    }\
    if turns[0].LineStart != 0 || turns[0].LineEnd != 2 {\
        t.Errorf("turn0 bounds = [%d,%d), want [0,2)", turns[0].LineStart, turns[0].LineEnd)\
    }\
    if turns[0].Prompt != "first" || turns[1].Prompt != "second" {\
        t.Errorf("prompts = %q,%q", turns[0].Prompt, turns[1].Prompt)\
    }\
    if turns[0].Model != "claude-x" {\
        t.Errorf("turn0 model = %q, want claude-x", turns[0].Model)\
    }\
    if turns[0].Tokens == nil || turns[0].Tokens.OutputTokens != 5 {\
        t.Errorf("turn0 tokens not bounded to its own turn: %+v", turns[0].Tokens)\
    }\
    if turns[1].Tokens == nil || turns[1].Tokens.OutputTokens != 7 {\
        t.Errorf("turn1 tokens wrong: %+v", turns[1].Tokens)\
    }\
}\
\
func TestClaudeSplitTurns_ToolResultIsNotATurn(t *testing.T) {\
    t.Parallel()\
    full := []byte(strings.Join([]string{\
        `{"type":"user","uuid":"u1","message":{"role":"user","content":"do it"}}`,\
        `{"type":"assistant","uuid":"a1","message":{"id":"m1","content":[{"type":"tool_use","id":"t1","name":"Bash","input":{}}],"usage":{"output_tokens":3}}}`,\
        `{"type":"user","uuid":"r1","message":{"content":[{"type":"tool_result","tool_use_id":"t1","content":"out"}]}}`,\
    }, "\n") + "\n")\
    turns, err := claudeImporter{}.SplitTurns(SessionFile{Path: filepath.Join(t.TempDir(), "s.jsonl"), SessionID: "s"}, full)\
    if err != nil {\
        t.Fatal(err)\
    }\
    if len(turns) != 1 {\
        t.Fatalf("tool_result must not start a turn; want 1 turn, got %d", len(turns))\
    }\
}\
```\
\
Acmd/entire/cli/agentimport/claude\_test.go+106\
\
```\
5 unmodified lines\
\
6\
7\
8\
9\
9\
10\
11\
12\
4 unmodified lines\
\
17\
18\
19\
20\
20\
21\
22\
23\
24\
25\
26\
27\
24\
28\
29\
30\
31\
32\
33\
30\
31\
32\
33\
34\
34\
35\
36\
37\
38\
39\
40\
41\
9 unmodified lines\
\
51\
52\
53\
50\
54\
55\
56\
57\
58\
55\
59\
60\
61\
62\
4 unmodified lines\
\
67\
68\
69\
66\
70\
71\
72\
73\
\
5 unmodified lines\
\
    "github.com/spf13/cobra"\
\
    "github.com/entireio/cli/cmd/entire/cli/importclaude"\
    "github.com/entireio/cli/cmd/entire/cli/agentimport"\
    "github.com/entireio/cli/cmd/entire/cli/paths"\
)\
\
4 unmodified lines\
\
        Hidden: true,\
        RunE:   func(c *cobra.Command, _ []string) error { return c.Help() },\
    }\
    cmd.AddCommand(newImportClaudeCodeCmd())\
    // One subcommand per registered importer, so adding an agent is just a new\
    // agentimport.Importer registration — no command wiring needed here.\
    for _, imp := range agentimport.All() {\
        cmd.AddCommand(newImportAgentCmd(imp))\
    }\
    return cmd\
}\
\
func newImportClaudeCodeCmd() *cobra.Command {\
func newImportAgentCmd(imp agentimport.Importer) *cobra.Command {\
    var pathFlag string\
    var dryRun bool\
    var sessions []string\
\
    cmd := &cobra.Command{\
        Use:   "claude-code",\
        Short: "Import existing Claude Code transcripts as local, read-only checkpoints",\
        Long: `Import pre-existing Claude Code transcripts for this repo (the past month)\
as local-only, read-only checkpoints. Imported history is searchable and\
explainable but is never pushed and not rewindable.`,\
        Use:   imp.Name(),\
        Short: fmt.Sprintf("Import existing %s transcripts as read-only checkpoints", imp.AgentType()),\
        Long: fmt.Sprintf(`Import pre-existing %s transcripts for this repo (the past month) as\
read-only checkpoints. Imported history is searchable and explainable but is\
not rewindable.`, imp.AgentType()),\
        Args: cobra.NoArgs,\
        RunE: func(c *cobra.Command, _ []string) error {\
            ctx := c.Context()\
9 unmodified lines\
\
            }\
            defer repo.Close()\
\
            res, err := importclaude.Run(ctx, repo, importclaude.Options{\
            res, err := agentimport.Run(ctx, repo, imp, agentimport.Options{\
                RepoRoot: repoRoot, OverridePath: pathFlag, SessionFilter: sessions,\
                Now: time.Now(), DryRun: dryRun,\
            })\
            if err != nil {\
                return fmt.Errorf("import claude-code: %w", err)\
                return fmt.Errorf("import %s: %w", imp.Name(), err)\
            }\
            verb := "Imported"\
            if dryRun {\
4 unmodified lines\
\
            return nil\
        },\
    }\
    cmd.Flags().StringVar(&pathFlag, "path", "", "Override the Claude projects directory to import from")\
    cmd.Flags().StringVar(&pathFlag, "path", "", "Override the transcript directory to import from")\
    cmd.Flags().BoolVar(&dryRun, "dry-run", false, "Report what would be imported without writing")\
    cmd.Flags().StringSliceVar(&sessions, "session", nil, "Import only these session IDs (repeatable)")\
    return cmd\
```\
\
Mcmd/entire/cli/import\_cmd.go+15/-11\
\
```\
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\
\
package importclaude\
\
import (\
    "fmt"\
    "os"\
    "path/filepath"\
    "slices"\
    "strings"\
    "time"\
\
    "github.com/entireio/cli/cmd/entire/cli/agent/claudecode"\
)\
\
// LookbackDays bounds how far back import reaches. Fixed this pass (no --since).\
const LookbackDays = 30\
\
// DiscoverSessions returns absolute paths of Claude transcript files for the\
// repo modified within the lookback window. overridePath replaces the default\
// ~/.claude/projects/<slug> dir. sessionFilter, when non-empty, keeps only\
// files whose stem (name without .jsonl) matches one of the entries.\
func DiscoverSessions(repoRoot, overridePath string, now time.Time, sessionFilter []string) ([]string, error) {\
    dir := overridePath\
    if dir == "" {\
        ag := &claudecode.ClaudeCodeAgent{}\
        d, err := ag.GetSessionDir(repoRoot)\
        if err != nil {\
            return nil, fmt.Errorf("resolve claude session dir: %w", err)\
        }\
        dir = d\
    }\
    entries, err := os.ReadDir(dir)\
    if err != nil {\
        if os.IsNotExist(err) {\
            return nil, nil // no transcripts for this repo\
        }\
        return nil, fmt.Errorf("read claude session dir: %w", err)\
    }\
    cutoff := now.AddDate(0, 0, -LookbackDays)\
    var out []string\
    for _, e := range entries {\
        if e.IsDir() || !strings.HasSuffix(e.Name(), ".jsonl") {\
            continue\
        }\
        stem := strings.TrimSuffix(e.Name(), ".jsonl")\
        if len(sessionFilter) > 0 && !slices.Contains(sessionFilter, stem) {\
            continue\
        }\
        info, err := e.Info()\
        if err != nil {\
            continue\
        }\
        if info.ModTime().Before(cutoff) {\
            continue\
        }\
        out = append(out, filepath.Join(dir, e.Name()))\
    }\
    slices.Sort(out)\
    return out, nil\
}\
```\
\
Dcmd/entire/cli/importclaude/discover.go-59\
\
```\
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\
\
package importclaude\
\
import (\
    "os"\
    "path/filepath"\
    "testing"\
    "time"\
)\
\
func TestDiscoverSessions_LookbackAndFilter(t *testing.T) {\
    t.Parallel()\
    dir := t.TempDir()\
    now := time.Date(2026, 6, 25, 12, 0, 0, 0, time.UTC)\
    writeAged := func(name string, age time.Duration) {\
        p := filepath.Join(dir, name)\
        if err := os.WriteFile(p, []byte("{}\n"), 0o644); err != nil {\
            t.Fatal(err)\
        }\
        mt := now.Add(-age)\
        if err := os.Chtimes(p, mt, mt); err != nil {\
            t.Fatal(err)\
        }\
    }\
    writeAged("recent.jsonl", 5*24*time.Hour)\
    writeAged("old.jsonl", 60*24*time.Hour)\
    writeAged("skip.txt", 1*time.Hour)\
\
    got, err := DiscoverSessions("", dir, now, nil)\
    if err != nil {\
        t.Fatal(err)\
    }\
    if len(got) != 1 || filepath.Base(got[0]) != "recent.jsonl" {\
        t.Fatalf("lookback filter wrong: %v", got)\
    }\
\
    writeAged("abc123.jsonl", 1*24*time.Hour)\
    got, err = DiscoverSessions("", dir, now, []string{"abc123"})\
    if err != nil {\
        t.Fatal(err)\
    }\
    if len(got) != 1 || filepath.Base(got[0]) != "abc123.jsonl" {\
        t.Fatalf("session filter wrong: %v", got)\
    }\
}\
\
func TestDiscoverSessions_MissingDirIsEmpty(t *testing.T) {\
    t.Parallel()\
    got, err := DiscoverSessions("", filepath.Join(t.TempDir(), "does-not-exist"), time.Now(), nil)\
    if err != nil {\
        t.Fatalf("missing dir should not error: %v", err)\
    }\
    if len(got) != 0 {\
        t.Fatalf("expected empty, got %v", got)\
    }\
}\
```\
\
Dcmd/entire/cli/importclaude/discover\_test.go-55\
\
```\
1\
2\
3\
4\
5\
6\
7\
8\
9\
10\
11\
12\
13\
14\
15\
16\
17\
18\
19\
20\
\
// Package importclaude imports pre-existing Claude Code transcripts into Entire\
// as read-only, commit-less ("orphaned") checkpoints on the local-only\
// entire/imports/v1 ref.\
package importclaude\
\
import (\
    "crypto/sha256"\
    "encoding/hex"\
\
    "github.com/entireio/cli/cmd/entire/cli/checkpoint/id"\
)\
\
// DeriveCheckpointID produces a stable 12-hex checkpoint ID for an imported\
// turn. Re-importing the same (sessionID, turnUUID) yields the same ID, which\
// is how import stays idempotent.\
func DeriveCheckpointID(sessionID, turnUUID string) id.CheckpointID {\
    sum := sha256.Sum256([]byte(sessionID + "/" + turnUUID))\
    hexID := hex.EncodeToString(sum[:6]) // 6 bytes = 12 lowercase hex chars\
    return id.MustCheckpointID(hexID)\
}\
```\
\
Dcmd/entire/cli/importclaude/id.go-20\
\
```\
1\
2\
3\
4\
5\
6\
7\
8\
9\
10\
11\
12\
13\
14\
15\
16\
17\
18\
19\
\
package importclaude\
\
import "testing"\
\
func TestDeriveCheckpointID_StableAndDistinct(t *testing.T) {\
    t.Parallel()\
    a := DeriveCheckpointID("sess", "turn-1")\
    b := DeriveCheckpointID("sess", "turn-1")\
    c := DeriveCheckpointID("sess", "turn-2")\
    if a != b {\
        t.Errorf("not deterministic: %s != %s", a, b)\
    }\
    if a == c {\
        t.Errorf("collision across turns: %s == %s", a, c)\
    }\
    if a.IsEmpty() {\
        t.Error("derived id is empty")\
    }\
}\
```\
\
Dcmd/entire/cli/importclaude/id\_test.go-19\
\
```\
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\
\
package importclaude\
\
import (\
    "context"\
    "fmt"\
    "os"\
    "path/filepath"\
    "strings"\
    "time"\
\
    "github.com/go-git/go-git/v6"\
\
    "github.com/entireio/cli/cmd/entire/cli/agent"\
    cp "github.com/entireio/cli/cmd/entire/cli/checkpoint"\
    "github.com/entireio/cli/cmd/entire/cli/checkpoint/id"\
    "github.com/entireio/cli/cmd/entire/cli/session"\
    "github.com/entireio/cli/redact"\
)\
\
const importVersion = 1\
\
// Options configures an import run.\
type Options struct {\
    RepoRoot      string\
    OverridePath  string\
    SessionFilter []string\
    Now           time.Time\
    DryRun        bool\
}\
\
// Result summarizes an import run.\
type Result struct {\
    SessionsScanned int\
    TurnsImported   int\
    TurnsSkipped    int\
}\
\
// Run imports the repo's Claude Code transcripts (within the lookback window)\
// as read-only, local-only checkpoints on entire/imports/v1. It is idempotent:\
// turns whose deterministic ID already exists are skipped.\
func Run(ctx context.Context, repo *git.Repository, opts Options) (Result, error) {\
    var res Result\
    files, err := DiscoverSessions(opts.RepoRoot, opts.OverridePath, opts.Now, opts.SessionFilter)\
    if err != nil {\
        return res, err\
    }\
\
    stores, err := cp.Open(ctx, repo, cp.OpenOptions{})\
    if err != nil {\
        return res, fmt.Errorf("open checkpoint store: %w", err)\
    }\
    existing := make(map[string]bool)\
    if infos, listErr := stores.Persistent.List(ctx); listErr == nil {\
        for _, in := range infos {\
            existing[in.CheckpointID.String()] = true\
        }\
    }\
\
    for _, path := range files {\
        res.SessionsScanned++\
        sessionID := strings.TrimSuffix(filepath.Base(path), ".jsonl")\
        full, readErr := os.ReadFile(path) //nolint:gosec // G304: path is a discovered .jsonl in the user's own Claude transcript dir\
        if readErr != nil {\
            return res, fmt.Errorf("read %s: %w", path, readErr)\
        }\
        subagentsDir := filepath.Join(filepath.Dir(path), sessionID, "subagents")\
        turns, splitErr := SplitTurns(full, subagentsDir)\
        if splitErr != nil {\
            return res, splitErr\
        }\
        for _, turn := range turns {\
            cid := DeriveCheckpointID(sessionID, turn.UUID)\
            if existing[cid.String()] {\
                res.TurnsSkipped++\
                continue\
            }\
            if opts.DryRun {\
                res.TurnsImported++ // counts what would import\
                continue\
            }\
            if err := writeTurn(ctx, stores, cid, sessionID, path, full, turn); err != nil {\
                return res, err\
            }\
            existing[cid.String()] = true\
            res.TurnsImported++\
        }\
    }\
    return res, nil\
}\
\
func writeTurn(ctx context.Context, stores *cp.Stores, cid id.CheckpointID, sessionID, path string, full []byte, turn Turn) error {\
    red, err := redact.JSONLBytes(full)\
    if err != nil {\
        return fmt.Errorf("redact transcript: %w", err)\
    }\
    prov := &cp.Provenance{\
        Source: "claude-code", TranscriptPath: path, SessionID: sessionID,\
        TurnUUID: turn.UUID, ParentUUID: turn.ParentUUID,\
        LineStart: turn.LineStart, LineEnd: turn.LineEnd,\
        ContentHash: turn.ContentHash, ImportVersion: importVersion,\
    }\
    if err := stores.Persistent.Write(ctx, cp.Session(cp.WriteOptions{\
        CheckpointID:              cid,\
        SessionID:                 sessionID,\
        CreatedAt:                 turn.CreatedAt,\
        Strategy:                  "import",\
        Kind:                      string(session.KindImported),\
        Agent:                     agent.AgentTypeClaudeCode,\
        Model:                     turn.Model,\
        Transcript:                red,\
        Prompts:                   []string{turn.Prompt},\
        CheckpointsCount:          1,\
        CheckpointTranscriptStart: turn.LineStart,\
        TokenUsage:                turn.Tokens,\
        Provenance:                prov,\
    })); err != nil {\
        return fmt.Errorf("write imported checkpoint %s: %w", cid, err)\
    }\
    return nil\
}\
```\
\
Dcmd/entire/cli/importclaude/importer.go-120\
\
```\
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\
\
package importclaude\
\
import (\
    "context"\
    "os"\
    "path/filepath"\
    "strings"\
    "testing"\
    "time"\
\
    "github.com/go-git/go-git/v6"\
    "github.com/go-git/go-git/v6/plumbing/object"\
\
    cp "github.com/entireio/cli/cmd/entire/cli/checkpoint"\
    "github.com/entireio/cli/cmd/entire/cli/testutil"\
)\
\
func initRepoWithCommit(t *testing.T) (*git.Repository, string) {\
    t.Helper()\
    repoDir := t.TempDir()\
    testutil.InitRepo(t, repoDir)\
    repo, err := git.PlainOpen(repoDir)\
    if err != nil {\
        t.Fatal(err)\
    }\
    wt, err := repo.Worktree()\
    if err != nil {\
        t.Fatal(err)\
    }\
    testutil.WriteFile(t, repoDir, "f.txt", "x")\
    if _, err := wt.Add("f.txt"); err != nil {\
        t.Fatal(err)\
    }\
    if _, err := wt.Commit("init", &git.CommitOptions{\
        Author: &object.Signature{Name: "Test", Email: "test@test.com"},\
    }); err != nil {\
        t.Fatal(err)\
    }\
    return repo, repoDir\
}\
\
func writeFixtureSession(t *testing.T, dir, name string) {\
    t.Helper()\
    content := strings.Join([]string{\
        `{"type":"user","uuid":"u1","timestamp":"2026-06-20T00:00:00Z","message":{"role":"user","content":"first"}}`,\
        `{"type":"assistant","uuid":"a1","message":{"id":"m1","model":"claude-x","content":[{"type":"text","text":"ok"}],"usage":{"output_tokens":5}}}`,\
        `{"type":"user","uuid":"u2","timestamp":"2026-06-20T00:01:00Z","message":{"role":"user","content":"second"}}`,\
    }, "\n") + "\n"\
    if err := os.WriteFile(filepath.Join(dir, name), []byte(content), 0o644); err != nil {\
        t.Fatal(err)\
    }\
}\
\
func TestRun_ImportsAndIsIdempotent(t *testing.T) {\
    t.Parallel()\
    repo, repoDir := initRepoWithCommit(t)\
    claudeDir := t.TempDir()\
    writeFixtureSession(t, claudeDir, "sess1.jsonl")\
\
    opts := Options{RepoRoot: repoDir, OverridePath: claudeDir, Now: time.Date(2026, 6, 25, 0, 0, 0, 0, time.UTC)}\
\
    res, err := Run(context.Background(), repo, opts)\
    if err != nil {\
        t.Fatal(err)\
    }\
    if res.TurnsImported != 2 {\
        t.Fatalf("want 2 imported, got %+v", res)\
    }\
\
    res2, err := Run(context.Background(), repo, opts)\
    if err != nil {\
        t.Fatal(err)\
    }\
    if res2.TurnsImported != 0 || res2.TurnsSkipped != 2 {\
        t.Fatalf("re-run not idempotent: %+v", res2)\
    }\
\
    stores, err := cp.Open(context.Background(), repo, cp.OpenOptions{})\
    if err != nil {\
        t.Fatal(err)\
    }\
    infos, err := stores.Persistent.List(context.Background())\
    if err != nil {\
        t.Fatal(err)\
    }\
    if len(infos) != 2 {\
        t.Fatalf("expected 2 imported checkpoints on v1, got %+v", infos)\
    }\
    for _, in := range infos {\
        if !in.Imported {\
            t.Fatalf("checkpoint %s missing Imported flag: %+v", in.CheckpointID, in)\
        }\
    }\
}\
\
func TestRun_DryRunWritesNothing(t *testing.T) {\
    t.Parallel()\
    repo, repoDir := initRepoWithCommit(t)\
    claudeDir := t.TempDir()\
    writeFixtureSession(t, claudeDir, "sess1.jsonl")\
\
    res, err := Run(context.Background(), repo, Options{\
        RepoRoot: repoDir, OverridePath: claudeDir, DryRun: true,\
        Now: time.Date(2026, 6, 25, 0, 0, 0, 0, time.UTC),\
    })\
    if err != nil {\
        t.Fatal(err)\
    }\
    if res.TurnsImported != 2 {\
        t.Fatalf("dry-run should count 2 turns, got %+v", res)\
    }\
\
    stores, err := cp.Open(context.Background(), repo, cp.OpenOptions{})\
    if err != nil {\
        t.Fatal(err)\
    }\
    infos, err := stores.Persistent.List(context.Background())\
    if err != nil {\
        t.Fatal(err)\
    }\
    if len(infos) != 0 {\
        t.Fatalf("dry-run must not write, got %+v", infos)\
    }\
}\
```\
\
Dcmd/entire/cli/importclaude/importer\_test.go-124\
\
```\
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\
\
package importclaude\
\
import (\
    "crypto/sha256"\
    "encoding/hex"\
    "encoding/json"\
    "fmt"\
    "strings"\
    "time"\
\
    "github.com/entireio/cli/cmd/entire/cli/agent/claudecode"\
    "github.com/entireio/cli/cmd/entire/cli/agent/types"\
    "github.com/entireio/cli/cmd/entire/cli/transcript"\
)\
\
// Turn is one user-prompt turn extracted from a session transcript. Line\
// offsets are in raw-line space (newline-counted), matching the offsets used by\
// transcript.SliceFromLine and the agent token-usage helpers.\
type Turn struct {\
    LineStart, LineEnd int\
    UUID, ParentUUID   string\
    Prompt, Model      string\
    CreatedAt          time.Time\
    Tokens             *types.TokenUsage\
    ContentHash        string\
}\
\
// extraFields are line fields not modeled by transcript.Line.\
type extraFields struct {\
    ParentUUID string `json:"parentUuid"`\
    Timestamp  string `json:"timestamp"`\
    Message    struct {\
        Model string `json:"model"`\
    } `json:"message"`\
}\
\
// SplitTurns produces one Turn per user-prompt line. Token usage for each turn\
// is computed on the slice [LineStart, LineEnd) so turns don't double-count\
// later turns. tool_result lines (Type == "user" but no text content) do not\
// start a turn.\
func SplitTurns(full []byte, subagentsDir string) ([]Turn, error) {\
    rawLines := splitRawLines(full)\
\
    // Identify user-prompt turn starts in raw-line space.\
    var starts []int\
    for i, raw := range rawLines {\
        if isUserPromptLine(raw) {\
            starts = append(starts, i)\
        }\
    }\
\
    ag := &claudecode.ClaudeCodeAgent{}\
    turns := make([]Turn, 0, len(starts))\
    for k, start := range starts {\
        end := len(rawLines)\
        if k+1 < len(starts) {\
            end = starts[k+1]\
        }\
\
        // Bound token usage to [start, end): truncate to the first `end` lines,\
        // then let the agent helper slice from `start`.\
        truncated := joinLines(rawLines[:end])\
        tokens, err := ag.CalculateTotalTokenUsage(truncated, start, subagentsDir)\
        if err != nil {\
            return nil, fmt.Errorf("token usage for turn %d: %w", k, err)\
        }\
\
        var rec struct {\
            UUID       string          `json:"uuid"`\
            Message    json.RawMessage `json:"message"`\
            ParentUUID string          `json:"parentUuid"`\
            Timestamp  string          `json:"timestamp"`\
        }\
        if err := json.Unmarshal(rawLines[start], &rec); err != nil {\
            // Already validated as a user-prompt line in isUserPromptLine; skip\
            // defensively if it somehow fails to parse here.\
            continue\
        }\
        ts, parseErr := time.Parse(time.RFC3339, rec.Timestamp)\
        if parseErr != nil {\
            ts = time.Time{}\
        }\
\
        slice := joinLines(rawLines[start:end])\
        sum := sha256.Sum256(slice)\
\
        turns = append(turns, Turn{\
            LineStart:   start,\
            LineEnd:     end,\
            UUID:        rec.UUID,\
            ParentUUID:  rec.ParentUUID,\
            Prompt:      transcript.ExtractUserContent(rec.Message),\
            Model:       modelInRange(rawLines, start, end),\
            CreatedAt:   ts,\
            Tokens:      tokens,\
            ContentHash: "sha256:" + hex.EncodeToString(sum[:]),\
        })\
    }\
    return turns, nil\
}\
\
// modelInRange returns the model from the first assistant message within\
// [start, end), or "" when none carries one. The model lives on assistant\
// lines, not the user-prompt line.\
func modelInRange(rawLines [][]byte, start, end int) string {\
    for i := start; i < end && i < len(rawLines); i++ {\
        var line transcript.Line\
        if err := json.Unmarshal(rawLines[i], &line); err != nil {\
            continue\
        }\
        if line.Type != "assistant" {\
            continue\
        }\
        var ex extraFields\
        if err := json.Unmarshal(rawLines[i], &ex); err != nil {\
            continue\
        }\
        if ex.Message.Model != "" {\
            return ex.Message.Model\
        }\
    }\
    return ""\
}\
\
// isUserPromptLine reports whether a raw JSONL line is a genuine user-prompt\
// turn start: type "user" (or role "user") with non-empty extractable text.\
// tool_result lines are type "user" but carry no text, so they return false.\
func isUserPromptLine(raw []byte) bool {\
    var line transcript.Line\
    if err := json.Unmarshal(raw, &line); err != nil {\
        return false\
    }\
    typ := line.Type\
    if typ == "" {\
        typ = line.Role\
    }\
    if typ != "user" {\
        return false\
    }\
    return transcript.ExtractUserContent(line.Message) != ""\
}\
\
// splitRawLines splits content into raw lines in the same index space as\
// transcript.SliceFromLine (newline-counted). Trailing empty segment from a\
// final newline is dropped.\
func splitRawLines(content []byte) [][]byte {\
    if len(content) == 0 {\
        return nil\
    }\
    parts := strings.Split(string(content), "\n")\
    if len(parts) > 0 && parts[len(parts)-1] == "" {\
        parts = parts[:len(parts)-1]\
    }\
    out := make([][]byte, len(parts))\
    for i, p := range parts {\
        out[i] = []byte(p)\
    }\
    return out\
}\
\
// joinLines reassembles raw lines into newline-terminated bytes.\
func joinLines(lines [][]byte) []byte {\
    if len(lines) == 0 {\
        return nil\
    }\
    strs := make([]string, len(lines))\
    for i, l := range lines {\
        strs[i] = string(l)\
    }\
    return []byte(strings.Join(strs, "\n") + "\n")\
}\
```\
\
Dcmd/entire/cli/importclaude/turns.go-171\
\
```\
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\
\
package importclaude\
\
import (\
    "strings"\
    "testing"\
)\
\
func TestSplitTurns_TwoPromptsBoundedByNext(t *testing.T) {\
    t.Parallel()\
    full := []byte(strings.Join([]string{\
        `{"type":"user","uuid":"u1","parentUuid":"","timestamp":"2026-06-20T00:00:00Z","message":{"role":"user","content":"first"}}`,\
        `{"type":"assistant","uuid":"a1","message":{"id":"m1","model":"claude-x","content":[{"type":"text","text":"ok"}],"usage":{"input_tokens":10,"output_tokens":5}}}`,\
        `{"type":"user","uuid":"u2","parentUuid":"a1","timestamp":"2026-06-20T00:01:00Z","message":{"role":"user","content":"second"}}`,\
        `{"type":"assistant","uuid":"a2","message":{"id":"m2","model":"claude-x","content":[{"type":"text","text":"done"}],"usage":{"input_tokens":20,"output_tokens":7}}}`,\
    }, "\n") + "\n")\
\
    turns, err := SplitTurns(full, "")\
    if err != nil {\
        t.Fatal(err)\
    }\
    if len(turns) != 2 {\
        t.Fatalf("want 2 turns, got %d", len(turns))\
    }\
    if turns[0].LineStart != 0 || turns[0].LineEnd != 2 {\
        t.Errorf("turn0 bounds = [%d,%d), want [0,2)", turns[0].LineStart, turns[0].LineEnd)\
    }\
    if turns[0].Prompt != "first" || turns[1].Prompt != "second" {\
        t.Errorf("prompts = %q,%q", turns[0].Prompt, turns[1].Prompt)\
    }\
    if turns[0].UUID != "u1" || turns[1].ParentUUID != "a1" {\
        t.Errorf("uuid/parent wrong: %+v %+v", turns[0], turns[1])\
    }\
    if turns[0].Model != "claude-x" {\
        t.Errorf("turn0 model = %q, want claude-x", turns[0].Model)\
    }\
    if turns[0].Tokens == nil || turns[0].Tokens.OutputTokens != 5 {\
        t.Errorf("turn0 tokens not bounded to its own turn: %+v", turns[0].Tokens)\
    }\
    if turns[1].Tokens == nil || turns[1].Tokens.OutputTokens != 7 {\
        t.Errorf("turn1 tokens wrong: %+v", turns[1].Tokens)\
    }\
}\
\
func TestSplitTurns_ToolResultIsNotATurn(t *testing.T) {\
    t.Parallel()\
    full := []byte(strings.Join([]string{\
        `{"type":"user","uuid":"u1","message":{"role":"user","content":"do it"}}`,\
        `{"type":"assistant","uuid":"a1","message":{"id":"m1","content":[{"type":"tool_use","id":"t1","name":"Bash","input":{}}],"usage":{"output_tokens":3}}}`,\
        `{"type":"user","uuid":"r1","message":{"content":[{"type":"tool_result","tool_use_id":"t1","content":"out"}]}}`,\
    }, "\n") + "\n")\
    turns, err := SplitTurns(full, "")\
    if err != nil {\
        t.Fatal(err)\
    }\
    if len(turns) != 1 {\
        t.Fatalf("tool_result must not start a turn; want 1 turn, got %d", len(turns))\
    }\
}\
```\
\
Dcmd/entire/cli/importclaude/turns\_test.go-58\
\
```\
9 unmodified lines\
\
10\
11\
12\
13\
13\
14\
15\
16\
27 unmodified lines\
\
44\
45\
46\
47\
47\
48\
49\
50\
\
9 unmodified lines\
\
    "github.com/stretchr/testify/require"\
\
    "github.com/entireio/cli/cmd/entire/cli/importclaude"\
    "github.com/entireio/cli/cmd/entire/cli/agentimport"\
)\
\
func TestImportClaudeCode_EndToEnd(t *testing.T) {\
27 unmodified lines\
\
    // 4. explain resolves an imported checkpoint by ID (regression: explain once\
    //    only consulted the committed/shadow paths and missed imports).\
    importedID := importclaude.DeriveCheckpointID(sessionID, "u1").String()\
    importedID := agentimport.DeriveCheckpointID(sessionID, "u1").String()\
    explainOut := env.RunCLI("checkpoint", "explain", importedID)\
    require.Contains(t, explainOut, "first", "explain should show the imported turn's prompt; got: %s", explainOut)\
```\
\
Mcmd/entire/cli/integration\_test/import\_claude\_test.go+2/-2\
\
```\
13 unmodified lines\
\
14\
15\
16\
17\
17\
18\
19\
1151 unmodified lines\
\
1171\
1172\
1173\
1175\
1176\
1177\
1178\
1174\
1175\
1176\
688 unmodified lines\
\
1865\
1866\
1867\
1873\
1874\
1875\
1876\
1877\
1878\
1879\
1880\
1881\
1882\
1883\
1884\
1885\
1886\
1887\
1888\
1889\
1890\
1891\
1892\
1893\
1894\
1895\
1896\
1897\
1898\
1899\
1900\
1901\
1902\
1903\
1904\
1905\
1906\
1907\
1908\
1909\
1910\
1911\
1912\
1913\
1914\
1915\
1916\
1917\
1918\
1919\
1920\
1921\
1922\
1923\
1924\
1925\
1926\
1927\
1928\
1929\
1930\
1931\
1932\
1933\
1934\
1868\
1869\
1870\
\
13 unmodified lines\
\
    "github.com/entireio/cli/cmd/entire/cli/agent/external"\
    "github.com/entireio/cli/cmd/entire/cli/agent/types"\
    "github.com/entireio/cli/cmd/entire/cli/gitremote"\
    "github.com/entireio/cli/cmd/entire/cli/importclaude"\
    "github.com/entireio/cli/cmd/entire/cli/interactive"\
    "github.com/entireio/cli/cmd/entire/cli/logging"\
    "github.com/entireio/cli/cmd/entire/cli/paths"\
1151 unmodified lines\
\
        return nil\
    }\
\
    if err := promptImportClaudeContext(ctx, w, opts); err != nil {\
        return err\
    }\
\
    fmt.Fprintln(w, "\nReady.")\
\
    // Note about empty repos at the end, after setup is complete\
688 unmodified lines\
\
    return nil\
}\
\
// promptImportClaudeContext offers, at enable time, to import the repo's\
// existing Claude Code transcripts (past month) as local, read-only history.\
// No-op when non-interactive, under --yes, or when nothing is found.\
func promptImportClaudeContext(ctx context.Context, w io.Writer, opts EnableOptions) error {\
    if opts.Yes || !interactive.CanPromptInteractively() {\
        return nil\
    }\
    repoRoot, err := paths.WorktreeRoot(ctx)\
    if err != nil {\
        return nil\
    }\
    now := time.Now()\
    files, err := importclaude.DiscoverSessions(repoRoot, "", now, nil)\
    if err != nil || len(files) == 0 {\
        return nil\
    }\
    turnCount := 0\
    for _, f := range files {\
        data, rerr := os.ReadFile(f) //nolint:gosec // G304: f is a discovered .jsonl in the user's own Claude transcript dir\
        if rerr != nil {\
            continue\
        }\
        turns, terr := importclaude.SplitTurns(data, "")\
        if terr != nil {\
            continue\
        }\
        turnCount += len(turns)\
    }\
    if turnCount == 0 {\
        return nil\
    }\
\
    doImport := false\
    form := NewAccessibleForm(\
        huh.NewGroup(\
            huh.NewConfirm().\
                Title(fmt.Sprintf("Found %d past Claude session turn(s) for this repo from the past month.", turnCount)).\
                Description("Import them as local, read-only history? It stays on your machine and is never pushed.").\
                Affirmative("Yes").\
                Negative("No").\
                Value(&doImport),\
        ),\
    )\
    if err := form.Run(); err != nil {\
        return fmt.Errorf("import prompt: %w", err)\
    }\
    if !doImport {\
        return nil\
    }\
    repo, err := strategy.OpenRepository(ctx)\
    if err != nil {\
        return fmt.Errorf("open repository: %w", err)\
    }\
    defer repo.Close()\
    res, err := importclaude.Run(ctx, repo, importclaude.Options{RepoRoot: repoRoot, Now: now})\
    if err != nil {\
        return fmt.Errorf("import claude history: %w", err)\
    }\
    fmt.Fprintf(w, "  ✓ Imported %d turn(s) of Claude history (local, read-only)\n", res.TurnsImported)\
    return nil\
}\
\
// promptTelemetryConsent asks the user if they want to enable telemetry.\
// It modifies settings.Telemetry based on the user's choice or flags.\
// The caller is responsible for saving settings.\
```\
\
Mcmd/entire/cli/setup.go-67\
\
```\
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\
\
package cli\
\
import (\
    "bytes"\
    "context"\
    "os"\
    "path/filepath"\
    "testing"\
\
    "github.com/entireio/cli/cmd/entire/cli/checkpoint"\
    "github.com/entireio/cli/cmd/entire/cli/strategy"\
    "github.com/entireio/cli/cmd/entire/cli/testutil"\
)\
\
func TestPromptImportClaudeContext_SkipsWhenNoTranscripts(t *testing.T) {\
    // Not parallel: t.Chdir + t.Setenv.\
    repoDir := t.TempDir()\
    testutil.InitRepo(t, repoDir)\
    t.Chdir(repoDir)\
    t.Setenv("ENTIRE_TEST_CLAUDE_PROJECT_DIR", t.TempDir()) // empty dir\
    t.Setenv("ENTIRE_TEST_TTY", "0")                        // non-interactive\
\
    var out bytes.Buffer\
    if err := promptImportClaudeContext(context.Background(), &out, EnableOptions{}); err != nil {\
        t.Fatal(err)\
    }\
    if out.Len() != 0 {\
        t.Fatalf("expected silent no-op, got %q", out.String())\
    }\
}\
\
func TestPromptImportClaudeContext_NonInteractiveDoesNotImport(t *testing.T) {\
    // Not parallel: t.Chdir + t.Setenv.\
    repoDir := t.TempDir()\
    testutil.InitRepo(t, repoDir)\
    testutil.WriteFile(t, repoDir, "f.txt", "x")\
    testutil.GitAdd(t, repoDir, "f.txt")\
    testutil.GitCommit(t, repoDir, "init")\
    t.Chdir(repoDir)\
\
    cdir := t.TempDir()\
    if err := os.WriteFile(filepath.Join(cdir, "s.jsonl"),\
        []byte(`{"type":"user","uuid":"u1","message":{"role":"user","content":"hi"}}`+"\n"), 0o644); err != nil {\
        t.Fatal(err)\
    }\
    t.Setenv("ENTIRE_TEST_CLAUDE_PROJECT_DIR", cdir)\
    t.Setenv("ENTIRE_TEST_TTY", "0") // non-interactive => must not prompt or import\
\
    var out bytes.Buffer\
    if err := promptImportClaudeContext(context.Background(), &out, EnableOptions{}); err != nil {\
        t.Fatal(err)\
    }\
\
    repo, err := strategy.OpenRepository(context.Background())\
    if err != nil {\
        t.Fatal(err)\
    }\
    defer repo.Close()\
    stores, err := checkpoint.Open(context.Background(), repo, checkpoint.OpenOptions{})\
    if err != nil {\
        t.Fatal(err)\
    }\
    infos, err := stores.Persistent.List(context.Background())\
    if err != nil {\
        t.Fatal(err)\
    }\
    if len(infos) != 0 {\
        t.Fatalf("non-interactive import should not have written: %+v", infos)\
    }\
}\
```\
\
Dcmd/entire/cli/setup\_import\_prompt\_test.go-70