diff --git a/docs/designs/dashboard-unified.md b/docs/designs/dashboard-unified.md index b1a85719c..497b17bba 100644 --- a/docs/designs/dashboard-unified.md +++ b/docs/designs/dashboard-unified.md @@ -7,7 +7,7 @@ The logo's charcoal/blue palette, four-module navigation, English/Simplified Chi | View | Retained functionality | | --- | --- | | Overview | Local session status/counts, six current/prior 7-day metrics, KB coverage, maintenance candidate counts, session list | -| Team Execution | All supported tools, exact working-directory/tool filters, active and recently ended sessions, first/latest secret-redacted prompt summaries and output previews, duration, last activity/tool, intervention and token counts; keyboard-accessible Details opens all captured prompt summaries (capped at 200 characters), safely rendered Markdown output and full breakdowns | +| Team Execution | All supported tools, repository (all worktrees of a repo as one)/tool filters, active and recently ended sessions, first/latest secret-redacted prompt summaries and output previews, duration, last activity/tool, intervention and token counts; keyboard-accessible Details opens all captured prompt summaries (capped at 200 characters), safely rendered Markdown output and full breakdowns | | Team Context | Original KB totals, coverage by type, top recalled chart, expandable never-recalled groups, last-recall month chart, author contribution table, reported data scope and report generation time | | Team Improvement | Local trends, original promotion/archive/stale candidate guidance and commands, digest/session-save/share-learnings command references | @@ -45,6 +45,6 @@ The existing `avgRequestCostMicros`, request-day daily buckets and digest statis ### Workspace selection and missing data -The sidebar switches between all local sessions, user scope, and installed project scopes. Project partitions are discovered from their anchor files; legacy installs are discovered from the startup directory and recorded session directories. Linked Git worktrees share a project. User scope contains sessions outside installed projects. In the all-workspaces view, knowledge uses the startup scope; this is labeled explicitly. Individual scopes select their own knowledge configuration, with separate report caches and stale-response protection. Restart the dashboard after installing a new scope. +The sidebar switches between all local sessions, user scope, and installed project scopes. Project partitions are discovered from their anchor files; legacy installs are discovered from the startup directory, the repo each event recorded (`projectAnchor`), and recorded session directories. Linked Git worktrees share a project, and a removed worktree's sessions stay with it. User scope contains sessions outside installed projects. In the all-workspaces view, knowledge uses the startup scope; this is labeled explicitly. Individual scopes select their own knowledge configuration, with separate report caches and stale-response protection. Restart the dashboard after installing a new scope. The complete health report is split between Team Context (coverage, recalls, entries, authors) and Team Improvement (maintenance), with internal navigation. The legacy report URL remains compatible, but is not linked from the dashboard. Missing prior-period samples are labeled as no ended sessions, no priced sessions, or no usage data rather than collecting. diff --git a/docs/designs/dashboard-unified.zh-CN.md b/docs/designs/dashboard-unified.zh-CN.md index 556814fcc..c174e8b23 100644 --- a/docs/designs/dashboard-unified.zh-CN.md +++ b/docs/designs/dashboard-unified.zh-CN.md @@ -7,7 +7,7 @@ | 页面 | 保留功能 | | --- | --- | | 总览 | 本机会话状态与计数、六项近 7 天/前 7 天指标、知识覆盖率、维护候选计数、会话列表 | -| 团队执行 | 全部已支持工具、按完整工作目录/工具筛选、活跃和最近结束会话、首条/最近经密钥脱敏的输入摘要及输出预览、时长、最近活动/工具、干预与 Token 计数;支持键盘访问的详情保留全部已捕获输入摘要(最长 200 个字符)、安全渲染的 Markdown 输出及完整明细 | +| 团队执行 | 全部已支持工具、按仓库(同一仓库的所有 worktree 合为一项)/工具筛选、活跃和最近结束会话、首条/最近经密钥脱敏的输入摘要及输出预览、时长、最近活动/工具、干预与 Token 计数;支持键盘访问的详情保留全部已捕获输入摘要(最长 200 个字符)、安全渲染的 Markdown 输出及完整明细 | | 团队上下文 | 原有知识库总量、按类型覆盖率、高频召回图表、可折叠从未召回分组、最近召回月份图表、作者贡献表、数据来源与报告生成时间 | | 团队改进 | 本机趋势,原有晋升/归档/过期知识候选说明和命令,以及 digest/session-save/share-learnings 命令说明 | @@ -45,6 +45,6 @@ ### 工作区切换与缺失数据 -侧栏支持全部本机会话、用户范围和已安装项目范围。项目分区从 anchor 文件发现;旧安装从启动目录及已有会话目录发现。同一项目的 Git worktree 共享项目范围。用户范围包含不属于已安装项目的会话。全部工作区模式的知识库使用启动时范围,并明确标注。单独范围使用自己的知识库配置、独立报告缓存并阻止旧请求覆盖新范围数据。安装新范围后需重启仪表盘。 +侧栏支持全部本机会话、用户范围和已安装项目范围。项目分区从 anchor 文件发现;旧安装从启动目录、事件记录的仓库(`projectAnchor`)及已有会话目录发现。同一项目的 Git worktree 共享项目范围,已删除的 worktree 的会话仍归属该项目。用户范围包含不属于已安装项目的会话。全部工作区模式的知识库使用启动时范围,并明确标注。单独范围使用自己的知识库配置、独立报告缓存并阻止旧请求覆盖新范围数据。安装新范围后需重启仪表盘。 完整健康报告分别放在团队上下文(覆盖率、召回、条目、贡献者)和团队改进(维护)中,通过内部导航访问。旧报告 URL 保持兼容,仪表盘不再提供跳转入口。上期缺少样本时显示无已结束会话、无已定价会话或无用量数据,不再显示正在收集。 diff --git a/docs/designs/data-directory-layout.md b/docs/designs/data-directory-layout.md index d7c7f71d4..a6463906b 100644 --- a/docs/designs/data-directory-layout.md +++ b/docs/designs/data-directory-layout.md @@ -345,12 +345,18 @@ read. Local votes followed for the same reason (#787): `/votes/`, and `~/.teamai/user-votes/` (`getUserVotesDir()`) for the user scope, so a scope pushes only the votes cast where it is set up. The old shared `~/.teamai/votes/` is never read, and its pending deltas are not pushed. The dashboard stays an A2 -singleton: `teamai dashboard`, `stats --by-repo`, `session save` and the -contribute check read across scopes. Each event instead +singleton: `teamai dashboard`, `session save` and the contribute check read +across scopes; `stats --by-repo` reads only the current scope's events, as the +rest of `stats` does (#795). Each event instead carries `dataHome`, the `getDataHome()` of the scope the hook resolved (#785), and a scope's report keeps only its own. An event written before that field existed is attributed by its `cwd`, realpath'd, to the project whose root holds it, never to -the user scope. The snapshots of what was already reported are per scope too +the user scope. Inside git an event also carries `projectAnchor`, the repo's +main checkout, which all of its worktrees share (#809). `stats --by-repo`, +`session save` and the dashboard's Repository filter key a session by the last +anchor it recorded, else by its `cwd`, and the dashboard gives an event to the +project rooted at its anchor, so a worktree counts as its repo, also after it +is removed. The snapshots of what was already reported are per scope too (#786), because a session can record events in two scopes (a `cd` mid-session): `/dashboard/reported-*.json`, and `~/.teamai/dashboard/user-reported-*.json` for the user scope. The first time a scope needs one it copies the shared diff --git a/docs/usage-guide.md b/docs/usage-guide.md index 8882c8022..d0dc0b218 100644 --- a/docs/usage-guide.md +++ b/docs/usage-guide.md @@ -1435,6 +1435,8 @@ teamai codebase --lint --output /path/to/repo When extract finds components, it writes `teamwiki/evidence/code//_manifest.json` even if AI enrichment is skipped or produces nothing, so `--deep-enrich` can start. +Without `--project`, `` is the directory's name. At the root of a linked git worktree it is the repo's name: the main checkout's, or a bare repo's (`repo/.bare` or `repo.git` → `repo`). Every worktree of a repo writes the same entry. `teamai import --dir` picks its slug the same way. + ### Dashboard ```bash @@ -1442,7 +1444,7 @@ teamai dashboard # Start the web dashboard (default port 3721) teamai dashboard --port 8080 ``` -The sidebar contains **Overview**, **Team Execution**, **Team Context** and **Team Improvement**. Overview summarizes the three modules. Execution shows this machine's sessions, filters by working directory and AI tool, and opens complete session details. Context contains KB Health (including author contributions and never-recalled entries); Improvement contains local trends and the original promotion/archive/quality-update maintenance commands. Commands are displayed for use in your terminal; the dashboard does not execute them. +The sidebar contains **Overview**, **Team Execution**, **Team Context** and **Team Improvement**. Overview summarizes the three modules. Execution shows this machine's sessions, filters by repository (every worktree of a repo is one entry) and AI tool, and opens complete session details. Context contains KB Health (including author contributions and never-recalled entries); Improvement contains local trends and the original promotion/archive/quality-update maintenance commands. Commands are displayed for use in your terminal; the dashboard does not execute them. Use the header to select English or Simplified Chinese and light, dark, or system theme. Preferences are saved in browser storage when available. User prompts, AI output, knowledge titles and commands are not translated. The standalone `/kb-report` remains available as the original complete report. @@ -1507,7 +1509,7 @@ teamai session save --push --force # push even a trivial session teamai session save --push --include-prompt # also include the (redacted) first-ask line ``` -**Local (always):** appends to `~/.teamai/session-logs/.md`. Idempotent per session (a session already recorded that month is skipped), and logs older than 90 days are pruned automatically. +**Local (always):** appends to `~/.teamai/session-logs/.md`. Idempotent per session (a session already recorded that month is skipped), and logs older than 90 days are pruned automatically. Each entry names the session's repo as `Project:`, the same for every worktree of the repo, and its working directory as `Directory:`. **Team (`--push`, opt-in):** commits the summary directly (no PR) to `sessions//.md` on the `teamai-reports` branch — the exact path `teamai digest` reads, so the session shows up under **Session Highlights**. Only a **valuable** session is pushed by default: one that shows friction (an interrupt / tool-reject / correction) or substantial tool use (≥ 3 distinct tools). Trivial sessions stay local unless you pass `--force`. On a read-only (HTTP-mode) team, `--push` fails gracefully and the local log is still kept. diff --git a/docs/usage-guide.zh-CN.md b/docs/usage-guide.zh-CN.md index 298f09acc..836fc4511 100644 --- a/docs/usage-guide.zh-CN.md +++ b/docs/usage-guide.zh-CN.md @@ -1374,6 +1374,8 @@ teamai codebase --lint --output /path/to/repo 只要 extract 发现了组件,就会写入 `teamwiki/evidence/code//_manifest.json`(包括跳过 AI 增强或增强没有产出的情况),因此 `--deep-enrich` 可以接着跑。 +不传 `--project` 时,`` 取目录名;在 git 链接 worktree 的根目录下取仓库名:主检出的目录名,或 bare 仓库的名称(`repo/.bare` 或 `repo.git` → `repo`)。同一仓库的所有 worktree 写入同一个条目。`teamai import --dir` 用同样的方式确定 slug。 + ### Dashboard ```bash @@ -1381,7 +1383,7 @@ teamai dashboard # 启动 Web 面板(默认端口 3721) teamai dashboard --port 8080 ``` -侧栏包含 **Overview(总览)**、**Team Execution(团队执行)**、**Team Context(团队上下文)**、**Team Improvement(团队改进)**。总览汇总三模块;执行页展示本机会话,支持工作目录和 AI 工具筛选及完整详情;上下文页保留 KB Health(含作者贡献和从未召回条目);改进页保留本机趋势及晋升、归档、质量更新维护命令。命令需在终端使用,页面不执行维护操作。 +侧栏包含 **Overview(总览)**、**Team Execution(团队执行)**、**Team Context(团队上下文)**、**Team Improvement(团队改进)**。总览汇总三模块;执行页展示本机会话,支持按仓库(同一仓库的所有 worktree 合为一项)和 AI 工具筛选及完整详情;上下文页保留 KB Health(含作者贡献和从未召回条目);改进页保留本机趋势及晋升、归档、质量更新维护命令。命令需在终端使用,页面不执行维护操作。 页头支持英文/简体中文及日间/夜间/跟随系统主题,浏览器存储可用时记住偏好。用户输入、AI 输出、知识标题和命令保持原文。独立 `/kb-report` 继续提供原有完整报告。 @@ -1446,7 +1448,7 @@ teamai session save --push --force # 即便是琐碎会话也推送 teamai session save --push --include-prompt # 额外带上(脱敏后的)首个 prompt 行 ``` -**本地(始终执行):** 追加到 `~/.teamai/session-logs/<年-月>.md`。按会话幂等(当月已记录的会话会跳过),且超过 90 天的日志会自动清理。 +**本地(始终执行):** 追加到 `~/.teamai/session-logs/<年-月>.md`。按会话幂等(当月已记录的会话会跳过),且超过 90 天的日志会自动清理。每条记录用 `Project:` 标出会话所属的仓库(同一仓库的所有 worktree 相同),用 `Directory:` 标出其工作目录。 **团队(`--push`,需显式开启):** 直接提交(不走 PR)到 `teamai-reports` 分支的 `sessions//<年-月>.md`——正是 `teamai digest` 读取的路径,于是该会话会出现在 **Session Highlights** 板块。默认只推送**有价值**的会话:出现摩擦(interrupt / tool-reject / correction)或工具使用充分(≥ 3 种不同工具)。琐碎会话除非加 `--force`,否则只留本地。对只读(HTTP 模式)的团队,`--push` 会优雅失败并保留本地日志。 diff --git a/skill-data/core/references/commands.md b/skill-data/core/references/commands.md index 9b9e2a0d9..8313406f4 100644 --- a/skill-data/core/references/commands.md +++ b/skill-data/core/references/commands.md @@ -342,7 +342,7 @@ Generated: do not edit by hand. Regenerate with - `teamai codebase` — Inspect and maintain team-codebase outputs - `--extract [path]` — Extract code knowledge and build graph from source - `--incremental` (hidden) — Only re-extract changed files (requires prior manifest) - - `--project ` (hidden) — Project slug for --extract (defaults to directory name) and required for --deep-enrich + - `--project ` (hidden) — Project slug for --extract (defaults to the directory name; a linked worktree's root uses the repo's name) and required for --deep-enrich - `--max-files ` (hidden) — Max source files to scan (default: 200) - `--upgrade-wiki` (hidden) — Migrate docs/team-codebase/ to teamwiki/ graph format - `--lint` — Run global consistency lint over the teamwiki knowledge graph diff --git a/src/__tests__/anchors.test.ts b/src/__tests__/anchors.test.ts index 7c7798aef..d1168e8b5 100644 --- a/src/__tests__/anchors.test.ts +++ b/src/__tests__/anchors.test.ts @@ -5,6 +5,7 @@ import { realpathSync } from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { resolveAnchors, listWorktrees } from '../utils/git.js'; +import { defaultProjectSlug } from '../codebase-extract.js'; // ─── Real-git tests for resolveAnchors (issue #374 P0) ────────────────────── // @@ -121,6 +122,90 @@ describe('resolveAnchors', () => { }); }); +describe('resolveAnchors memo (#809)', () => { + const freshRepo = (name: string) => { + const dir = path.join(base, name); + fs.mkdirSync(dir); + git(dir, 'init', '-q'); + git(dir, 'config', 'user.email', 'test@example.com'); + git(dir, 'config', 'user.name', 'Test'); + git(dir, 'commit', '--allow-empty', '-q', '-m', 'init'); + return dir; + }; + + it('answers a directory it resolved before without running git again', async () => { + const dir = freshRepo('memo-hit'); + const first = await resolveAnchors(dir); + expect(first).not.toBeNull(); + // With .git gone, git would find no repository here. + fs.renameSync(path.join(dir, '.git'), path.join(dir, 'git-moved')); + expect(await resolveAnchors(dir)).toEqual(first); + }); + + it('does not remember a directory that was not a repository', async () => { + const dir = path.join(base, 'memo-miss'); + fs.mkdirSync(dir); + expect(await resolveAnchors(dir)).toBeNull(); + git(dir, 'init', '-q'); + git(dir, 'config', 'user.email', 'test@example.com'); + git(dir, 'config', 'user.name', 'Test'); + git(dir, 'commit', '--allow-empty', '-q', '-m', 'init'); + expect(await resolveAnchors(dir)).toEqual({ workspaceRoot: dir, projectAnchor: dir }); + }); + + it('keeps one entry per directory', async () => { + const a = freshRepo('memo-a'); + const b = freshRepo('memo-b'); + expect((await resolveAnchors(a))?.projectAnchor).toBe(a); + expect((await resolveAnchors(b))?.projectAnchor).toBe(b); + const sub = path.join(worktreeRoot, 'memo-sub'); + fs.mkdirSync(sub); + expect((await resolveAnchors(sub))?.workspaceRoot).toBe(worktreeRoot); + }); +}); + +describe('defaultProjectSlug (#809)', () => { + it('names a linked worktree\'s root after its main checkout', async () => { + expect(await defaultProjectSlug(worktreeRoot)).toBe('main-repo'); + }); + + it('keeps the directory\'s own name everywhere else', async () => { + const mainSub = path.join(repoRoot, 'pkg', 'api'); + const worktreeSub = path.join(worktreeRoot, 'pkg', 'web'); + fs.mkdirSync(mainSub, { recursive: true }); + fs.mkdirSync(worktreeSub, { recursive: true }); + expect(await defaultProjectSlug(repoRoot)).toBe('main-repo'); + expect(await defaultProjectSlug(mainSub)).toBe('api'); + expect(await defaultProjectSlug(worktreeSub)).toBe('web'); + expect(await defaultProjectSlug(nonGitDir)).toBe('plain'); + const file = path.join(repoRoot, 'README.md'); + fs.writeFileSync(file, '# readme\n'); + expect(await defaultProjectSlug(file)).toBe('README.md'); + }); + + it('names every worktree of a bare repo after the repo, not its git directory', async () => { + // repo/.bare + repo/ + const bare = path.join(base, 'bare-layout', '.bare'); + execFileSync('git', ['clone', '-q', '--bare', repoRoot, bare]); + for (const wt of ['main', 'feature']) { + const checkout = path.join(base, 'bare-layout', wt); + git(bare, 'worktree', 'add', '-q', checkout); + expect((await resolveAnchors(checkout))?.projectAnchor).toBe(bare); + expect(await defaultProjectSlug(checkout)).toBe('bare-layout'); + } + const sub = path.join(base, 'bare-layout', 'main', 'pkg'); + fs.mkdirSync(sub); + expect(await defaultProjectSlug(sub)).toBe('pkg'); + + // proj.git + a worktree beside it + const dotGit = path.join(base, 'proj.git'); + const checkout = path.join(base, 'proj-wt'); + execFileSync('git', ['clone', '-q', '--bare', repoRoot, dotGit]); + git(dotGit, 'worktree', 'add', '-q', checkout); + expect(await defaultProjectSlug(checkout)).toBe('proj'); + }); +}); + describe('listWorktrees', () => { it('lists the main checkout and every linked worktree (realpath\'d)', async () => { const roots = await listWorktrees(repoRoot); diff --git a/src/__tests__/dashboard-report-scope.test.ts b/src/__tests__/dashboard-report-scope.test.ts index 85da2b982..97a1286a4 100644 --- a/src/__tests__/dashboard-report-scope.test.ts +++ b/src/__tests__/dashboard-report-scope.test.ts @@ -133,6 +133,21 @@ describe('each scope reports only the dashboard sessions recorded in it (#785)', expect(await reportedSessions(project)).toBe(1); }); + it('records the repo of a session, and still no path for a Copilot one (#809)', async () => { + const { root } = await setup(); + await session('claude', { session_id: 'sid-a', cwd: root }); + await session('copilot', { session_id: 'copilot-a', cwd: root }); + + const events = fs.readFileSync(path.join(teamaiHome(), 'dashboard', 'events.jsonl'), 'utf-8') + .split('\n').filter(Boolean).map((line) => JSON.parse(line) as Record); + const claude = events.filter((e) => e.sessionId === 'sid-a'); + expect(claude.length).toBeGreaterThan(0); + expect(claude.every((e) => e.projectAnchor === root)).toBe(true); + const copilot = events.filter((e) => e.tool === 'copilot'); + expect(copilot.length).toBeGreaterThan(0); + expect(copilot.filter((e) => 'cwd' in e || 'projectAnchor' in e)).toEqual([]); + }); + it('a session started under a symlinked path of the project is reported by the project', async () => { const { root, user, project } = await setup(); const link = path.join(tmp, 'link-p'); diff --git a/src/__tests__/dashboard-ui.test.ts b/src/__tests__/dashboard-ui.test.ts index b2c40a171..cfa7ecaa7 100644 --- a/src/__tests__/dashboard-ui.test.ts +++ b/src/__tests__/dashboard-ui.test.ts @@ -26,6 +26,12 @@ describe('unified dashboard', () => { expect(html).toContain('/api/sessions'); }); + it('filters Repository by the session\'s repoKey, labelled with its repoLabel (#809)', () => { + const html = getDashboardHtml(3721); + expect(html).toContain("s.repoKey === $('repo').value"); + expect(html).toContain("['repo','repoKey','repoLabel','All repositories']"); + }); + it('counts priced sessions rather than requests; includes resumed costs in first-stop cohorts', () => { const result = summarizeSessionCosts(new Map([ ['current', snapshot('2026-09-16', [['2026-09-15', 8, 600_000], ['2026-09-16', 2, 400_000]])], diff --git a/src/__tests__/dashboard-workspaces.test.ts b/src/__tests__/dashboard-workspaces.test.ts index 3ff9aa48a..91bf3f566 100644 --- a/src/__tests__/dashboard-workspaces.test.ts +++ b/src/__tests__/dashboard-workspaces.test.ts @@ -1,5 +1,10 @@ -import { describe, expect, it } from 'vitest'; -import { workspaceEvents, type DashboardWorkspace } from '../dashboard/workspaces.js'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { execFileSync } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import YAML from 'yaml'; +import { dashboardWorkspaces, workspaceEvents, type DashboardWorkspace } from '../dashboard/workspaces.js'; import type { DashboardEvent } from '../types.js'; // Minimal event factory: workspaceEvents only reads sessionId + cwd. @@ -45,6 +50,15 @@ describe('workspaceEvents ownership', () => { expect(workspaceEvents(events, user, ws)).toEqual([]); }); + it('assigns an event to the project rooted at its projectAnchor, whatever its cwd (#809)', () => { + const p = project('p1', ['/repo/main']); + const ws = [user, p, unassigned]; + // A worktree removed since: its path matches no root any more. + const events = [{ ...ev('s1', '/repo/wt-gone'), projectAnchor: '/repo/main' }]; + expect(workspaceEvents(events, p, ws).map(e => e.sessionId)).toEqual(['s1']); + expect(workspaceEvents(events, unassigned, ws)).toEqual([]); + }); + it('keeps a whole session together once any of its events matches a project', () => { const p = project('p1', ['/repo/a']); const ws = [user, p, unassigned]; @@ -54,3 +68,55 @@ describe('workspaceEvents ownership', () => { expect(workspaceEvents(events, unassigned, ws)).toEqual([]); }); }); + +describe('dashboardWorkspaces discovery (#809)', () => { + let home = ''; + afterEach(() => { + vi.unstubAllEnvs(); + if (home) fs.rmSync(home, { recursive: true, force: true }); + }); + + it('finds a project from the anchor its events recorded, after the worktree they ran in is gone', async () => { + home = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'teamai-ws-discovery-'))); + vi.stubEnv('HOME', home); + // A project installed in its main checkout (no partition), named only by the events' anchor. + const main = path.join(home, 'repo'); + fs.mkdirSync(path.join(main, '.teamai'), { recursive: true }); + fs.writeFileSync(path.join(main, '.teamai', 'config.yaml'), YAML.stringify({ + repo: { localPath: path.join(main, '.teamai', 'team-repo'), remote: 'https://example.test/team.git' }, + username: 'fixture', scope: 'project', projectRoot: main, + })); + const events = [{ ...ev('s1', path.join(home, 'wt-gone')), projectAnchor: main }]; + + const workspaces = await dashboardWorkspaces(events); + const p = workspaces.find(w => w.root === main); + expect(p, JSON.stringify(workspaces)).toBeDefined(); + expect(p && workspaceEvents(events, p, workspaces).map(e => e.sessionId)).toEqual(['s1']); + }); + + it('still finds a legacy config kept in a linked worktree, which its anchor does not hold', async () => { + home = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'teamai-ws-discovery-'))); + vi.stubEnv('HOME', home); + const main = path.join(home, 'repo'); + const worktree = path.join(home, 'repo-wt'); + const git = (...args: string[]) => execFileSync('git', args, { + cwd: main, stdio: 'pipe', + env: { ...process.env, GIT_CONFIG_GLOBAL: '/dev/null', GIT_CONFIG_SYSTEM: '/dev/null' }, + }); + fs.mkdirSync(main); + git('init', '-q', '-b', 'main'); + git('-c', 'user.name=T', '-c', 'user.email=t@e.invalid', 'commit', '-q', '--allow-empty', '-m', 'init'); + git('worktree', 'add', '-q', worktree, '-b', 'wt'); + fs.mkdirSync(path.join(worktree, '.teamai')); + fs.writeFileSync(path.join(worktree, '.teamai', 'config.yaml'), YAML.stringify({ + repo: { localPath: path.join(worktree, '.teamai', 'team-repo'), remote: 'https://example.test/team.git' }, + username: 'fixture', scope: 'project', projectRoot: worktree, + })); + const events = [{ ...ev('s1', worktree), projectAnchor: main }]; + + const workspaces = await dashboardWorkspaces(events); + const p = workspaces.find(w => w.root === main); + expect(p, JSON.stringify(workspaces)).toBeDefined(); + expect(p && workspaceEvents(events, p, workspaces).map(e => e.sessionId)).toEqual(['s1']); + }); +}); diff --git a/src/__tests__/e2e/repo-attribution-worktrees-809.test.ts b/src/__tests__/e2e/repo-attribution-worktrees-809.test.ts new file mode 100644 index 000000000..85eff184c --- /dev/null +++ b/src/__tests__/e2e/repo-attribution-worktrees-809.test.ts @@ -0,0 +1,255 @@ +/** + * E2E (#809): every worktree of a repo counts as that repo. + * + * Each dashboard event recorded its `cwd` and nothing else about the project, + * so `stats --by-repo`, the dashboard and `session save` derived the project + * from a per-worktree path, and the wiki commands took the slug from the + * directory they ran in. A worktree became its own project, and once it was + * removed git could no longer map its path back to the repo. + */ +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { execFileSync, spawn, type ChildProcess } from 'node:child_process'; +import fs from 'node:fs'; +import net from 'node:net'; +import os from 'node:os'; +import path from 'node:path'; +import { once } from 'node:events'; +import { fileURLToPath } from 'node:url'; +import { projectSlug } from '../../utils/partition.js'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const ROOT = path.resolve(__dirname, '..', '..', '..'); +const CLI = path.join(ROOT, 'dist', 'index.js'); + +const GIT_ENV = { + GIT_AUTHOR_NAME: 'TeamAI CI', + GIT_AUTHOR_EMAIL: 'ci@teamai.test', + GIT_COMMITTER_NAME: 'TeamAI CI', + GIT_COMMITTER_EMAIL: 'ci@teamai.test', +}; + +interface RunResult { + code: number | null; + output: string; +} + +function cliEnv(home: string): NodeJS.ProcessEnv { + // The host's session id would override --session-id defaults and hook payloads. + const { CLAUDE_SESSION_ID: _ignored, ...env } = process.env; + return { ...env, ...GIT_ENV, HOME: home, USERPROFILE: home, FORCE_COLOR: '0', NO_COLOR: '1' }; +} + +function runCLI(args: string[], cwd: string, home: string, input = ''): Promise { + return new Promise((resolve) => { + const child = spawn('node', [CLI, ...args], { cwd, env: cliEnv(home), stdio: ['pipe', 'pipe', 'pipe'] }); + let output = ''; + child.stdout.on('data', (data: Buffer) => { output += data.toString(); }); + child.stderr.on('data', (data: Buffer) => { output += data.toString(); }); + child.on('close', (code) => resolve({ code, output })); + child.stdin.end(input); + }); +} + +function git(args: string[], cwd: string): void { + execFileSync('git', args, { cwd, stdio: 'pipe', env: { ...process.env, ...GIT_ENV } }); +} + +/** `[label, sessions]` for each row of the `By Repo` section. */ +function byRepo(output: string): Array<[string, number]> { + return [...output.slice(output.indexOf('By Repo')).matchAll(/^ {2}(\S+)\s+(\d+) sess,/gm)] + .map((m): [string, number] => [m[1], Number(m[2])]); +} + +async function freePort(): Promise { + const server = net.createServer(); + server.listen(0, '127.0.0.1'); + await once(server, 'listening'); + const address = server.address(); + const port = typeof address === 'object' && address ? address.port : 0; + await new Promise((resolve) => server.close(() => resolve())); + return port; +} + +describe('one repo, many worktrees (#809)', () => { + let sandbox: string; + let home: string; + let repo: string; + let worktree: string; + let workApi: string; + let personalApi: string; + let dashboard: ChildProcess | undefined; + + const events = () => fs.readFileSync(path.join(home, '.teamai', 'dashboard', 'events.jsonl'), 'utf8') + .split('\n').filter(Boolean).map((line) => JSON.parse(line) as { sessionId: string; cwd?: string }); + + /** A prompt and a tool call, sent the way the installed hooks send them. */ + const session = async (sessionId: string, cwd: string, writer: 'dispatch' | 'legacy' = 'dispatch') => { + const payloads = [ + { hook: 'prompt-submit', data: { session_id: sessionId, hook_event_name: 'UserPromptSubmit', cwd, prompt: `work in ${sessionId}` } }, + { hook: 'post-tool-use', data: { session_id: sessionId, hook_event_name: 'PostToolUse', cwd, tool_name: 'Edit', tool_input: {}, tool_response: {} } }, + ]; + for (const { hook, data } of payloads) { + const args = writer === 'dispatch' + ? ['hook-dispatch', hook, '--tool', 'claude', '--stdin'] + : ['dashboard-report', '--stdin', '--tool', 'claude']; + const r = await runCLI(args, cwd, home, JSON.stringify(data)); + expect(r.code, r.output).toBe(0); + } + }; + + beforeAll(async () => { + if (!fs.existsSync(CLI)) { + throw new Error(`CLI binary not found at ${CLI}. Run "npm run build" first.`); + } + + sandbox = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'teamai-issue809-e2e-'))); + home = path.join(sandbox, 'home'); + repo = path.join(sandbox, 'my-repo'); + worktree = path.join(sandbox, 'wt-demo'); + workApi = path.join(sandbox, 'work', 'api'); + personalApi = path.join(sandbox, 'personal', 'api'); + const seed = path.join(sandbox, 'seed'); + const remote = path.join(sandbox, 'team-remote.git'); + + fs.mkdirSync(seed, { recursive: true }); + fs.writeFileSync(path.join(seed, 'teamai.yaml'), 'team: issue-809-e2e\nrepo: https://example.com/team.git\nprovider: tgit\n'); + git(['init', '-q', '-b', 'main'], seed); + git(['add', '-A'], seed); + git(['commit', '-q', '-m', 'seed'], seed); + git(['clone', '-q', '--bare', seed, remote], sandbox); + + // The business repo, set up for teamai in project scope (its partition), + // with a linked worktree outside the checkout. + fs.mkdirSync(path.join(repo, 'src'), { recursive: true }); + fs.writeFileSync(path.join(repo, 'src', 'greet.ts'), 'export function greet(name: string): string {\n return `hello ${name}`;\n}\n'); + fs.writeFileSync(path.join(repo, '.gitignore'), 'teamwiki/\n'); + git(['init', '-q', '-b', 'main'], repo); + git(['add', '-A'], repo); + git(['commit', '-q', '-m', 'project'], repo); + git(['worktree', 'add', '-q', worktree, '-b', 'wt-demo'], repo); + + const partition = path.join(home, '.teamai', 'projects', projectSlug(repo)); + const projectTeamRepo = path.join(partition, 'team-repo'); + fs.mkdirSync(partition, { recursive: true }); + git(['clone', '-q', remote, projectTeamRepo], sandbox); + fs.writeFileSync(path.join(partition, 'anchor'), `${repo}\n`); + fs.writeFileSync(path.join(partition, 'config.yaml'), [ + 'repo:', + ` localPath: ${projectTeamRepo}`, + ` remote: ${remote}`, + 'username: ci-809', + 'updatePolicy: auto', + 'scope: project', + `projectRoot: ${repo}`, + 'enabledAgents: [claude]', + '', + ].join('\n')); + + // User scope, which records sessions outside the project. + const userTeamRepo = path.join(home, '.teamai', 'team-repo'); + git(['clone', '-q', remote, userTeamRepo], sandbox); + fs.writeFileSync(path.join(home, '.teamai', 'config.yaml'), [ + 'repo:', + ` localPath: ${userTeamRepo}`, + ` remote: ${remote}`, + 'username: ci-809', + 'updatePolicy: auto', + 'scope: user', + 'enabledAgents: [claude]', + '', + ].join('\n')); + + // Two unrelated repos that share a directory name. + for (const dir of [workApi, personalApi]) { + fs.mkdirSync(dir, { recursive: true }); + git(['init', '-q', '-b', 'main'], dir); + git(['commit', '-q', '--allow-empty', '-m', 'init'], dir); + } + + await session('main-1', repo); + await session('main-2', path.join(repo, 'src')); + await session('wt-1', worktree); + await session('wt-2', worktree, 'legacy'); + // Starts in HOME, then `cd`s into the repo. + await session('cd-1', home); + await session('cd-1', repo); + await session('api-work', workApi); + await session('api-personal', personalApi); + }, 120_000); + + afterAll(async () => { + if (dashboard && dashboard.exitCode === null) { + const exited = once(dashboard, 'exit'); + dashboard.kill('SIGTERM'); + await exited; + } + if (sandbox) fs.rmSync(sandbox, { recursive: true, force: true }); + }); + + it('records every session', () => { + expect([...new Set(events().map((e) => e.sessionId))].sort()).toEqual( + ['api-personal', 'api-work', 'cd-1', 'main-1', 'main-2', 'wt-1', 'wt-2'], + ); + }); + + it('stats --by-repo shows one row for the repo, counting its worktree sessions', async () => { + const r = await runCLI(['stats', '--by-repo'], repo, home); + expect(r.code, r.output).toBe(0); + expect(byRepo(r.output), r.output).toEqual([['my-repo', 5]]); + }); + + it('stats --by-repo labels two unrelated repos with the same name apart', async () => { + const r = await runCLI(['stats', '--by-repo'], home, home); + expect(r.code, r.output).toBe(0); + expect(byRepo(r.output), r.output).toEqual(expect.arrayContaining([['work/api', 1], ['personal/api', 1]])); + }); + + it('session save from a worktree session records the repo as Project and the path as Directory', async () => { + const r = await runCLI(['session', 'save', '--session-id', 'wt-1'], home, home); + expect(r.code, r.output).toBe(0); + const logs = path.join(home, '.teamai', 'session-logs'); + const log = fs.readdirSync(logs).map((f) => fs.readFileSync(path.join(logs, f), 'utf8')).join('\n'); + expect(log, r.output).toContain('- Project: `my-repo`'); + expect(log, r.output).toContain(`- Directory: \`${worktree}\``); + }); + + it('import --dir and codebase --extract in a worktree use the repo name as the wiki slug', async () => { + const imported = await runCLI(['import', '--dir', '.', '--dry-run'], worktree, home); + expect(imported.code, imported.output).toBe(0); + expect(imported.output).toContain('(project: my-repo)'); + + const extracted = await runCLI(['codebase', '--extract', worktree, '--json', '--max-files', '10'], worktree, home); + expect(extracted.code, extracted.output).toBe(0); + expect(fs.readdirSync(path.join(worktree, 'teamwiki', 'evidence', 'code')), extracted.output).toEqual(['my-repo']); + }); + + it('keeps a removed worktree\'s sessions in the repo\'s dashboard workspace, listed once in the Repository filter', async () => { + git(['worktree', 'remove', '--force', worktree], repo); + + const port = await freePort(); + const base = `http://127.0.0.1:${port}`; + dashboard = spawn(process.execPath, [CLI, 'dashboard', '--port', String(port)], { cwd: home, env: cliEnv(home), stdio: 'pipe' }); + let output = ''; + dashboard.stdout?.on('data', (b: Buffer) => { output += b.toString(); }); + dashboard.stderr?.on('data', (b: Buffer) => { output += b.toString(); }); + const deadline = Date.now() + 15_000; + while (!output.includes('Dashboard running') && Date.now() < deadline) { + if (dashboard.exitCode !== null) throw new Error(output); + await new Promise((r) => setTimeout(r, 50)); + } + expect(output).toContain('Dashboard running'); + + const workspaces = await (await fetch(`${base}/api/workspaces`)).json() as Array<{ id: string; scope: string; root: string }>; + const project = workspaces.find((w) => w.scope === 'project' && w.root === repo); + expect(project, JSON.stringify(workspaces)).toBeDefined(); + const sessions = await (await fetch(`${base}/api/sessions?workspace=${project?.id}`)).json() as + Array<{ sessionId: string; cwd: string; repoKey?: string; repoLabel?: string }>; + expect(sessions.map((s) => s.sessionId).sort()).toEqual(['cd-1', 'main-1', 'main-2', 'wt-1', 'wt-2']); + + // The Repository filter lists one option per repoKey (the cwd before #809), + // labelled with repoLabel. Session rows still show the cwd. + const options = new Map(sessions.map((s) => [s.repoKey ?? s.cwd, s.repoLabel ?? s.cwd])); + expect([...options.values()]).toEqual(['my-repo']); + expect(sessions.find((s) => s.sessionId === 'wt-1')?.cwd).toBe(worktree); + }); +}); diff --git a/src/__tests__/hook-handlers.test.ts b/src/__tests__/hook-handlers.test.ts index 923319f44..08afd3ee1 100644 --- a/src/__tests__/hook-handlers.test.ts +++ b/src/__tests__/hook-handlers.test.ts @@ -38,6 +38,7 @@ vi.mock('../dashboard-collector.js', () => ({ appendEvent: mockAppendEvent, compactEvents: vi.fn().mockResolvedValue(undefined), dashboardReport: mockDashboardReport, + eventProjectAnchor: vi.fn().mockResolvedValue(undefined), })); // Use the REAL resolveSkillUse (pure Skill/Read+SKILL.md logic, no I/O) so the diff --git a/src/__tests__/import-dir.test.ts b/src/__tests__/import-dir.test.ts index 307133278..abd5a2bab 100644 --- a/src/__tests__/import-dir.test.ts +++ b/src/__tests__/import-dir.test.ts @@ -7,6 +7,8 @@ import fs from 'fs-extra'; // Mock external dependencies vi.mock('../codebase-extract.js', () => ({ extractCodebase: vi.fn(), + // The fixtures are outside git, where the slug is the directory's name. + defaultProjectSlug: async (dir: string) => path.basename(dir), })); vi.mock('../graph-aggregate.js', () => ({ diff --git a/src/__tests__/session-analytics.test.ts b/src/__tests__/session-analytics.test.ts index 567c24392..c09dbec6a 100644 --- a/src/__tests__/session-analytics.test.ts +++ b/src/__tests__/session-analytics.test.ts @@ -1,5 +1,9 @@ -import { describe, it, expect } from 'vitest'; -import { canonicalRepo, attributeRepo } from '../utils/repo-attribution.js'; +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { execFileSync } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { canonicalRepo, attributeRepo, repoKeys, repoLabel, repoName } from '../utils/repo-attribution.js'; import { attributeByRepo, timeAnalytics, renderHourSparkline } from '../session-analytics.js'; import type { DashboardEvent, TokenUsage } from '../types.js'; @@ -42,6 +46,109 @@ describe('attributeRepo', () => { }); }); +describe('repoKeys (#809)', () => { + it('keys a session by the last anchor it recorded, else its last cwd', () => { + const keys = repoKeys([ + ev({ type: 'prompt_submit', timestamp: 't1', sessionId: 'wt', cwd: '/w/wt-a', projectAnchor: '/w/repo' }), + // Written after the worktree was removed: no anchor, and the session keeps it. + ev({ type: 'stop', timestamp: 't2', sessionId: 'wt', cwd: '/w/wt-a' }), + ev({ type: 'prompt_submit', timestamp: 't1', sessionId: 'old', cwd: '/w/old' }), + ev({ type: 'prompt_submit', timestamp: 't1', sessionId: 'none' }), + ]); + expect(Object.fromEntries(keys)).toEqual({ wt: '/w/repo', old: '/w/old', none: '' }); + }); +}); + +describe('repoLabel (#809)', () => { + it('names a key by its directory, qualified by the parent only when names collide', () => { + const keys = ['/w/work/api', '/w/personal/api', '/w/solo']; + expect(repoLabel('/w/work/api', keys)).toBe('work/api'); + expect(repoLabel('/w/personal/api', keys)).toBe('personal/api'); + expect(repoLabel('/w/solo', keys)).toBe('solo'); + }); + it('falls back to the whole key when the parent names collide too', () => { + expect(repoLabel('/a/work/api', ['/a/work/api', '/b/work/api'])).toBe('/a/work/api'); + }); + it('keeps no_repo for directories that are not a project', () => { + expect(repoLabel('/home', ['/home', '/root', ''])).toBe('no_repo'); + expect(repoLabel('', ['/home', ''])).toBe('no_repo'); + }); + it('gives remote-form keys that canonicalize alike one label, so they share a row', () => { + const keys = ['github.com/Tencent/teamai-cli', 'cnb.cool/Tencent/teamai-cli']; + expect(keys.map((k) => repoLabel(k, keys))).toEqual(['Tencent/teamai-cli', 'Tencent/teamai-cli']); + const repos = attributeByRepo(keys.map((cwd, i) => ev({ type: 'tool_use', timestamp: 't1', sessionId: `s${i}`, cwd }))); + expect(repos.map((r) => [r.repo, r.sessions])).toEqual([['Tencent/teamai-cli', 2]]); + }); +}); + +describe('repoName (#809)', () => { + let base = ''; + beforeAll(() => { + base = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'teamai-repo-name-'))); + }); + afterAll(() => { + if (base) fs.rmSync(base, { recursive: true, force: true }); + }); + const bare = (...segs: string[]) => { + const dir = path.join(base, ...segs); + execFileSync('git', ['init', '-q', '--bare', dir]); + return dir; + }; + + it('names a bare git directory after its repo', () => { + expect(repoName(bare('a', 'repo', '.bare'))).toBe('repo'); + expect(repoName(bare('b', 'repo', '.git'))).toBe('repo'); + expect(repoName(bare('c', 'repo.git'))).toBe('repo'); + }); + + it('keeps every other directory\'s own name', () => { + const checkout = path.join(base, 'd', 'foo.git'); + fs.mkdirSync(checkout, { recursive: true }); + execFileSync('git', ['init', '-q', checkout]); + // A checkout whose own files are named like a git directory's is still a checkout. + fs.writeFileSync(path.join(checkout, 'HEAD'), ''); + fs.mkdirSync(path.join(checkout, 'objects')); + expect(repoName(checkout)).toBe('foo.git'); + expect(repoName(path.join(base, 'gone', 'repo', '.bare'))).toBe('.bare'); + }); + + it('labels two bare repos with the same name by their parents', () => { + const keys = [bare('work', 'repo', '.bare'), bare('personal', 'repo', '.bare')]; + expect(keys.map((k) => repoLabel(k, keys))).toEqual(['work/repo', 'personal/repo']); + expect(repoLabel(keys[0], [keys[0]])).toBe('repo'); + }); + + it('names a repo after its directory even when that is a word like workspace, with its worktrees', () => { + for (const word of ['workspace', 'home', 'data']) { + const main = path.join(base, 'words', word); + const worktree = path.join(base, 'words', `${word}-wt`); + fs.mkdirSync(main, { recursive: true }); + execFileSync('git', ['init', '-q', main]); + execFileSync('git', ['-c', 'user.name=T', '-c', 'user.email=t@e.invalid', 'commit', '-q', '--allow-empty', '-m', 'init'], { cwd: main }); + execFileSync('git', ['worktree', 'add', '-q', worktree], { cwd: main }); + const repos = attributeByRepo([ + ev({ type: 'tool_use', timestamp: 't1', sessionId: 'main', cwd: main, projectAnchor: main }), + ev({ type: 'tool_use', timestamp: 't1', sessionId: 'wt', cwd: worktree, projectAnchor: main }), + ]); + expect(repos.map((r) => [r.repo, r.sessions])).toEqual([[word, 2]]); + } + // A directory with that name that is not a repo is still no_repo. + const plain = path.join(base, 'plain', 'data'); + fs.mkdirSync(plain, { recursive: true }); + expect(repoLabel(plain, [plain])).toBe('no_repo'); + }); + + it('counts a session in a bare layout\'s own directory as that repo', () => { + const anchor = bare('e', 'repo', '.bare'); + const container = path.join(base, 'e', 'repo'); + const repos = attributeByRepo([ + ev({ type: 'tool_use', timestamp: 't1', sessionId: 'wt', cwd: path.join(container, 'main'), projectAnchor: anchor }), + ev({ type: 'tool_use', timestamp: 't1', sessionId: 'top', cwd: container }), + ]); + expect(repos.map((r) => [r.repo, r.sessions])).toEqual([['repo', 2]]); + }); +}); + describe('attributeByRepo', () => { const events: DashboardEvent[] = [ // Session A in /home/u/alpha: 2 tools, 1 prompt, tokens 100/50 @@ -74,6 +181,16 @@ describe('attributeByRepo', () => { const repos = attributeByRepo(events); expect(repos[0].repo).toBe('alpha'); }); + + it('counts every worktree of a repo as the repo, and merges non-project dirs into no_repo', () => { + const repos = attributeByRepo([ + ev({ type: 'tool_use', timestamp: 't1', sessionId: 'main', cwd: '/w/repo', projectAnchor: '/w/repo' }), + ev({ type: 'tool_use', timestamp: 't1', sessionId: 'wt', cwd: '/w/wt-a', projectAnchor: '/w/repo' }), + ev({ type: 'tool_use', timestamp: 't1', sessionId: 'h', cwd: '/home' }), + ev({ type: 'tool_use', timestamp: 't1', sessionId: 'r', cwd: '/root' }), + ]); + expect(repos.map((r) => [r.repo, r.sessions]).sort()).toEqual([['no_repo', 2], ['repo', 2]]); + }); }); describe('timeAnalytics', () => { diff --git a/src/codebase-extract.ts b/src/codebase-extract.ts index fd8ef3fec..87bedeebd 100644 --- a/src/codebase-extract.ts +++ b/src/codebase-extract.ts @@ -6,7 +6,8 @@ * evidence pages, router/hot/index navigation, and gaps detection. */ -import { mkdir, writeFile, readFile } from 'node:fs/promises'; +import { statSync } from 'node:fs'; +import { mkdir, writeFile, readFile, realpath } from 'node:fs/promises'; import path from 'node:path'; import chalk from 'chalk'; @@ -36,6 +37,8 @@ import { mergeInterfaceInventories, } from './wiki-engine/code-knowledge/code-incremental.js'; import { writeIfChanged } from './utils/fs.js'; +import { resolveAnchors } from './utils/git.js'; +import { repoName } from './utils/repo-attribution.js'; import type { GraphIndex } from './wiki-engine/core/graph-index.schema.js'; import { routerTemplate, indexTemplate, HOT_TEMPLATE } from './wiki-engine/adapters/templates.js'; import type { DomainGroup, IndexStats } from './wiki-engine/adapters/templates.js'; @@ -533,9 +536,24 @@ function buildOverview( return lines.join('\n'); } +/** + * The wiki slug of `dir` when no `--project` is given (#809): a linked + * worktree's root takes its repo's name (repoName: the main checkout's, or a + * bare repo's), so every worktree of a repo writes the repo's evidence; any + * other directory keeps its own name. + */ +export async function defaultProjectSlug(dir: string): Promise { + if (!statSync(dir, { throwIfNoEntry: false })?.isDirectory()) return path.basename(dir); + const anchors = await resolveAnchors(dir); + if (anchors && anchors.workspaceRoot !== anchors.projectAnchor && await realpath(dir) === anchors.workspaceRoot) { + return repoName(anchors.projectAnchor); + } + return path.basename(dir); +} + export async function extractCodebase(opts: ExtractCodebaseOptions): Promise { const root = path.resolve(opts.path || '.'); - const project = opts.project || path.basename(root); + const project = opts.project || await defaultProjectSlug(root); const maxFiles = opts.maxFiles || 200; const outputBase = opts.outputRoot ? path.resolve(opts.outputRoot) : root; diff --git a/src/dashboard-collector.ts b/src/dashboard-collector.ts index a80e55500..defbf7e4a 100644 --- a/src/dashboard-collector.ts +++ b/src/dashboard-collector.ts @@ -6,6 +6,7 @@ import { log } from './utils/logger.js'; import { deriveSessionId } from './utils/session-id.js'; import { resolveHookCwd } from './utils/hook-cwd.js'; import { ensureDir } from './utils/fs.js'; +import { repoKeys, repoLabel } from './utils/repo-attribution.js'; import { resolveMonitorPid } from './pid-monitor.js'; import { normalizeToolName } from './utils/tool-names.js'; import { redactWithEnv } from './utils/redact.js'; @@ -1626,16 +1627,21 @@ export async function readEvents(eventsPath?: string): Promise export function rebuildSessions(events: DashboardEvent[]): DashboardSession[] { const sessions = new Map(); const now = Date.now(); + const keys = repoKeys(events); + const allKeys = new Set(keys.values()); for (const event of events) { let session = sessions.get(event.sessionId); if (!session) { + const repoKey = keys.get(event.sessionId) ?? ''; session = { sessionId: event.sessionId, tool: event.tool, status: 'running', cwd: event.cwd ?? '', + repoKey, + repoLabel: repoLabel(repoKey, allKeys), promptSummary: '', lastActivity: event.timestamp, startedAt: event.timestamp, @@ -1928,6 +1934,19 @@ export async function compactEvents(eventsPath?: string): Promise { } } +/** + * The `projectAnchor` an event records (#809): the main checkout of the repo + * holding the event's `cwd`, the directory the hook resolved its scope for. + * Undefined for an event that records no cwd, which keeps it free of paths + * (Copilot), outside git, and for a directory that no longer exists, which git + * refuses to open. Both event writers go through here. + */ +export async function eventProjectAnchor(cwd: string | undefined): Promise { + if (!cwd || !fs.existsSync(cwd)) return undefined; + const { resolveAnchors } = await import('./utils/git.js'); + return (await resolveAnchors(cwd))?.projectAnchor; +} + // ─── CLI entry point ──────────────────────────────────── /** @@ -1968,6 +1987,7 @@ export async function dashboardReport(toolArg?: string): Promise { if (!event) return; event.dataHome = getDataHome(config); + event.projectAnchor = await eventProjectAnchor(event.cwd); await appendEvent(event); // Trigger compaction check (non-blocking) diff --git a/src/dashboard.ts b/src/dashboard.ts index 83c338402..3f94a03d0 100644 --- a/src/dashboard.ts +++ b/src/dashboard.ts @@ -125,6 +125,8 @@ export async function startDashboard(port?: number): Promise { cwd: session.cwd, // The session's own data home, so its scope's report still sees it end. dataHome: events.find((e) => e.sessionId === session.sessionId && e.dataHome)?.dataHome, + // And its repo, which the cwd may no longer lead to (a removed worktree). + projectAnchor: [...events].reverse().find((e) => e.sessionId === session.sessionId && e.projectAnchor)?.projectAnchor, }; await appendEvent(exitEvent); log.info( diff --git a/src/dashboard/client.ts b/src/dashboard/client.ts index a94e88a5e..285987a17 100644 --- a/src/dashboard/client.ts +++ b/src/dashboard/client.ts @@ -34,13 +34,14 @@ const tokens = s => Object.values(s.tokens || {}).reduce((sum, v) => sum + (Numb function duration(s) { const ms = Date.parse(s.status === 'stopped' ? s.stoppedAt || s.lastActivity : new Date().toISOString()) - Date.parse(s.startedAt); if (!Number.isFinite(ms)) return '—'; const seconds = Math.max(0, Math.floor(ms/1000)); return seconds < 60 ? seconds+'s' : seconds < 3600 ? Math.floor(seconds/60)+'m' : Math.floor(seconds/3600)+'h '+Math.floor(seconds%3600/60)+'m'; } function ago(value) { const ms = Date.now()-Date.parse(value); if (!Number.isFinite(ms)) return '—'; if (ms<5000) return t('Just now'); return (ms<60000?Math.floor(ms/1000)+'s':ms<3600000?Math.floor(ms/60000)+'m':Math.floor(ms/3600000)+'h')+' '+t('ago'); } function status(s) { return label(statusNames[s.status] || s.status); } -function scopeSessions() { return sessions.filter(s => (!$('repo').value || s.cwd === $('repo').value) && (!$('agent').value || s.tool === $('agent').value)); } +function scopeSessions() { return sessions.filter(s => (!$('repo').value || s.repoKey === $('repo').value) && (!$('agent').value || s.tool === $('agent').value)); } function filters() { - for (const [id, field, title] of [['repo','cwd','All repositories'],['agent','tool','All AI tools']]) { + for (const [id, field, text, title] of [['repo','repoKey','repoLabel','All repositories'],['agent','tool','tool','All AI tools']]) { const selected = $(id).value; - const values = [...new Set(sessions.map(s => s[field]).filter(Boolean))].sort(); + const names = new Map(sessions.map(s => [s[field], s[text]])); + const values = [...names.keys()].filter(Boolean).sort(); if (selected && !values.includes(selected)) values.push(selected); - $(id).innerHTML = ''+values.map(value=>'').join(''); + $(id).innerHTML = ''+values.map(value=>'').join(''); $(id).value = selected === 'all' ? '' : selected; } } diff --git a/src/dashboard/workspaces.ts b/src/dashboard/workspaces.ts index c2bdbac9e..e0d8f96ff 100644 --- a/src/dashboard/workspaces.ts +++ b/src/dashboard/workspaces.ts @@ -20,8 +20,16 @@ async function configAt(home: string): Promise { catch { return null; } } const inside = (cwd: string, root: string) => cwd === root || cwd.startsWith(root + path.sep); -/** The project workspace that owns an event cwd (longest matching root wins), or null. */ -function ownerOf(cwd: string, projects: DashboardWorkspace[]): string | null { +/** + * The project workspace that owns an event, or null: the project rooted at the + * event's projectAnchor, which still names it after its worktree is removed + * (#809), else the one whose roots hold its cwd (longest matching root wins). + */ +function ownerOf(event: DashboardEvent, projects: DashboardWorkspace[]): string | null { + const anchored = event.projectAnchor && projects.find(w => w.root === event.projectAnchor); + if (anchored) return anchored.id; + const cwd = event.cwd; + if (!cwd) return null; const matches = projects.filter(w => w.roots.some(root => inside(cwd, root))); if (!matches.length) return null; return matches.sort((a, b) => @@ -54,7 +62,14 @@ export async function dashboardWorkspaces(events: DashboardEvent[]): Promise '')).trim(); if (path.isAbsolute(root)) await add(root, dataHome); } - for (const cwd of new Set([process.cwd(), ...events.map(event => event.cwd).filter(Boolean)])) { + // An event that recorded its repo names the project, even after its worktree + // is gone; git is asked only for the rest: older events, this process's cwd, + // and an anchor with no config of its own (a legacy `.teamai/` in a worktree). + for (const anchor of new Set(events.flatMap(event => event.projectAnchor ? [event.projectAnchor] : []))) { + await add(anchor, path.join(anchor, '.teamai')); + } + const unnamed = events.filter(event => !event.projectAnchor || !seen.has(event.projectAnchor)); + for (const cwd of new Set([process.cwd(), ...unnamed.map(event => event.cwd).filter(Boolean)])) { const anchors = await resolveAnchors(cwd).catch(() => null); if (anchors) { await add(anchors.projectAnchor, path.join(anchors.workspaceRoot, '.teamai')); @@ -64,7 +79,7 @@ export async function dashboardWorkspaces(events: DashboardEvent[]): Promise w.scope === 'project'); - if (events.some(event => event.cwd && !ownerOf(event.cwd, projects))) { + if (events.some(event => event.cwd && !ownerOf(event, projects))) { result.push({ id: 'unassigned', label: 'Unassigned sessions', scope: 'unassigned', root: '', config: null, roots: [] }); } return result; @@ -73,7 +88,7 @@ export function workspaceEvents(events: DashboardEvent[], workspace: DashboardWo const projects = workspaces.filter(w => w.scope === 'project'); const owners = new Map(); for (const event of events) { - const owner = event.cwd ? ownerOf(event.cwd, projects) : null; + const owner = ownerOf(event, projects); // First matching event fixes the whole session's owner; unmatched → 'unassigned'. if (!owners.has(event.sessionId)) owners.set(event.sessionId, owner ?? 'unassigned'); else if (owner) owners.set(event.sessionId, owner); diff --git a/src/hook-handlers.ts b/src/hook-handlers.ts index 694f51796..2fdca7978 100644 --- a/src/hook-handlers.ts +++ b/src/hook-handlers.ts @@ -191,7 +191,7 @@ const dashboardReportHandler: HookHandler = { async execute(stdin, tool, config) { // Registered with requiresConfig: a session outside any scope is not recorded. if (!config) return null; - const { parseHookEvent, appendEvent, compactEvents } = await import('./dashboard-collector.js'); + const { parseHookEvent, appendEvent, compactEvents, eventProjectAnchor } = await import('./dashboard-collector.js'); const { getDataHome } = await import('./types.js'); const raw = JSON.stringify(stdin); const event = await parseHookEvent(raw, tool, { @@ -202,6 +202,7 @@ const dashboardReportHandler: HookHandler = { // The dispatcher's scope, which knows the project even when the host // sends no cwd (Copilot) or a symlinked one (#785). event.dataHome = getDataHome(config); + event.projectAnchor = await eventProjectAnchor(event.cwd); await appendEvent(event); // Non-blocking compaction compactEvents().catch(() => {}); diff --git a/src/import.ts b/src/import.ts index 252510908..de0fe754c 100644 --- a/src/import.ts +++ b/src/import.ts @@ -322,7 +322,8 @@ export async function importCmd(opts: ImportOptions): Promise { if (!(await fs.pathExists(dirPath))) { throw new Error(`Directory not found: ${dirPath}`); } - const slug = path.basename(dirPath); + const { defaultProjectSlug, extractCodebase } = await import('./codebase-extract.js'); + const slug = await defaultProjectSlug(dirPath); log.info(`Scanning local directory: ${dirPath} (project: ${slug})`); if (opts.dryRun) { @@ -334,7 +335,6 @@ export async function importCmd(opts: ImportOptions): Promise { // 使用临时目录承接 extractCodebase 产物,避免污染源码目录已有的 teamwiki/ const tmpExtractDir = await fs.mkdtemp(path.join(os.tmpdir(), 'teamai-extract-')); try { - const { extractCodebase } = await import('./codebase-extract.js'); await extractCodebase({ path: dirPath, project: slug, diff --git a/src/index.ts b/src/index.ts index 03f6f43bc..5b4e81c2a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1117,7 +1117,7 @@ program .description('Inspect and maintain team-codebase outputs') .option('--extract [path]', 'Extract code knowledge and build graph from source') .addOption(new Option('--incremental', 'Only re-extract changed files (requires prior manifest)').hideHelp()) - .addOption(new Option('--project ', 'Project slug for --extract (defaults to directory name) and required for --deep-enrich').hideHelp()) + .addOption(new Option('--project ', 'Project slug for --extract (defaults to the directory name; a linked worktree\'s root uses the repo\'s name) and required for --deep-enrich').hideHelp()) .addOption(new Option('--max-files ', 'Max source files to scan (default: 200)').hideHelp()) .addOption(new Option('--upgrade-wiki', 'Migrate docs/team-codebase/ to teamwiki/ graph format').hideHelp()) .option('--lint', 'Run global consistency lint over the teamwiki knowledge graph') diff --git a/src/session-analytics.ts b/src/session-analytics.ts index e2553ffed..1816183f5 100644 --- a/src/session-analytics.ts +++ b/src/session-analytics.ts @@ -10,7 +10,7 @@ import { aggregateSessionMetrics } from './dashboard-collector.js'; import { emptyTokenUsage, addTokenUsage, totalTokens } from './types.js'; -import { attributeRepo } from './utils/repo-attribution.js'; +import { repoKeys, repoLabel } from './utils/repo-attribution.js'; import type { DashboardEvent, TokenUsage } from './types.js'; export interface RepoStat { @@ -23,28 +23,28 @@ export interface RepoStat { } /** - * Roll usage up per repo. Each session is attributed to the project of its cwd; - * its prompts/interventions/tokens (from aggregateSessionMetrics) and tool count - * are added to that repo's totals. Sorted by total tokens, then session count. + * Roll usage up per repo. Each session is attributed to its repo (repoKeys), so + * every worktree of a repo counts as the repo; its prompts/interventions/tokens + * (from aggregateSessionMetrics) and tool count are added to that repo's totals, + * under its repoLabel. Sorted by total tokens, then session count. */ export function attributeByRepo(events: DashboardEvent[]): RepoStat[] { - const sessionCwd = new Map(); const sessionTools = new Map(); - const sessionIds = new Set(); for (const e of events) { - sessionIds.add(e.sessionId); - if (e.cwd) sessionCwd.set(e.sessionId, e.cwd); if (e.type === 'tool_use') { sessionTools.set(e.sessionId, (sessionTools.get(e.sessionId) ?? 0) + 1); } } + const keys = repoKeys(events); + const allKeys = new Set(keys.values()); const metrics = aggregateSessionMetrics(events); const byRepo = new Map(); - for (const sid of sessionIds) { - const repo = attributeRepo(sessionCwd.get(sid)); + for (const [sid, key] of keys) { + // By label, not key: the directories that are not a repo share `no_repo`. + const repo = repoLabel(key, allKeys); let r = byRepo.get(repo); if (!r) { r = { repo, sessions: 0, prompts: 0, tools: 0, interventions: 0, tokens: emptyTokenUsage() }; diff --git a/src/session-collector.ts b/src/session-collector.ts index 13d62b6fe..d00f77030 100644 --- a/src/session-collector.ts +++ b/src/session-collector.ts @@ -23,6 +23,7 @@ import path from 'node:path'; import { ensureDir } from './utils/fs.js'; import { redactWithEnv } from './utils/redact.js'; import { aggregateSessionMetrics } from './dashboard-collector.js'; +import { repoKeys, repoLabel } from './utils/repo-attribution.js'; import { emptyTokenUsage } from './types.js'; import type { DashboardEvent, SessionMetrics } from './types.js'; @@ -30,6 +31,8 @@ import type { DashboardEvent, SessionMetrics } from './types.js'; export interface SessionSummary { sessionId: string; tool: string; + /** The session's repo (repoLabel over every session in the log), shared by its worktrees. */ + project: string; cwd: string; startedAt: string; endedAt: string; @@ -106,10 +109,12 @@ export function collectSession(sessionId: string, events: DashboardEvent[]): Ses const firstPrompt = firstPromptRaw ? redactWithEnv(firstPromptRaw).replace(/\s+/g, ' ').trim().slice(0, FIRST_PROMPT_MAX_CHARS) : ''; + const keys = repoKeys(events); return { sessionId, tool, + project: repoLabel(keys.get(sessionId) ?? '', new Set(keys.values())), cwd, startedAt, endedAt, @@ -154,7 +159,8 @@ export function renderSessionMarkdown(summary: SessionSummary, options: RenderOp ``, `### ${date} · ${shortId(summary.sessionId)} · ${summary.tool}`, '', - `- Project: \`${summary.cwd || 'unknown'}\``, + `- Project: \`${summary.project}\``, + `- Directory: \`${summary.cwd || 'unknown'}\``, `- Prompts: ${summary.prompts} · Tools: ${summary.toolTotal} (${summary.distinctTools} distinct)`, `- Interventions: interrupt ${iv.interrupt}, toolReject ${iv.toolReject}, correction ${iv.correction}`, ]; diff --git a/src/types.ts b/src/types.ts index 724c9cc77..da4f2e787 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1237,6 +1237,15 @@ export interface DashboardEvent { * existed; the report then attributes them by `cwd`. */ dataHome?: string; + /** + * Main checkout of the git repo holding `cwd` (`resolveAnchors().projectAnchor`), + * shared by all of its worktrees, so a session is attributed to its repo even + * after its worktree is removed (#809). Absent outside git, on events that + * record no `cwd` (Copilot) or a `cwd` that no longer exists, and on events + * written before this field existed; attribution then uses the session's last + * anchor, else `cwd`. + */ + projectAnchor?: string; /** First user prompt (captured from UserPromptSubmit) */ promptSummary?: string; /** @@ -1316,6 +1325,10 @@ export interface DashboardSession { status: DashboardSessionStatus; /** Working directory */ cwd: string; + /** Repo the session belongs to (`repoKeys`), shared by all worktrees of a repo. */ + repoKey: string; + /** Display name of `repoKey` (`repoLabel`), distinct across the sessions rebuilt with it. */ + repoLabel: string; /** First user prompt summary */ promptSummary: string; /** ISO 8601 timestamp of last activity */ diff --git a/src/utils/git.ts b/src/utils/git.ts index a9db64e43..3da9e033f 100644 --- a/src/utils/git.ts +++ b/src/utils/git.ts @@ -739,8 +739,25 @@ export interface ProjectAnchors { * `/private/tmp`) does not make the same checkout look like two different ones. * Case-insensitive-filesystem normalization is intentionally NOT done here; it * is only needed for the P1 slug hash and belongs with that change. + * - A directory's anchors are remembered for the life of the process, so the + * hook that resolves them for the config and again for the event runs git + * once (#809). A checkout does not change which repository it belongs to, and + * a long-lived process (the dashboard server) keeps answering for a worktree + * removed after it was first asked, which is what attribution wants. `null` + * is not remembered: a directory can become a repository later. */ +const anchorsByDir = new Map(); + export async function resolveAnchors(cwd?: string): Promise { + const dir = path.resolve(cwd ?? process.cwd()); + const known = anchorsByDir.get(dir); + if (known) return known; + const anchors = await readAnchors(cwd); + if (anchors) anchorsByDir.set(dir, anchors); + return anchors; +} + +async function readAnchors(cwd?: string): Promise { const git = createGit(cwd); let toplevel: string; let mainWorktree: string; diff --git a/src/utils/repo-attribution.ts b/src/utils/repo-attribution.ts index 5cf1e957b..3584d61dc 100644 --- a/src/utils/repo-attribution.ts +++ b/src/utils/repo-attribution.ts @@ -2,14 +2,19 @@ * Attribute a session's working directory to a stable repo label, so usage can * be broken down per project. * - * The dashboard event stream records a `cwd` per session but no git remote, so - * attribution here is by the working directory's project folder (session-level), - * not the per-turn remote resolution claude-cloud-sync does device-side. The + * The dashboard event stream records a `cwd` and, inside git, the repo's main + * checkout (`projectAnchor`) per event, but no git remote, so attribution here is + * by the project folder (session-level), not the per-turn remote resolution + * claude-cloud-sync does device-side. The * remote-form canonicalization below is ported from that project's repo_canon so * that, if a remote-qualified identity ever does show up, `github.com/o/r` and * `cnb.cool/o/r` collapse to the same platform-independent `owner/repo`. */ +import { existsSync } from 'node:fs'; +import path from 'node:path'; +import type { DashboardEvent } from '../types.js'; + /** Leaf directory names that aren't projects — attributed to 'no_repo'. */ const NON_REPO_LEAVES = new Set([ 'home', 'root', 'users', 'user', 'tmp', 'workspace', 'srv', 'mnt', 'data', 'opt', @@ -59,3 +64,83 @@ export function attributeRepo(cwd: string | undefined): string { if (NON_REPO_LEAVES.has(leaf.toLowerCase())) return 'no_repo'; return leaf; } + +/** + * The repo each session belongs to, keyed by session id: the last + * `projectAnchor` its events recorded, else its last `cwd` (events from before + * #809, directories outside git), else ''. Every worktree of a repo shares the + * anchor, and a session keeps it after its worktree is removed, because the + * events written after that carry no anchor. + */ +export function repoKeys(events: DashboardEvent[]): Map { + const anchors = new Map(); + const cwds = new Map(); + for (const e of events) { + if (e.projectAnchor) anchors.set(e.sessionId, e.projectAnchor); + if (e.cwd) cwds.set(e.sessionId, e.cwd); + } + const keys = new Map(); + for (const e of events) keys.set(e.sessionId, anchors.get(e.sessionId) ?? cwds.get(e.sessionId) ?? ''); + return keys; +} + +/** + * The directory that names each anchor, its name, and whether it is a repo + * (a checkout with `.git`, or a bare git directory); see {@link repoName}. + */ +const namedBy = new Map(); + +function naming(anchor: string): { dir: string; name: string; repo: boolean } { + const known = namedBy.get(anchor); + if (known) return known; + const checkout = existsSync(path.join(anchor, '.git')); + // A bare git directory: the anchor of a bare repo's worktrees (`repo/.bare`, `repo.git`). + const bare = !checkout && existsSync(path.join(anchor, 'HEAD')) && existsSync(path.join(anchor, 'objects')); + const stem = path.basename(anchor).replace(/\.git$/, ''); + const named = !bare ? { dir: anchor, name: path.basename(anchor), repo: checkout } + : stem && !stem.startsWith('.') ? { dir: anchor, name: stem, repo: true } + : { dir: path.dirname(anchor), name: path.basename(path.dirname(anchor)), repo: true }; + namedBy.set(anchor, named); + return named; +} + +/** + * The name of the repo anchored at `anchor`: its directory's name, except for + * a bare git directory, which is named without its `.git` suffix (`repo.git`) + * or, when that leaves a hidden or empty name, after the directory holding it + * (`repo/.bare`, `repo/.git`). A directory that no longer exists is not bare. + * Every answer, "not bare" included, is kept for the life of the process: labels + * are rebuilt on each dashboard update, and a directory does not turn into a + * bare git directory under a running process in practice. + */ +export function repoName(anchor: string): string { + return naming(anchor).name; +} + +/** + * Display name of a repo key: its {@link attributeRepo} name ({@link repoName} + * for a path), prefixed with the parent directory's name when another path key + * in `allKeys` has the same name (`work/api`, `personal/api`), or the whole key + * when that still collides, so two repos never share a label. Keys named by the + * same directory (a bare repo's `repo/.bare` and a session in `repo/`) are one + * repo and share a label. A remote-form key keeps its canonical `owner/repo`, + * which it shares with the same repo on another host. + */ +export function repoLabel(key: string, allKeys: Iterable): string { + // A repo keeps its name even when it is a word attributeRepo reserves for + // directories that are not projects (a repo in `~/workspace`). + const nameOf = (k: string) => { + if (path.isAbsolute(k) && naming(k).repo) return repoName(k); + const name = attributeRepo(k); + return name === 'no_repo' || !path.isAbsolute(k) ? name : repoName(k); + }; + const name = nameOf(key); + if (name === 'no_repo' || !path.isAbsolute(key)) return name; + const dir = naming(key).dir; + const others = [...new Set(allKeys)].filter((k) => + k !== key && path.isAbsolute(k) && nameOf(k) === name && naming(k).dir !== dir); + if (others.length === 0) return name; + const qualified = (k: string) => `${path.basename(path.dirname(naming(k).dir))}/${nameOf(k)}`; + const label = qualified(key); + return others.some((k) => qualified(k) === label) ? key : label; +}