Repository navigation
[bug] stats, dashboard, session save and wiki slugs split one repo into one project per worktree #809
Description
Description
teamai stats --by-repo, teamai dashboard, session save and the codebase wiki commands treat every git worktree as its own project. Sessions from two worktrees of teamai-cli appear as two extra rows:
By Repo (local event log):
teamai-cli 8 sess, 148 turns, 773 tools, 228.3M tok, 4 intv
fix-760-lock 1 sess, 27 turns, 375 tools, 79.6M tok, 1 intv
fix-748-followup 2 sess, 12 turns, 89 tools, 4.8M tok
Both extra rows are worktrees under .claude/worktrees/. Worktrees outside the checkout, such as Orca workspaces or git worktree add ../x, split the same way. The expected output has a single teamai-cli row.
Cause. Each event records its cwd but no project identity, and the wiki commands only look at the directory they run in. Every consumer derives the project from a per-worktree path:
| Where | How it derives the project | Result |
|---|---|---|
attributeRepo (src/utils/repo-attribution.ts:47), used by stats --by-repo |
takes the basename | one row per worktree |
dashboardWorkspaces and workspaceEvents (src/dashboard/workspaces.ts:57,76) |
calls resolveAnchors(cwd), then matches the path against [root, ...listWorktrees(root)] |
a deleted worktree matches neither, and its sessions move to "Unassigned sessions" |
"Repository" filter (src/dashboard/client.ts:37-39) |
lists each distinct cwd |
one option per worktree |
renderSessionMarkdown (src/session-collector.ts:80,172) |
prints the last cwd as Project: |
one repo appears as several projects in the team session log |
import --dir (src/import.ts:325) and codebase --extract (src/codebase-extract.ts:539) |
take the basename as the project slug | from a worktree, import --dir pushes teamwiki/evidence/code/<worktree>/ to the team repo next to the repo's own entry |
Resolving the path at read time can't fix this. Worktrees are short-lived, and after one is removed git can no longer map its path to the main checkout. fix-760-lock above is no longer a registered worktree, and git worktree list omits it.
The hook already knows the answer when it writes the event, and discards it:
dashboardReport src/dashboard-collector.ts
resolveConfigForDir(cwd) src/config.ts
detectProjectConfig(cwd)
resolveAnchors(cwd) -> { workspaceRoot, projectAnchor }, used for the config, then dropped
parseHookEvent
event.dataHome = getDataHome(config)
appendEvent(event)
Proposed fix
All eight changes fit in one PR.
1. Record the project when the event is written. dashboardReport stores the main checkout, which all worktrees of a repo share, on every event:
dashboardReport
resolveConfigForDir(cwd)
detectProjectConfig(cwd)
resolveAnchors(cwd)
parseHookEvent
event.dataHome = getDataHome(config)
+ event.projectAnchor = (await resolveAnchors(cwd))?.projectAnchor
appendEvent(event)The hook writes it on every event, not only on session_start, because EnterWorktree or a cd changes the cwd in the middle of a session. The hook omits the field when cwd is outside a git repo. User-scope sessions get the field too, since a worktree splits the same way there.
2. Keep the hook's git calls at their current count. resolveAnchors memoizes each directory's result for the lifetime of the process, so the second call in one hook invocation runs no git command. It does not memoize null, so a directory that becomes a repo later in the same process still resolves.
3. Add one attribution helper to src/utils/repo-attribution.ts, and use it in both stats and the dashboard:
repoKey(session)
return last projectAnchor seen in the session's events
?? last cwd # events from before this change, non-git dirs
# events after the worktree is deleted have no anchor,
# so the session keeps the one it had
repoLabel(key, allKeys)
name = basename(key)
if another key in allKeys has the same basename
return basename(dirname(key)) + "/" + name # work/api, personal/api
return name
4. stats --by-repo. attributeByRepo groups sessions by repoKey and prints repoLabel.
5. Dashboard ownership. workspaceEvents matches on the anchor first:
owner(event)
+ if event.projectAnchor
+ return the project whose root === event.projectAnchor
return the project whose roots contain event.cwd (longest match)Discovery (workspaces.ts:57) adds the project from event.projectAnchor and calls resolveAnchors only for events that lack it.
6. Dashboard "Repository" filter. Options and filtering use repoKey and repoLabel instead of the raw cwd. Session rows keep showing the cwd.
7. session save. Project: shows repoLabel of the session's repoKey. The cwd moves to its own Directory: line, so the log keeps the path.
8. Wiki slug. When --project is not given, import --dir and codebase --extract use basename(resolveAnchors(dir).projectAnchor), falling back to basename(dir) outside git. The main checkout keeps the slug it has today.
Not in scope
- Events written before this change still split by worktree until they age out of
events.jsonl. Nothing backfills them. - The legacy branch of
filterEventsByScope(src/team-push.ts:415) still drops old events from worktrees outsideprojectRoot. New events carrydataHomeand reach the right scope, so this gap also closes as old events age out. - Wiki entries already pushed under a worktree slug stay in the team repo. Remove them by hand.
- Events whose
cwdwas deleted before the hook fired are still resolved to the user scope. That is [bug] hook events after a worktree is deleted fall back to the user scope #810. This fix only keeps them attributed to the session's repo in--by-repo. - There is no per-worktree breakdown. If someone needs one later, it belongs in a separate filter or flag.
Docs
DashboardEventgains an optionalprojectAnchor. Document it next todataHomeindocs/designs/data-directory-layout.md, around line 323. That file has no zh-CN copy.- The
--projecthelp oncodebase(src/index.ts:1119) says "defaults to directory name". Change it to the main checkout's directory name, then regenerateskill-data/core/references/commands.mdwithnpx vitest run commands-reference -u. - The
data-directory-layout.mdparagraph also saysstats --by-reporeads events from every scope. Since fix(report): each scope keeps its own reported dashboard snapshots (#786) #795,showStatspasses only the current scope's events (src/stats.ts:266). Fix that sentence in the same PR.
Reproduction
- In a teamai-enabled repo, run
git worktree add ../wt-demo -b wt-demo. - Start an agent session in
../wt-demoand run a few tool calls. - Run
teamai stats --by-repo. It shows awt-demorow next to the repo's row. - Run
git worktree remove ../wt-demo, thenteamai dashboard. The session appears under "Unassigned sessions". This step comes from readingworkspaces.tsand has not been run yet.
Acceptance
After step 3:
By Repo (local event log):
- my-repo 3 sess, ...
- wt-demo 1 sess, ...
+ my-repo 4 sess, ...- After step 4, the dashboard lists the session under the repo's workspace, and the "Repository" filter shows the repo once.
- Two unrelated repos that share a directory name appear as two rows with distinct labels.
- A session that starts in
~and then runscdinto the repo counts toward the repo. session savefrom a worktree session prints the repo asProject:.teamai import --dir . --dry-runinwt-demoprints(project: my-repo).
Environment
- OS: macOS (Darwin 25.5.0)
- teamai: 0.22.0
- Provider: GitHub
- AI tool(s): Claude Code, with worktrees from Orca and
EnterWorktree
Related: #752, which covers the cross-project gaps left after #748. Its item 1 proposed the dataHome field on events, which #785 implemented.
Activity
fix(stats): count every worktree of a repo as that repo (Tencent#809)
fix(stats): count every worktree of a repo as that repo (Tencent#809)
fix(stats): count every worktree of a repo as that repo (#809) (#813)
merge: bring origin/main into the namespace scoping branch (Tencent#707)
fix(hooks): keep a removed worktree's hook events in its project (Ten…
fix(hooks): keep a removed worktree's hook events in its project (Ten…
fix(hooks): keep a removed worktree's hook events in its project (#810)…