@dosu/cli 0.48.2 → 0.49.0-alpha.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,847 @@
1
+ From 73638f3f88691ee8bdb2051c4fbe0e9a5bcf4011 Mon Sep 17 00:00:00 2001
2
+ From: Caspian Zhao <caspian.zhao@outlook.com>
3
+ Date: Thu, 20 Aug 2026 09:25:59 -0700
4
+ Subject: [PATCH 1/2] feat: scope indexing to selected project roots
5
+
6
+ ---
7
+ internal/sources/claude.go | 19 ++-
8
+ internal/sources/codex.go | 16 +++
9
+ internal/sources/cursor.go | 4 +
10
+ internal/sources/project_scope.go | 160 +++++++++++++++++++++++++
11
+ internal/sources/project_scope_test.go | 133 ++++++++++++++++++++
12
+ internal/sources/scan_progress.go | 31 +++++
13
+ internal/sources/util.go | 1 +
14
+ 7 files changed, 363 insertions(+), 1 deletion(-)
15
+ create mode 100644 internal/sources/project_scope.go
16
+ create mode 100644 internal/sources/project_scope_test.go
17
+ create mode 100644 internal/sources/scan_progress.go
18
+
19
+ diff --git a/internal/sources/claude.go b/internal/sources/claude.go
20
+ index 25258c9..ded140c 100644
21
+ --- a/internal/sources/claude.go
22
+ +++ b/internal/sources/claude.go
23
+ @@ -37,7 +37,24 @@ func LoadClaude() []model.Session {
24
+ // ClaudeFiles lists the transcript files under the Claude root without parsing
25
+ // them — a cheap count for diagnostics.
26
+ func ClaudeFiles() []string {
27
+ - return walkFiles(ClaudeRoot(), ClaudeFileWanted)
28
+ + files := walkFiles(ClaudeRoot(), ClaudeFileWanted)
29
+ + if len(projectScopeRoots()) == 0 {
30
+ + return files
31
+ + }
32
+ + out := make([]string, 0, len(files))
33
+ + for _, path := range files {
34
+ + base := ClaudeProjectDirBase(path)
35
+ + if resolved := ResolveEncodedPath(base); resolved != "" {
36
+ + if projectScopeMatchesPath(resolved) {
37
+ + out = append(out, path)
38
+ + }
39
+ + continue
40
+ + }
41
+ + if projectScopeMatchesProject(claudeProjectName(claudeProjectDir(path))) {
42
+ + out = append(out, path)
43
+ + }
44
+ + }
45
+ + return out
46
+ }
47
+
48
+ // ClaudeFileWanted reports whether a path under the Claude root belongs in
49
+ diff --git a/internal/sources/codex.go b/internal/sources/codex.go
50
+ index 6c924cc..4d02567 100644
51
+ --- a/internal/sources/codex.go
52
+ +++ b/internal/sources/codex.go
53
+ @@ -29,6 +29,7 @@ func CodexRoot() string {
54
+ func LoadCodex() []model.Session {
55
+ root := CodexRoot()
56
+ files := walkFiles(filepath.Join(root, "sessions"), codexRolloutWanted)
57
+ + files = scopeCodexRollouts(files)
58
+ ss := parseFiles(files, ParseCodexRollout)
59
+ // history.jsonl repeats the prompts of sessions whose rollout deja has
60
+ // already read, with a coarser timestamp — so the ingest de-duplicator,
61
+ @@ -58,12 +59,27 @@ func codexRolloutWanted(p string) bool {
62
+ func CodexFiles() []string {
63
+ root := CodexRoot()
64
+ files := walkFiles(filepath.Join(root, "sessions"), codexRolloutWanted)
65
+ + files = scopeCodexRollouts(files)
66
+ if hist := filepath.Join(root, "history.jsonl"); fileExists(hist) {
67
+ files = append(files, hist)
68
+ }
69
+ return files
70
+ }
71
+
72
+ +func scopeCodexRollouts(files []string) []string {
73
+ + if len(projectScopeRoots()) == 0 {
74
+ + return files
75
+ + }
76
+ + out := make([]string, 0, len(files))
77
+ + for _, path := range files {
78
+ + _, cwd := codexRolloutHead(path)
79
+ + if projectScopeMatchesPath(cwd) {
80
+ + out = append(out, path)
81
+ + }
82
+ + }
83
+ + return out
84
+ +}
85
+ +
86
+ func ParseCodexHistory(path string) ([]model.Session, error) {
87
+ return ParseCodexHistoryFromOffset(path, 0)
88
+ }
89
+ diff --git a/internal/sources/cursor.go b/internal/sources/cursor.go
90
+ index 3cb1568..7899d4a 100644
91
+ --- a/internal/sources/cursor.go
92
+ +++ b/internal/sources/cursor.go
93
+ @@ -95,6 +95,9 @@ func CursorTranscripts() []string {
94
+ return nil
95
+ }
96
+ if cursorPathHasDir(p, "agent-transcripts") {
97
+ + if len(projectScopeRoots()) > 0 && !projectScopeMatchesProject(cursorTranscriptProject(p)) {
98
+ + return nil
99
+ + }
100
+ out = append(out, p)
101
+ }
102
+ return nil
103
+ @@ -123,6 +126,7 @@ func fileExists(p string) bool {
104
+ func LoadCursor() []model.Session {
105
+ var ss []model.Session
106
+ for _, db := range CursorDBs() {
107
+ + reportScanPath(db)
108
+ got, _ := ParseCursorDB(db)
109
+ ss = append(ss, got...)
110
+ }
111
+ diff --git a/internal/sources/project_scope.go b/internal/sources/project_scope.go
112
+ new file mode 100644
113
+ index 0000000..78e6add
114
+ --- /dev/null
115
+ +++ b/internal/sources/project_scope.go
116
+ @@ -0,0 +1,160 @@
117
+ +package sources
118
+ +
119
+ +import (
120
+ + "net/url"
121
+ + "os"
122
+ + "os/exec"
123
+ + "path/filepath"
124
+ + "strings"
125
+ + "sync"
126
+ +)
127
+ +
128
+ +type scopeRepositoryIdentity struct {
129
+ + commonDir string
130
+ + remote string
131
+ +}
132
+ +
133
+ +var scopeRepositoryCache sync.Map
134
+ +
135
+ +// projectScopeRoots is the private runtime boundary used by Dosu Drive. An
136
+ +// empty value preserves deja's normal all-history behavior. Paths use the host
137
+ +// path-list separator so more than one selected repository can be supplied.
138
+ +func projectScopeRoots() []string {
139
+ + raw := os.Getenv("DEJA_PROJECT_ROOTS")
140
+ + if raw == "" {
141
+ + return nil
142
+ + }
143
+ + var roots []string
144
+ + seen := map[string]bool{}
145
+ + for _, value := range filepath.SplitList(raw) {
146
+ + value = canonicalScopePath(value)
147
+ + if value == "" || seen[value] {
148
+ + continue
149
+ + }
150
+ + seen[value] = true
151
+ + roots = append(roots, value)
152
+ + }
153
+ + return roots
154
+ +}
155
+ +
156
+ +func canonicalScopePath(path string) string {
157
+ + path = strings.TrimSpace(path)
158
+ + if path == "" {
159
+ + return ""
160
+ + }
161
+ + if absolute, err := filepath.Abs(path); err == nil {
162
+ + path = absolute
163
+ + }
164
+ + if resolved, err := filepath.EvalSymlinks(path); err == nil {
165
+ + path = resolved
166
+ + }
167
+ + return filepath.Clean(path)
168
+ +}
169
+ +
170
+ +func projectScopeMatchesPath(path string) bool {
171
+ + path = canonicalScopePath(path)
172
+ + if path == "" {
173
+ + return false
174
+ + }
175
+ + for _, root := range projectScopeRoots() {
176
+ + if rel, err := filepath.Rel(root, path); err == nil &&
177
+ + (rel == "." || (rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)) && !filepath.IsAbs(rel))) {
178
+ + return true
179
+ + }
180
+ + }
181
+ + if candidate, ok := scopeRepositoryAt(path); ok {
182
+ + for _, root := range projectScopeRoots() {
183
+ + selected, selectedOK := scopeRepositoryAt(root)
184
+ + if !selectedOK {
185
+ + continue
186
+ + }
187
+ + if candidate.commonDir != "" && candidate.commonDir == selected.commonDir {
188
+ + return true
189
+ + }
190
+ + if candidate.remote != "" && candidate.remote == selected.remote {
191
+ + return true
192
+ + }
193
+ + }
194
+ + // An existing repository is identifiable. A different remote with the
195
+ + // same basename is a different project, not a related worktree.
196
+ + return false
197
+ + }
198
+ + // Deleted worktrees no longer have Git metadata to prove. Keep the current
199
+ + // whole-segment fallback so historical sessions from a removed worktree do
200
+ + // not disappear merely because its checkout was cleaned up.
201
+ + return projectScopeMatchesProject(projectName(path))
202
+ +}
203
+ +
204
+ +func scopeRepositoryAt(path string) (scopeRepositoryIdentity, bool) {
205
+ + path = canonicalScopePath(path)
206
+ + if path == "" {
207
+ + return scopeRepositoryIdentity{}, false
208
+ + }
209
+ + if cached, ok := scopeRepositoryCache.Load(path); ok {
210
+ + identity := cached.(scopeRepositoryIdentity)
211
+ + return identity, identity.commonDir != "" || identity.remote != ""
212
+ + }
213
+ + if _, err := os.Stat(path); err != nil {
214
+ + scopeRepositoryCache.Store(path, scopeRepositoryIdentity{})
215
+ + return scopeRepositoryIdentity{}, false
216
+ + }
217
+ + identity := scopeRepositoryIdentity{
218
+ + commonDir: gitScopeValue(path, "rev-parse", "--path-format=absolute", "--git-common-dir"),
219
+ + remote: normalizeScopeRemote(gitScopeValue(path, "config", "--get", "remote.origin.url")),
220
+ + }
221
+ + identity.commonDir = canonicalScopePath(identity.commonDir)
222
+ + scopeRepositoryCache.Store(path, identity)
223
+ + return identity, identity.commonDir != "" || identity.remote != ""
224
+ +}
225
+ +
226
+ +func gitScopeValue(path string, args ...string) string {
227
+ + cmdArgs := append([]string{"-C", path}, args...)
228
+ + out, err := exec.Command("git", cmdArgs...).Output()
229
+ + if err != nil {
230
+ + return ""
231
+ + }
232
+ + return strings.TrimSpace(string(out))
233
+ +}
234
+ +
235
+ +func normalizeScopeRemote(remote string) string {
236
+ + remote = strings.TrimSpace(remote)
237
+ + if remote == "" {
238
+ + return ""
239
+ + }
240
+ + if parsed, err := url.Parse(remote); err == nil && parsed.Host != "" {
241
+ + remote = parsed.Host + "/" + strings.TrimPrefix(parsed.Path, "/")
242
+ + } else if colon := strings.Index(remote, ":"); colon > 0 && !filepath.IsAbs(remote) {
243
+ + host := remote[:colon]
244
+ + if at := strings.LastIndex(host, "@"); at >= 0 {
245
+ + host = host[at+1:]
246
+ + }
247
+ + remote = host + "/" + strings.TrimPrefix(remote[colon+1:], "/")
248
+ + }
249
+ + remote = strings.TrimSuffix(strings.TrimSuffix(remote, "/"), ".git")
250
+ + return strings.ToLower(remote)
251
+ +}
252
+ +
253
+ +// projectScopeMatchesProject keeps the project-name fallback deja and Dosu
254
+ +// already expose: another checkout/worktree named "dosu" belongs to a selected
255
+ +// "dosu" repository. Exact-path matching above wins first; this fallback is
256
+ +// deliberately no broader than whole path segments.
257
+ +func projectScopeMatchesProject(project string) bool {
258
+ + project = normalizeScopeProject(project)
259
+ + if project == "" || project == "-" {
260
+ + return false
261
+ + }
262
+ + for _, root := range projectScopeRoots() {
263
+ + base := normalizeScopeProject(filepath.Base(root))
264
+ + parentBase := normalizeScopeProject(filepath.Join(filepath.Base(filepath.Dir(root)), filepath.Base(root)))
265
+ + if project == base || project == parentBase || strings.HasSuffix(project, "/"+base) {
266
+ + return true
267
+ + }
268
+ + }
269
+ + return false
270
+ +}
271
+ +
272
+ +func normalizeScopeProject(project string) string {
273
+ + project = strings.ReplaceAll(strings.TrimSpace(project), `\`, "/")
274
+ + project = strings.TrimPrefix(project, "imported:")
275
+ + return strings.ToLower(strings.Trim(project, "/"))
276
+ +}
277
+ diff --git a/internal/sources/project_scope_test.go b/internal/sources/project_scope_test.go
278
+ new file mode 100644
279
+ index 0000000..9986630
280
+ --- /dev/null
281
+ +++ b/internal/sources/project_scope_test.go
282
+ @@ -0,0 +1,133 @@
283
+ +package sources
284
+ +
285
+ +import (
286
+ + "encoding/json"
287
+ + "os"
288
+ + "os/exec"
289
+ + "path/filepath"
290
+ + "slices"
291
+ + "strings"
292
+ + "testing"
293
+ +)
294
+ +
295
+ +func TestCodexFilesHonorsProjectRootScopeBeforeParsing(t *testing.T) {
296
+ + root := t.TempDir()
297
+ + selected := filepath.Join(root, "selected", "dosu")
298
+ + related := filepath.Join(root, "another", "dosu")
299
+ + unrelatedSameName := filepath.Join(root, "unrelated", "dosu")
300
+ + deletedWorktree := filepath.Join(root, "deleted", "dosu")
301
+ + initScopeRepository(t, selected, "git@example.com:dosu-ai/dosu.git")
302
+ + initScopeRepository(t, related, "https://example.com/dosu-ai/dosu.git")
303
+ + initScopeRepository(t, unrelatedSameName, "https://example.com/someone-else/dosu.git")
304
+ + codex := filepath.Join(root, "codex")
305
+ + t.Setenv("DEJA_CODEX_ROOT", codex)
306
+ + t.Setenv("DEJA_PROJECT_ROOTS", selected)
307
+ +
308
+ + want := filepath.Join(codex, "sessions", "2026", "08", "rollout-want.jsonl")
309
+ + sameProjectOtherCheckout := filepath.Join(codex, "sessions", "2026", "08", "rollout-related.jsonl")
310
+ + deletedProjectCheckout := filepath.Join(codex, "sessions", "2026", "08", "rollout-deleted.jsonl")
311
+ + reject := filepath.Join(codex, "sessions", "2026", "08", "rollout-reject.jsonl")
312
+ + writeCodexScopeFixture(t, want, selected, "want")
313
+ + writeCodexScopeFixture(t, sameProjectOtherCheckout, related, "related")
314
+ + writeCodexScopeFixture(t, deletedProjectCheckout, deletedWorktree, "deleted")
315
+ + writeCodexScopeFixture(t, reject, unrelatedSameName, "reject")
316
+ + history := filepath.Join(codex, "history.jsonl")
317
+ + writeScopeFile(t, history, `{"session_id":"history-only","ts":1,"text":"keep history collision semantics"}`+"\n")
318
+ +
319
+ + got := CodexFiles()
320
+ + slices.Sort(got)
321
+ + wantFiles := []string{deletedProjectCheckout, history, sameProjectOtherCheckout, want}
322
+ + slices.Sort(wantFiles)
323
+ + if !slices.Equal(got, wantFiles) {
324
+ + t.Fatalf("CodexFiles() = %v, want %v", got, wantFiles)
325
+ + }
326
+ +}
327
+ +
328
+ +func TestScanProgressLineIsMachineReadableAndEscapesPaths(t *testing.T) {
329
+ + line := scanProgressLine("/tmp/a\nstrange.jsonl")
330
+ + if !strings.HasPrefix(line, scanProgressPrefix) {
331
+ + t.Fatalf("progress line = %q", line)
332
+ + }
333
+ + var event struct {
334
+ + Path string `json:"path"`
335
+ + }
336
+ + if err := json.Unmarshal([]byte(strings.TrimPrefix(line, scanProgressPrefix)), &event); err != nil {
337
+ + t.Fatal(err)
338
+ + }
339
+ + if event.Path != "/tmp/a\nstrange.jsonl" {
340
+ + t.Fatalf("path = %q", event.Path)
341
+ + }
342
+ +}
343
+ +
344
+ +func TestClaudeFilesHonorsProjectRootScopeBeforeParsing(t *testing.T) {
345
+ + root := t.TempDir()
346
+ + selected := filepath.Join(root, "work", "dosu")
347
+ + other := filepath.Join(root, "work", "other")
348
+ + for _, dir := range []string{selected, other} {
349
+ + if err := os.MkdirAll(dir, 0o755); err != nil {
350
+ + t.Fatal(err)
351
+ + }
352
+ + }
353
+ + claude := filepath.Join(root, "claude-projects")
354
+ + t.Setenv("DEJA_CLAUDE_ROOT", claude)
355
+ + t.Setenv("DEJA_PROJECT_ROOTS", selected)
356
+ + want := filepath.Join(claude, strings.ReplaceAll(selected, string(filepath.Separator), "-"), "want.jsonl")
357
+ + reject := filepath.Join(claude, strings.ReplaceAll(other, string(filepath.Separator), "-"), "reject.jsonl")
358
+ + writeScopeFile(t, want, `{}`+"\n")
359
+ + writeScopeFile(t, reject, `{}`+"\n")
360
+ +
361
+ + if got := ClaudeFiles(); !slices.Equal(got, []string{want}) {
362
+ + t.Fatalf("ClaudeFiles() = %v, want [%s]", got, want)
363
+ + }
364
+ +}
365
+ +
366
+ +func TestCursorTranscriptsHonorProjectRootScopeBeforeParsing(t *testing.T) {
367
+ + root := t.TempDir()
368
+ + selected := filepath.Join(root, "work", "dosu")
369
+ + other := filepath.Join(root, "work", "other")
370
+ + for _, dir := range []string{selected, other} {
371
+ + if err := os.MkdirAll(dir, 0o755); err != nil {
372
+ + t.Fatal(err)
373
+ + }
374
+ + }
375
+ + cursor := filepath.Join(root, "cursor")
376
+ + t.Setenv("DEJA_CURSOR_CLI_ROOT", cursor)
377
+ + t.Setenv("DEJA_PROJECT_ROOTS", selected)
378
+ + want := filepath.Join(cursor, "projects", strings.TrimPrefix(strings.ReplaceAll(selected, string(filepath.Separator), "-"), "-"), "agent-transcripts", "want.jsonl")
379
+ + reject := filepath.Join(cursor, "projects", strings.TrimPrefix(strings.ReplaceAll(other, string(filepath.Separator), "-"), "-"), "agent-transcripts", "reject.jsonl")
380
+ + writeScopeFile(t, want, `{}`+"\n")
381
+ + writeScopeFile(t, reject, `{}`+"\n")
382
+ +
383
+ + if got := CursorTranscripts(); !slices.Equal(got, []string{want}) {
384
+ + t.Fatalf("CursorTranscripts() = %v, want [%s]", got, want)
385
+ + }
386
+ +}
387
+ +
388
+ +func writeCodexScopeFixture(t *testing.T, path, cwd, id string) {
389
+ + t.Helper()
390
+ + writeScopeFile(t, path,
391
+ + `{"type":"session_meta","payload":{"id":"`+id+`","cwd":"`+cwd+`"}}`+"\n"+
392
+ + `{"type":"response_item","payload":{"role":"user","content":[{"type":"input_text","text":"hello"}]}}`+"\n")
393
+ +}
394
+ +
395
+ +func writeScopeFile(t *testing.T, path, body string) {
396
+ + t.Helper()
397
+ + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
398
+ + t.Fatal(err)
399
+ + }
400
+ + if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
401
+ + t.Fatal(err)
402
+ + }
403
+ +}
404
+ +
405
+ +func initScopeRepository(t *testing.T, path, remote string) {
406
+ + t.Helper()
407
+ + if err := os.MkdirAll(path, 0o755); err != nil {
408
+ + t.Fatal(err)
409
+ + }
410
+ + for _, args := range [][]string{{"init", "-q", path}, {"-C", path, "remote", "add", "origin", remote}} {
411
+ + if output, err := exec.Command("git", args...).CombinedOutput(); err != nil {
412
+ + t.Fatalf("git %v: %v: %s", args, err, output)
413
+ + }
414
+ + }
415
+ +}
416
+ diff --git a/internal/sources/scan_progress.go b/internal/sources/scan_progress.go
417
+ new file mode 100644
418
+ index 0000000..da03350
419
+ --- /dev/null
420
+ +++ b/internal/sources/scan_progress.go
421
+ @@ -0,0 +1,31 @@
422
+ +package sources
423
+ +
424
+ +import (
425
+ + "encoding/json"
426
+ + "fmt"
427
+ + "os"
428
+ + "sync"
429
+ +)
430
+ +
431
+ +const scanProgressPrefix = "@dosu-scan "
432
+ +
433
+ +var scanProgressMu sync.Mutex
434
+ +
435
+ +func scanProgressLine(path string) string {
436
+ + payload, _ := json.Marshal(struct {
437
+ + Path string `json:"path"`
438
+ + }{Path: path})
439
+ + return scanProgressPrefix + string(payload)
440
+ +}
441
+ +
442
+ +// reportScanPath is inert for normal deja users. Dosu's private runtime turns
443
+ +// it on and consumes the machine-readable stderr lines to update one spinner
444
+ +// in place; the paths never accumulate in the terminal scrollback.
445
+ +func reportScanPath(path string) {
446
+ + if os.Getenv("DEJA_SCAN_PROGRESS") != "1" {
447
+ + return
448
+ + }
449
+ + scanProgressMu.Lock()
450
+ + defer scanProgressMu.Unlock()
451
+ + fmt.Fprintln(os.Stderr, scanProgressLine(path))
452
+ +}
453
+ diff --git a/internal/sources/util.go b/internal/sources/util.go
454
+ index bbf9081..e5dc6ba 100644
455
+ --- a/internal/sources/util.go
456
+ +++ b/internal/sources/util.go
457
+ @@ -330,6 +330,7 @@ func parseFiles(files []string, parse func(string) ([]model.Session, error)) []m
458
+ go func() {
459
+ defer wg.Done()
460
+ for j := range jobs {
461
+ + reportScanPath(j.p)
462
+ ss, err := safeParse(j.p, parse)
463
+ diagFileError(j.p, err)
464
+ outs <- struct {
465
+ --
466
+ 2.50.1 (Apple Git-155)
467
+
468
+
469
+ From e23e2af7b630aa864472db9fb28bec124a39cc04 Mon Sep 17 00:00:00 2001
470
+ From: Caspian Zhao <caspian.zhao@outlook.com>
471
+ Date: Thu, 20 Aug 2026 09:47:48 -0700
472
+ Subject: [PATCH 2/2] fix: retain scoped repository identity
473
+
474
+ ---
475
+ internal/sources/claude.go | 13 ++++
476
+ internal/sources/claude_decode.go | 3 +
477
+ internal/sources/cursor.go | 55 ++++++++++++++---
478
+ internal/sources/project_scope.go | 51 ++++++++++++----
479
+ internal/sources/project_scope_test.go | 85 +++++++++++++++++++-------
480
+ 5 files changed, 163 insertions(+), 44 deletions(-)
481
+
482
+ diff --git a/internal/sources/claude.go b/internal/sources/claude.go
483
+ index ded140c..9d70991 100644
484
+ --- a/internal/sources/claude.go
485
+ +++ b/internal/sources/claude.go
486
+ @@ -136,9 +136,22 @@ func parseClaudeGenericFromOffset(path string, offset int64) ([]model.Session, e
487
+ if p := projectFromPaths(s.Messages); p != "" {
488
+ s.Project = p
489
+ }
490
+ + if root := claudeScopeRoot(path); root != "" {
491
+ + s.Project = root
492
+ + }
493
+ return []model.Session{s}, err
494
+ }
495
+
496
+ +func claudeScopeRoot(path string) string {
497
+ + if len(projectScopeRoots()) == 0 {
498
+ + return ""
499
+ + }
500
+ + if resolved := ResolveEncodedPath(ClaudeProjectDirBase(path)); resolved != "" {
501
+ + return projectScopeRootForPath(resolved)
502
+ + }
503
+ + return projectScopeRootForProject(claudeProjectName(claudeProjectDir(path)))
504
+ +}
505
+ +
506
+ func claudeProjectDir(path string) string {
507
+ return projectDir(ClaudeRoot(), path)
508
+ }
509
+ diff --git a/internal/sources/claude_decode.go b/internal/sources/claude_decode.go
510
+ index 85b2678..7651f53 100644
511
+ --- a/internal/sources/claude_decode.go
512
+ +++ b/internal/sources/claude_decode.go
513
+ @@ -103,6 +103,9 @@ func parseClaudeTypedFromOffset(path string, offset int64) ([]model.Session, err
514
+ if p := projectFromPaths(s.Messages); p != "" {
515
+ s.Project = p
516
+ }
517
+ + if root := claudeScopeRoot(path); root != "" {
518
+ + s.Project = root
519
+ + }
520
+ return []model.Session{s}, err
521
+ }
522
+
523
+ diff --git a/internal/sources/cursor.go b/internal/sources/cursor.go
524
+ index 7899d4a..c4a24a5 100644
525
+ --- a/internal/sources/cursor.go
526
+ +++ b/internal/sources/cursor.go
527
+ @@ -95,7 +95,7 @@ func CursorTranscripts() []string {
528
+ return nil
529
+ }
530
+ if cursorPathHasDir(p, "agent-transcripts") {
531
+ - if len(projectScopeRoots()) > 0 && !projectScopeMatchesProject(cursorTranscriptProject(p)) {
532
+ + if len(projectScopeRoots()) > 0 && cursorTranscriptScopeRoot(p) == "" {
533
+ return nil
534
+ }
535
+ out = append(out, p)
536
+ @@ -199,6 +199,7 @@ func parseCursorDB(db string, sinceMS int64) ([]model.Session, error) {
537
+ cid = strings.TrimPrefix(str(c["key"]), "composerData:")
538
+ }
539
+ s := model.Session{Harness: "cursor", ID: cid, Project: "-", Path: db, Title: str(c["name"])}
540
+ + workspace := ""
541
+ s.Touch(epochMS(c["created"]))
542
+ s.Touch(epochMS(c["updated"]))
543
+ bs := byComposer[cid]
544
+ @@ -221,11 +222,19 @@ func parseCursorDB(db string, sinceMS int64) ([]model.Session, error) {
545
+ s.Messages = append(s.Messages, model.Message{Role: role, Text: text, Time: t})
546
+ if s.Project == "-" {
547
+ if ws := str(b["wsdir"]); ws != "" {
548
+ + workspace = ws
549
+ s.Project = projectName(ws)
550
+ }
551
+ }
552
+ }
553
+ if len(s.Messages) > 0 {
554
+ + if len(projectScopeRoots()) > 0 {
555
+ + root := projectScopeRootForPath(workspace)
556
+ + if root == "" {
557
+ + continue
558
+ + }
559
+ + s.Project = root
560
+ + }
561
+ out = append(out, s)
562
+ }
563
+ }
564
+ @@ -284,7 +293,11 @@ var cursorDialect = toolDialect{
565
+ // JSONL; control lines (turn_ended etc.) carry no role and are skipped.
566
+ func ParseCursorTranscript(path string) ([]model.Session, error) {
567
+ id := strings.TrimSuffix(filepath.Base(path), ".jsonl")
568
+ - s := model.Session{Harness: "cursor", ID: id, Project: cursorTranscriptProject(path), Path: path}
569
+ + project := cursorTranscriptProject(path)
570
+ + if root := cursorTranscriptScopeRoot(path); root != "" {
571
+ + project = root
572
+ + }
573
+ + s := model.Session{Harness: "cursor", ID: id, Project: project, Path: path}
574
+ fi, err := os.Stat(path)
575
+ if err == nil {
576
+ s.Touch(fi.ModTime()) // transcripts carry no timestamps
577
+ @@ -330,24 +343,46 @@ func ParseCursorTranscript(path string) ([]model.Session, error) {
578
+ // back to a path with a greedy existence-checked walk; hyphens in real dir
579
+ // names survive because the literal branch is tried when the split fails.
580
+ func cursorTranscriptProject(path string) string {
581
+ + encoded := cursorTranscriptEncodedProject(path)
582
+ + if encoded == "" {
583
+ + return "-"
584
+ + }
585
+ + if resolved := resolveEncodedPath("-" + encoded); resolved != "" {
586
+ + return projectName(resolved)
587
+ + }
588
+ + parts := strings.Split(encoded, "-")
589
+ + if len(parts) >= 2 {
590
+ + return filepath.Join(parts[len(parts)-2], parts[len(parts)-1])
591
+ + }
592
+ + return encoded
593
+ +}
594
+ +
595
+ +func cursorTranscriptEncodedProject(path string) string {
596
+ dir := filepath.Clean(path)
597
+ for !strings.EqualFold(filepath.Base(filepath.Dir(dir)), "projects") {
598
+ parent := filepath.Dir(dir)
599
+ if parent == dir {
600
+ - return "-"
601
+ + return ""
602
+ }
603
+ dir = parent
604
+ }
605
+ encoded := filepath.Base(dir)
606
+ if encoded == "" || encoded == "." {
607
+ - return "-"
608
+ + return ""
609
+ }
610
+ - if resolved := resolveEncodedPath("-" + encoded); resolved != "" {
611
+ - return projectName(resolved)
612
+ + return encoded
613
+ +}
614
+ +
615
+ +func cursorTranscriptScopeRoot(path string) string {
616
+ + if len(projectScopeRoots()) == 0 {
617
+ + return ""
618
+ }
619
+ - parts := strings.Split(encoded, "-")
620
+ - if len(parts) >= 2 {
621
+ - return filepath.Join(parts[len(parts)-2], parts[len(parts)-1])
622
+ + encoded := cursorTranscriptEncodedProject(path)
623
+ + if encoded == "" {
624
+ + return ""
625
+ }
626
+ - return encoded
627
+ + if resolved := resolveEncodedPath("-" + encoded); resolved != "" {
628
+ + return projectScopeRootForPath(resolved)
629
+ + }
630
+ + return projectScopeRootForProject(cursorTranscriptProject(path))
631
+ }
632
+ diff --git a/internal/sources/project_scope.go b/internal/sources/project_scope.go
633
+ index 78e6add..9cdd1de 100644
634
+ --- a/internal/sources/project_scope.go
635
+ +++ b/internal/sources/project_scope.go
636
+ @@ -52,37 +52,56 @@ func canonicalScopePath(path string) string {
637
+ }
638
+
639
+ func projectScopeMatchesPath(path string) bool {
640
+ + return projectScopeRootForPath(path) != ""
641
+ +}
642
+ +
643
+ +// projectScopeRootForPath returns the one selected repository a source path
644
+ +// belongs to. Exact containment wins. A checkout elsewhere can still belong
645
+ +// through a shared Git common directory or normalized origin, but ambiguous
646
+ +// matches are rejected rather than assigned to an arbitrary selected root.
647
+ +func projectScopeRootForPath(path string) string {
648
+ path = canonicalScopePath(path)
649
+ if path == "" {
650
+ - return false
651
+ + return ""
652
+ }
653
+ + exact := ""
654
+ for _, root := range projectScopeRoots() {
655
+ if rel, err := filepath.Rel(root, path); err == nil &&
656
+ (rel == "." || (rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)) && !filepath.IsAbs(rel))) {
657
+ - return true
658
+ + if len(root) > len(exact) {
659
+ + exact = root
660
+ + }
661
+ }
662
+ }
663
+ + if exact != "" {
664
+ + return exact
665
+ + }
666
+ if candidate, ok := scopeRepositoryAt(path); ok {
667
+ + matched := ""
668
+ for _, root := range projectScopeRoots() {
669
+ selected, selectedOK := scopeRepositoryAt(root)
670
+ if !selectedOK {
671
+ continue
672
+ }
673
+ - if candidate.commonDir != "" && candidate.commonDir == selected.commonDir {
674
+ - return true
675
+ - }
676
+ - if candidate.remote != "" && candidate.remote == selected.remote {
677
+ - return true
678
+ + if (candidate.commonDir != "" && candidate.commonDir == selected.commonDir) ||
679
+ + (candidate.remote != "" && candidate.remote == selected.remote) {
680
+ + if matched != "" && matched != root {
681
+ + return ""
682
+ + }
683
+ + matched = root
684
+ }
685
+ }
686
+ + if matched != "" {
687
+ + return matched
688
+ + }
689
+ // An existing repository is identifiable. A different remote with the
690
+ // same basename is a different project, not a related worktree.
691
+ - return false
692
+ + return ""
693
+ }
694
+ // Deleted worktrees no longer have Git metadata to prove. Keep the current
695
+ // whole-segment fallback so historical sessions from a removed worktree do
696
+ // not disappear merely because its checkout was cleaned up.
697
+ - return projectScopeMatchesProject(projectName(path))
698
+ + return projectScopeRootForProject(projectName(path))
699
+ }
700
+
701
+ func scopeRepositoryAt(path string) (scopeRepositoryIdentity, bool) {
702
+ @@ -139,18 +158,26 @@ func normalizeScopeRemote(remote string) string {
703
+ // "dosu" repository. Exact-path matching above wins first; this fallback is
704
+ // deliberately no broader than whole path segments.
705
+ func projectScopeMatchesProject(project string) bool {
706
+ + return projectScopeRootForProject(project) != ""
707
+ +}
708
+ +
709
+ +func projectScopeRootForProject(project string) string {
710
+ project = normalizeScopeProject(project)
711
+ if project == "" || project == "-" {
712
+ - return false
713
+ + return ""
714
+ }
715
+ + matched := ""
716
+ for _, root := range projectScopeRoots() {
717
+ base := normalizeScopeProject(filepath.Base(root))
718
+ parentBase := normalizeScopeProject(filepath.Join(filepath.Base(filepath.Dir(root)), filepath.Base(root)))
719
+ if project == base || project == parentBase || strings.HasSuffix(project, "/"+base) {
720
+ - return true
721
+ + if matched != "" && matched != root {
722
+ + return ""
723
+ + }
724
+ + matched = root
725
+ }
726
+ }
727
+ - return false
728
+ + return matched
729
+ }
730
+
731
+ func normalizeScopeProject(project string) string {
732
+ diff --git a/internal/sources/project_scope_test.go b/internal/sources/project_scope_test.go
733
+ index 9986630..ecd22a2 100644
734
+ --- a/internal/sources/project_scope_test.go
735
+ +++ b/internal/sources/project_scope_test.go
736
+ @@ -62,45 +62,86 @@ func TestScanProgressLineIsMachineReadableAndEscapesPaths(t *testing.T) {
737
+ func TestClaudeFilesHonorsProjectRootScopeBeforeParsing(t *testing.T) {
738
+ root := t.TempDir()
739
+ selected := filepath.Join(root, "work", "dosu")
740
+ - other := filepath.Join(root, "work", "other")
741
+ - for _, dir := range []string{selected, other} {
742
+ - if err := os.MkdirAll(dir, 0o755); err != nil {
743
+ - t.Fatal(err)
744
+ - }
745
+ - }
746
+ + related := filepath.Join(root, "related", "dosu")
747
+ + unrelatedSameName := filepath.Join(root, "unrelated", "dosu")
748
+ + initScopeRepository(t, selected, "git@example.com:dosu-ai/dosu.git")
749
+ + initScopeRepository(t, related, "https://example.com/dosu-ai/dosu.git")
750
+ + initScopeRepository(t, unrelatedSameName, "https://example.com/someone-else/dosu.git")
751
+ claude := filepath.Join(root, "claude-projects")
752
+ t.Setenv("DEJA_CLAUDE_ROOT", claude)
753
+ t.Setenv("DEJA_PROJECT_ROOTS", selected)
754
+ want := filepath.Join(claude, strings.ReplaceAll(selected, string(filepath.Separator), "-"), "want.jsonl")
755
+ - reject := filepath.Join(claude, strings.ReplaceAll(other, string(filepath.Separator), "-"), "reject.jsonl")
756
+ - writeScopeFile(t, want, `{}`+"\n")
757
+ - writeScopeFile(t, reject, `{}`+"\n")
758
+ + relatedTranscript := filepath.Join(claude, strings.ReplaceAll(related, string(filepath.Separator), "-"), "related.jsonl")
759
+ + reject := filepath.Join(claude, strings.ReplaceAll(unrelatedSameName, string(filepath.Separator), "-"), "reject.jsonl")
760
+ + writeClaudeScopeFixture(t, want, "want")
761
+ + writeClaudeScopeFixture(t, relatedTranscript, "related")
762
+ + writeClaudeScopeFixture(t, reject, "reject")
763
+
764
+ - if got := ClaudeFiles(); !slices.Equal(got, []string{want}) {
765
+ - t.Fatalf("ClaudeFiles() = %v, want [%s]", got, want)
766
+ + got := ClaudeFiles()
767
+ + slices.Sort(got)
768
+ + wantFiles := []string{relatedTranscript, want}
769
+ + slices.Sort(wantFiles)
770
+ + if !slices.Equal(got, wantFiles) {
771
+ + t.Fatalf("ClaudeFiles() = %v, want %v", got, wantFiles)
772
+ + }
773
+ + sessions := LoadClaude()
774
+ + if len(sessions) != 2 {
775
+ + t.Fatalf("LoadClaude() returned %d sessions, want 2", len(sessions))
776
+ + }
777
+ + selected = canonicalScopePath(selected)
778
+ + for _, session := range sessions {
779
+ + if session.Project != selected {
780
+ + t.Errorf("Claude session %q project = %q, want selected root %q", session.ID, session.Project, selected)
781
+ + }
782
+ }
783
+ }
784
+
785
+ func TestCursorTranscriptsHonorProjectRootScopeBeforeParsing(t *testing.T) {
786
+ root := t.TempDir()
787
+ selected := filepath.Join(root, "work", "dosu")
788
+ - other := filepath.Join(root, "work", "other")
789
+ - for _, dir := range []string{selected, other} {
790
+ - if err := os.MkdirAll(dir, 0o755); err != nil {
791
+ - t.Fatal(err)
792
+ - }
793
+ - }
794
+ + related := filepath.Join(root, "related", "dosu")
795
+ + unrelatedSameName := filepath.Join(root, "unrelated", "dosu")
796
+ + initScopeRepository(t, selected, "git@example.com:dosu-ai/dosu.git")
797
+ + initScopeRepository(t, related, "https://example.com/dosu-ai/dosu.git")
798
+ + initScopeRepository(t, unrelatedSameName, "https://example.com/someone-else/dosu.git")
799
+ cursor := filepath.Join(root, "cursor")
800
+ t.Setenv("DEJA_CURSOR_CLI_ROOT", cursor)
801
+ + t.Setenv("DEJA_CURSOR_ROOT", filepath.Join(root, "cursor-ide"))
802
+ t.Setenv("DEJA_PROJECT_ROOTS", selected)
803
+ want := filepath.Join(cursor, "projects", strings.TrimPrefix(strings.ReplaceAll(selected, string(filepath.Separator), "-"), "-"), "agent-transcripts", "want.jsonl")
804
+ - reject := filepath.Join(cursor, "projects", strings.TrimPrefix(strings.ReplaceAll(other, string(filepath.Separator), "-"), "-"), "agent-transcripts", "reject.jsonl")
805
+ - writeScopeFile(t, want, `{}`+"\n")
806
+ - writeScopeFile(t, reject, `{}`+"\n")
807
+ + relatedTranscript := filepath.Join(cursor, "projects", strings.TrimPrefix(strings.ReplaceAll(related, string(filepath.Separator), "-"), "-"), "agent-transcripts", "related.jsonl")
808
+ + reject := filepath.Join(cursor, "projects", strings.TrimPrefix(strings.ReplaceAll(unrelatedSameName, string(filepath.Separator), "-"), "-"), "agent-transcripts", "reject.jsonl")
809
+ + writeCursorScopeFixture(t, want)
810
+ + writeCursorScopeFixture(t, relatedTranscript)
811
+ + writeCursorScopeFixture(t, reject)
812
+
813
+ - if got := CursorTranscripts(); !slices.Equal(got, []string{want}) {
814
+ - t.Fatalf("CursorTranscripts() = %v, want [%s]", got, want)
815
+ + got := CursorTranscripts()
816
+ + slices.Sort(got)
817
+ + wantFiles := []string{relatedTranscript, want}
818
+ + slices.Sort(wantFiles)
819
+ + if !slices.Equal(got, wantFiles) {
820
+ + t.Fatalf("CursorTranscripts() = %v, want %v", got, wantFiles)
821
+ }
822
+ + sessions := LoadCursor()
823
+ + if len(sessions) != 2 {
824
+ + t.Fatalf("LoadCursor() returned %d sessions, want 2", len(sessions))
825
+ + }
826
+ + selected = canonicalScopePath(selected)
827
+ + for _, session := range sessions {
828
+ + if session.Project != selected {
829
+ + t.Errorf("Cursor session %q project = %q, want selected root %q", session.ID, session.Project, selected)
830
+ + }
831
+ + }
832
+ +}
833
+ +
834
+ +func writeClaudeScopeFixture(t *testing.T, path, id string) {
835
+ + t.Helper()
836
+ + writeScopeFile(t, path, `{"type":"user","sessionId":"`+id+`","timestamp":"2026-08-20T12:00:00Z","message":{"role":"user","content":"hello"}}`+"\n")
837
+ +}
838
+ +
839
+ +func writeCursorScopeFixture(t *testing.T, path string) {
840
+ + t.Helper()
841
+ + writeScopeFile(t, path, `{"role":"user","message":{"content":"hello"}}`+"\n")
842
+ }
843
+
844
+ func writeCodexScopeFixture(t *testing.T, path, cwd, id string) {
845
+ --
846
+ 2.50.1 (Apple Git-155)
847
+