diff --git a/docs/designs/data-directory-layout.md b/docs/designs/data-directory-layout.md index 57e489fc2..479b57072 100644 --- a/docs/designs/data-directory-layout.md +++ b/docs/designs/data-directory-layout.md @@ -466,7 +466,18 @@ main checkout, which all of its worktrees share (#809); a Copilot event, with no `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 +is removed. A hook whose `cwd` no longer exists (a session that outlives its +worktree) would resolve to the user scope, or be dropped without one, so it +keeps the scope its session last recorded instead (#810): the config at that +event's `projectAnchor` (for a bare repo, at one of its worktrees that still +exists), used only while the key of its `getDataHome()` is still the recorded +`dataHomeKey`. Its events and skill uses stay with the project, and the share +reminder's gate reads the project too. A project config there that +cannot be read records nothing, and with nothing recorded to match the hook +resolves from its `cwd` as before. The recorded scope is read from `events.jsonl`, so it lasts as long +as the session's earlier events do (compaction keeps only active sessions), +and Copilot, whose events record no directory, has none to recover. +The snapshots of what was already reported are per scope too (#786), because a session ID can recur in another scope (Copilot's fallback ID is the parent PID): `/dashboard/reported-*.json`, and `~/.teamai/dashboard/user-reported-*.json` diff --git a/docs/designs/team-intelligence-platform.md b/docs/designs/team-intelligence-platform.md index 738f0b26a..3f160f004 100644 --- a/docs/designs/team-intelligence-platform.md +++ b/docs/designs/team-intelligence-platform.md @@ -69,7 +69,7 @@ Transform TeamAI from a simple skill-sharing CLI into a **Team Intelligence Plat **Storage:** `~/.teamai/sessions/.md` 按月聚合。 #### 2. Skill Usage Tracker (Local) -**What:** PostToolUse hook 检测 Claude Code 的 Skill 工具调用,追加写入该 scope 的 `/usage.jsonl`(会话所在目录对应的已配置项目,否则 user scope 的 `~/.teamai/user-usage.jsonl`;未配置 teamai 的目录不记录,#748)。 +**What:** PostToolUse hook 检测 Claude Code 的 Skill 工具调用,追加写入该 scope 的 `/usage.jsonl`(会话所在目录对应的已配置项目,否则 user scope 的 `~/.teamai/user-usage.jsonl`;未配置 teamai 的目录不记录,#748;目录已删除时沿用该会话最后记录的 scope,#810)。 **Data format (JSONL, one event per line):** ```jsonl diff --git a/docs/usage-guide.md b/docs/usage-guide.md index 9cf535959..1be2daa71 100644 --- a/docs/usage-guide.md +++ b/docs/usage-guide.md @@ -553,7 +553,7 @@ teamai pull --dry-run # Dry run, no actual changes A manual `teamai pull` ends by running the `teamai doctor` checks and printing each one that failed, with its fix — including whether the skills it just reported syncing are readable on disk for every enabled tool. It prints nothing when they all pass, and the exit code is unchanged. The SessionStart hook path and `--dry-run` run no checks at all, so session startup stays as fast as before. Provider checks (`gh`/`gf` authentication) are left to `teamai doctor`: the pull just used the provider. -> Project scope is isolated by default. When the current working directory contains a project-scope `.teamai/config.yaml`, `pull` processes that project and skips user scope unless the local config has `inheritUserScope: true`; in that case it first refreshes the safe user-resource channel. Without a project config in the current directory, `pull` processes user scope. User `env`, MCP definitions, sources, reporting, and writes remain isolated in project mode. Hooks are the one exception: a project scope's hooks are injected into your **HOME** tool settings (`~/.claude/settings.json`, …), not ``, because the built-in hooks gate on the `cwd` handed to `hook-dispatch` and `~/.claude` always exists so the "installed tool" gate passes (see the Hooks section). In a directory with no teamai config (no project config and no user scope), the team hooks do nothing: no reminders, and no session or skill usage is recorded; only machine-level work runs (the CLI update check, the session-start pull, the local agent, and package hints a pull stashed). For the team hooks and skill usage, a project config that exists but cannot be read counts as none, never as the user scope or as a lower-priority project config (such as a legacy `.teamai/config.yaml`) behind it. `pull` follows the same rule: it syncs no scope there, prints ``Nothing was synced: : . Fix the file, or move it aside and run `teamai init` to write a new one.`` and exits 1 (with `--silent`, it prints nothing and still exits 1); a session start there runs no pull, seeds no agent directory and stashes no package hint. Self single-repo mode keeps its hooks in the business repo so they travel on clone. +> Project scope is isolated by default. When the current working directory contains a project-scope `.teamai/config.yaml`, `pull` processes that project and skips user scope unless the local config has `inheritUserScope: true`; in that case it first refreshes the safe user-resource channel. Without a project config in the current directory, `pull` processes user scope. User `env`, MCP definitions, sources, reporting, and writes remain isolated in project mode. Hooks are the one exception: a project scope's hooks are injected into your **HOME** tool settings (`~/.claude/settings.json`, …), not ``, because the built-in hooks gate on the `cwd` handed to `hook-dispatch` and `~/.claude` always exists so the "installed tool" gate passes (see the Hooks section). In a directory with no teamai config (no project config and no user scope), the team hooks do nothing: no reminders, and no session or skill usage is recorded; only machine-level work runs (the CLI update check, the session-start pull, the local agent, and package hints a pull stashed). For the team hooks and skill usage, a project config that exists but cannot be read counts as none, never as the user scope or as a lower-priority project config (such as a legacy `.teamai/config.yaml`) behind it. `pull` follows the same rule: it syncs no scope there, prints ``Nothing was synced: : . Fix the file, or move it aside and run `teamai init` to write a new one.`` and exits 1 (with `--silent`, it prints nothing and still exits 1); a session start there runs no pull, seeds no agent directory and stashes no package hint. A hook whose `cwd` was deleted (a session that outlives its worktree) keeps the scope its session last recorded, so the session's last events and skill uses stay with the project, and its share reminder follows the project's settings, instead of the user scope's. This needs the session's earlier events in the local event log, which compaction trims to active sessions, and does not cover Copilot, whose events record no directory. Self single-repo mode keeps its hooks in the business repo so they travel on clone. With role-based skills enabled, `pull`'s skill sync source becomes the contents of `skills//`, expanded according to `primaryRole + additionalRoles` and flattened into each local AI tool's skills directory. `rules/` and `docs/` keep their original sync behavior; `agents//` follows the role's `agents` namespaces (see [Agents Resource Type](#agents-resource-type)). `learnings/` at the root is shared with everyone, while `learnings//` subdirectories sync only for the directory's active projects (see [Multi-project](#multi-project-project-as-a-dimension-orthogonal-to-role)). diff --git a/docs/usage-guide.zh-CN.md b/docs/usage-guide.zh-CN.md index 84201047d..aca7f557e 100644 --- a/docs/usage-guide.zh-CN.md +++ b/docs/usage-guide.zh-CN.md @@ -512,7 +512,7 @@ teamai pull --dry-run # 试运行,不实际修改 手动执行 `teamai pull` 会在结束时运行 `teamai doctor` 的检查,并逐条打印失败项及其修复建议——包括它刚刚报告同步的 skill 是否真的落到每个启用工具的磁盘上、且可被读取。全部通过时不会有任何额外输出,退出码也不变。SessionStart hook 路径和 `--dry-run` 完全不运行检查,会话启动速度保持不变。托管平台相关的检查(`gh`/`gf` 认证)留给 `teamai doctor`:这次 pull 刚刚用过该平台。 -> Project scope 默认与 user scope 隔离。当前工作目录包含 project scope 的 `.teamai/config.yaml` 时,`pull` 会处理该项目并跳过 user scope;仅当本地配置包含 `inheritUserScope: true` 时,才会先刷新安全的 user 资源通道。当前目录没有 project 配置时,`pull` 处理 user scope。project 模式下,user 的 `env`、MCP 定义、sources、reporting 和写入行为仍保持隔离。hooks 是唯一例外:project scope 的 hooks 会注入到你的 **HOME** 工具设置(`~/.claude/settings.json` 等),而非 ``——因为内置 hooks 依据传给 `hook-dispatch` 的 `cwd` 门控,且 `~/.claude` 恒存在、能通过「已安装工具」门槛(详见 Hooks 章节)。在没有 teamai 配置的目录中(既没有 project 配置也没有 user scope),团队 hooks 不做任何事:不显示提醒,也不记录会话或 skill 使用;只运行机器级别的工作(CLI 更新检查、SessionStart 时的 pull、本地 agent,以及 pull 暂存的包提示)。对团队 hooks 和 skill 使用记录而言,存在但无法读取的 project 配置视为没有配置,而不会退回 user scope,也不会退回其后优先级更低的 project 配置(如旧的 `.teamai/config.yaml`)。`pull` 遵循同一规则:此时不同步任何 scope,输出 ``Nothing was synced: : . Fix the file, or move it aside and run `teamai init` to write a new one.`` 并以 exit 1 退出(加 `--silent` 时不输出,但仍以 exit 1 退出);会话启动时不运行 pull,也不创建 agent 目录、不暂存包提示。self 单仓模式则把 hooks 保留在业务仓库里,随 clone 传播。 +> Project scope 默认与 user scope 隔离。当前工作目录包含 project scope 的 `.teamai/config.yaml` 时,`pull` 会处理该项目并跳过 user scope;仅当本地配置包含 `inheritUserScope: true` 时,才会先刷新安全的 user 资源通道。当前目录没有 project 配置时,`pull` 处理 user scope。project 模式下,user 的 `env`、MCP 定义、sources、reporting 和写入行为仍保持隔离。hooks 是唯一例外:project scope 的 hooks 会注入到你的 **HOME** 工具设置(`~/.claude/settings.json` 等),而非 ``——因为内置 hooks 依据传给 `hook-dispatch` 的 `cwd` 门控,且 `~/.claude` 恒存在、能通过「已安装工具」门槛(详见 Hooks 章节)。在没有 teamai 配置的目录中(既没有 project 配置也没有 user scope),团队 hooks 不做任何事:不显示提醒,也不记录会话或 skill 使用;只运行机器级别的工作(CLI 更新检查、SessionStart 时的 pull、本地 agent,以及 pull 暂存的包提示)。对团队 hooks 和 skill 使用记录而言,存在但无法读取的 project 配置视为没有配置,而不会退回 user scope,也不会退回其后优先级更低的 project 配置(如旧的 `.teamai/config.yaml`)。`pull` 遵循同一规则:此时不同步任何 scope,输出 ``Nothing was synced: : . Fix the file, or move it aside and run `teamai init` to write a new one.`` 并以 exit 1 退出(加 `--silent` 时不输出,但仍以 exit 1 退出);会话启动时不运行 pull,也不创建 agent 目录、不暂存包提示。`cwd` 已被删除的 hook(会话比它的 worktree 活得更久)沿用该会话最后记录的 scope,因此会话最后的事件和 skill 使用仍归属项目,分享提醒也遵循项目的设置,而不是 user scope 的。这需要本地事件日志中仍保留该会话之前的事件(压缩只保留活跃会话),且不适用于 Copilot,因为它的事件不记录目录。self 单仓模式则把 hooks 保留在业务仓库里,随 clone 传播。 启用角色化 skills 后,`pull` 的 skills 同步来源会变成 `skills//` 中的内容,按 `primaryRole + additionalRoles` 展开对应的 namespace,拍平安装到本地各 AI 工具 skills 目录。`rules/`、`docs/` 仍然保持原有同步逻辑;`agents//` 按角色的 `agents` namespace 同步(见 [Agents 资源类型](#agents-资源类型))。`learnings/` 根目录对所有人共享,而 `learnings//` 子目录只对本目录激活的项目同步(见 [多项目](#多项目project-作为与-role-正交的维度))。 diff --git a/src/__tests__/e2e/deleted-worktree-scope-810.test.ts b/src/__tests__/e2e/deleted-worktree-scope-810.test.ts new file mode 100644 index 000000000..03ce61a7b --- /dev/null +++ b/src/__tests__/e2e/deleted-worktree-scope-810.test.ts @@ -0,0 +1,365 @@ +/** + * E2E (#810): hook events that arrive after their worktree is deleted stay in + * the session's project. + * + * The scope of a hook event is resolved from its `cwd`, and a directory that + * no longer exists resolved to the user scope. So once `git worktree remove` + * ran, a session's last events were recorded under the user scope (and counted + * by it), or dropped when there was none, and its skill uses went to the user + * scope's usage file. + */ +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { execFileSync, spawn } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { dataHomeKey } from '../../dashboard-collector.js'; +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; +} + +interface RecordedEvent { + type: string; + sessionId: string; + cwd?: string; + dataHomeKey?: string; + projectAnchor?: string; +} + +const SESSION = 's-b'; + +function cliEnv(home: string): NodeJS.ProcessEnv { + // The host's session id would override the payloads' own. + 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 } }); +} + +function readJsonl(file: string): T[] { + if (!fs.existsSync(file)) return []; + return fs.readFileSync(file, 'utf8').split('\n').filter(Boolean).map((line) => JSON.parse(line) as T); +} + +interface ScenarioOptions { + /** Also set up the user scope, which the events must not reach. */ + userScope: boolean; + /** Make the project's config unreadable once `b` is removed. */ + breakProjectConfig?: boolean; + /** Turn the share reminder off in the project only, and score the session so a Stop would show it. */ + projectHintOff?: boolean; + /** + * The session records an event in a user-scope directory before `b` is + * removed, as when it moves on while `b`'s detached Stop has yet to run. + */ + movedOn?: boolean; + /** A bare repo (`proj/.bare`) whose checkouts are all worktrees (`proj/main`, `proj/b`). */ + bare?: boolean; +} + +/** + * One sandbox: a project installed in its partition, a worktree `b` outside + * the checkout, and optionally a user-scope config. A session in `b` records a + * prompt and a skill use, `b` is removed, and the session keeps sending hooks. + */ +function scenario({ userScope, breakProjectConfig = false, bare = false, projectHintOff = false, movedOn = false }: ScenarioOptions) { + const sandbox = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'teamai-issue810-e2e-'))); + const home = path.join(sandbox, 'home'); + // The checkout the project was set up in, and the repo's anchor (its main worktree). + const repo = bare ? path.join(sandbox, 'proj', 'main') : path.join(sandbox, 'repo'); + const anchor = bare ? path.join(sandbox, 'proj', '.bare') : repo; + const worktree = bare ? path.join(sandbox, 'proj', 'b') : path.join(sandbox, 'b'); + const partition = path.join(home, '.teamai', 'projects', projectSlug(anchor)); + const eventsFile = path.join(home, '.teamai', 'dashboard', 'events.jsonl'); + const state = { sandbox, home, repo, anchor, worktree, partition, eventsFile, before: 0, stopOutput: '', settled: false }; + + const hook = async (event: string, payload: Record, matcher = '*', tool = 'claude') => { + const r = await runCLI(['hook-dispatch', event, '--tool', tool, '--matcher', matcher, '--stdin'], home, home, + JSON.stringify({ session_id: SESSION, cwd: worktree, ...payload })); + expect(r.code, r.output).toBe(0); + return r.output; + }; + const legacy = async (args: string[], payload: Record) => { + const r = await runCLI([...args, '--stdin', '--tool', 'claude'], home, home, + JSON.stringify({ session_id: SESSION, cwd: worktree, ...payload })); + expect(r.code, r.output).toBe(0); + }; + const skillUse = { hook_event_name: 'PostToolUse', tool_name: 'Skill', tool_input: { skill: 'team-skill' }, tool_response: {} }; + + const setup = async () => { + 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-810-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); + + const source = bare ? path.join(sandbox, 'src') : repo; + fs.mkdirSync(source, { recursive: true }); + fs.writeFileSync(path.join(source, 'README.md'), '# repo\n'); + git(['init', '-q', '-b', 'main'], source); + git(['add', '-A'], source); + git(['commit', '-q', '-m', 'project'], source); + if (bare) { + git(['clone', '-q', '--bare', source, anchor], sandbox); + git(['worktree', 'add', '-q', repo, 'main'], anchor); + } + git(['worktree', 'add', '-q', worktree, '-b', 'b'], repo); + + fs.mkdirSync(partition, { recursive: true }); + git(['clone', '-q', remote, path.join(partition, 'team-repo')], sandbox); + fs.writeFileSync(path.join(partition, 'anchor'), `${anchor}\n`); + fs.writeFileSync(path.join(partition, 'config.yaml'), [ + 'repo:', + ` localPath: ${path.join(partition, 'team-repo')}`, + ` remote: ${remote}`, + 'username: ci-810', + 'updatePolicy: auto', + 'scope: project', + `projectRoot: ${repo}`, + 'enabledAgents: [claude]', + // A relocated Claude Code that only the project records. + 'toolRoots:', + ' claude: .claude-alt', + ...(projectHintOff ? ['contributeHintEnabled: false'] : []), + '', + ].join('\n')); + if (userScope) { + git(['clone', '-q', remote, path.join(home, '.teamai', 'team-repo')], sandbox); + fs.writeFileSync(path.join(home, '.teamai', 'config.yaml'), [ + 'repo:', + ` localPath: ${path.join(home, '.teamai', 'team-repo')}`, + ` remote: ${remote}`, + 'username: ci-810', + 'updatePolicy: auto', + 'scope: user', + 'enabledAgents: [claude]', + // The share reminder needs recall; only the user scope turns it on. + ...(projectHintOff ? ['recallEnabled: true'] : []), + '', + ].join('\n')); + } + // A skill on disk under the project's Claude root, so `track-slash` counts + // `/team-skill` only when it looks there. + fs.mkdirSync(path.join(home, '.claude-alt', 'skills', 'team-skill'), { recursive: true }); + fs.writeFileSync(path.join(home, '.claude-alt', 'skills', 'team-skill', 'SKILL.md'), '---\nname: team-skill\ndescription: fixture\n---\n'); + + // While the worktree exists: a prompt and a skill use. + await hook('prompt-submit', { hook_event_name: 'UserPromptSubmit', prompt: 'work in b' }); + await hook('post-tool-use', skillUse, 'Skill'); + if (movedOn) { + // Another repo, in the user scope: it records a scope and an anchor of its own. + const elsewhere = path.join(sandbox, 'elsewhere'); + fs.mkdirSync(elsewhere); + git(['init', '-q', '-b', 'main'], elsewhere); + git(['commit', '-q', '--allow-empty', '-m', 'init'], elsewhere); + await hook('prompt-submit', { hook_event_name: 'UserPromptSubmit', prompt: 'work elsewhere', cwd: elsewhere }); + } + state.before = readJsonl(eventsFile).length; + + git(['worktree', 'remove', '--force', worktree], repo); + if (breakProjectConfig) fs.writeFileSync(path.join(partition, 'config.yaml'), 'repo: [unterminated\n'); + if (projectHintOff) writeSessionState({ smartScore: 30, toolCount: 20, lastEvaluated: Date.now(), + friction: { interrupt: 1, toolReject: 0, correction: 0, toolError: 0 }, promptSummary: 'work in b' }); + + // After it is gone, through the dispatcher... + await hook('prompt-submit', { hook_event_name: 'UserPromptSubmit', prompt: '/team-skill again' }); + await hook('post-tool-use', { hook_event_name: 'PostToolUse', tool_name: 'Edit', tool_input: {}, tool_response: {} }); + await hook('post-tool-use', skillUse, 'Skill'); + await hook('session-end', { hook_event_name: 'SessionEnd' }); + state.stopOutput = await hook('stop', { hook_event_name: 'Stop' }); + // ...and through the entry points hooks from an earlier install still call. + await legacy(['dashboard-report'], { hook_event_name: 'UserPromptSubmit', prompt: 'legacy prompt' }); + await legacy(['track'], skillUse); + await legacy(['track-slash'], { hook_event_name: 'UserPromptSubmit', prompt: '/team-skill go' }); + + // SessionEnd and Stop are recorded by a detached child: wait for them. Where + // nothing may be recorded, wait instead for a later session's detached Stop, + // spawned after this session's, so theirs have had their turn. + if (breakProjectConfig) await hook('stop', { hook_event_name: 'Stop', session_id: 'control', cwd: home }); + const settled = breakProjectConfig + ? () => readJsonl(eventsFile).some((e) => e.sessionId === 'control' && e.type === 'stop') + : () => ['session_end', 'stop'].every((type) => afterRemoval().some((e) => e.type === type)); + const deadline = Date.now() + 20_000; + while (!settled() && Date.now() < deadline) { + await new Promise((r) => setTimeout(r, 200)); + } + state.settled = settled(); + }; + + // The session's contribute state, as the Stop hook's scoring leaves it. + const writeSessionState = (fields: Record) => { + fs.mkdirSync(path.join(home, '.teamai', 'sessions'), { recursive: true }); + fs.writeFileSync(path.join(home, '.teamai', 'sessions', `${SESSION}.json`), JSON.stringify(fields)); + }; + + const afterRemoval = () => readJsonl(eventsFile).slice(state.before).filter((e) => e.sessionId === SESSION); + const cleanup = () => fs.rmSync(sandbox, { recursive: true, force: true }); + return { state, setup, afterRemoval, cleanup, hook, writeSessionState }; +} + +describe('hook events after their worktree is deleted (#810)', () => { + describe('with a user-scope config as well', () => { + const s = scenario({ userScope: true }); + beforeAll(async () => { + if (!fs.existsSync(CLI)) throw new Error(`CLI binary not found at ${CLI}. Run "npm run build" first.`); + await s.setup(); + }, 120_000); + afterAll(() => s.cleanup()); + + it('records them under the project, with the session\'s repo', async () => { + const events = s.afterRemoval(); + expect(events.map((e) => e.type).sort()).toEqual(['prompt_submit', 'prompt_submit', 'session_end', 'stop', 'tool_use']); + const key = await dataHomeKey(s.state.partition); + expect(events.map((e) => [e.type, e.dataHomeKey, e.projectAnchor])).toEqual( + events.map((e) => [e.type, key, s.state.anchor])); + }); + + it('does not count the session in the user scope\'s stats', async () => { + const user = await runCLI(['stats'], s.state.home, s.state.home); + expect(user.code, user.output).toBe(0); + expect(user.output).not.toMatch(/Sessions:\s+[1-9]/); + const project = await runCLI(['stats'], s.state.repo, s.state.home); + expect(project.output).toMatch(/Sessions:\s+1\b/); + }); + + it('records the skill uses in the project\'s usage, not the user scope\'s', () => { + expect(readJsonl(path.join(s.state.partition, 'usage.jsonl'))).toHaveLength(5); + expect(readJsonl(path.join(s.state.home, '.teamai', 'user-usage.jsonl'))).toHaveLength(0); + }); + }); + + describe('without a user-scope config', () => { + const s = scenario({ userScope: false }); + beforeAll(async () => { + if (!fs.existsSync(CLI)) throw new Error(`CLI binary not found at ${CLI}. Run "npm run build" first.`); + await s.setup(); + }, 120_000); + afterAll(() => s.cleanup()); + + it('records them under the project instead of dropping them', async () => { + const events = s.afterRemoval(); + expect(events.map((e) => e.type).sort()).toEqual(['prompt_submit', 'prompt_submit', 'session_end', 'stop', 'tool_use']); + const key = await dataHomeKey(s.state.partition); + expect(events.every((e) => e.dataHomeKey === key && e.projectAnchor === s.state.anchor)).toBe(true); + }); + + it('records the skill uses in the project\'s usage', () => { + expect(readJsonl(path.join(s.state.partition, 'usage.jsonl'))).toHaveLength(5); + }); + }); + + describe('when the project\'s config can no longer be read', () => { + const s = scenario({ userScope: true, breakProjectConfig: true }); + beforeAll(async () => { + if (!fs.existsSync(CLI)) throw new Error(`CLI binary not found at ${CLI}. Run "npm run build" first.`); + await s.setup(); + }, 120_000); + afterAll(() => s.cleanup()); + + it('drops them rather than handing them to the user scope (#748)', () => { + expect(s.state.settled).toBe(true); + expect(s.afterRemoval()).toEqual([]); + expect(readJsonl(path.join(s.state.home, '.teamai', 'user-usage.jsonl'))).toHaveLength(0); + }); + }); + + describe('in a bare repo, whose anchor is the git directory', () => { + const s = scenario({ userScope: true, bare: true }); + beforeAll(async () => { + if (!fs.existsSync(CLI)) throw new Error(`CLI binary not found at ${CLI}. Run "npm run build" first.`); + await s.setup(); + }, 120_000); + afterAll(() => s.cleanup()); + + it('records them under the project, with the session\'s repo', async () => { + const events = s.afterRemoval(); + expect(events.map((e) => e.type).sort()).toEqual(['prompt_submit', 'prompt_submit', 'session_end', 'stop', 'tool_use']); + const key = await dataHomeKey(s.state.partition); + expect(events.map((e) => [e.type, e.dataHomeKey, e.projectAnchor])).toEqual( + events.map((e) => [e.type, key, s.state.anchor])); + }); + + it('records the skill uses in the project\'s usage, not the user scope\'s', () => { + expect(readJsonl(path.join(s.state.partition, 'usage.jsonl'))).toHaveLength(5); + expect(readJsonl(path.join(s.state.home, '.teamai', 'user-usage.jsonl'))).toHaveLength(0); + }); + }); + + describe('a session that moved on before the late hooks of its removed worktree', () => { + const s = scenario({ userScope: true, movedOn: true }); + beforeAll(async () => { + if (!fs.existsSync(CLI)) throw new Error(`CLI binary not found at ${CLI}. Run "npm run build" first.`); + await s.setup(); + }, 120_000); + afterAll(() => s.cleanup()); + + it('records them under the worktree\'s project, not where the session went next', async () => { + const events = s.afterRemoval(); + expect(events.map((e) => e.type).sort()).toEqual(['prompt_submit', 'prompt_submit', 'session_end', 'stop', 'tool_use']); + const key = await dataHomeKey(s.state.partition); + expect(events.map((e) => [e.type, e.dataHomeKey, e.projectAnchor])).toEqual( + events.map((e) => [e.type, key, s.state.anchor])); + expect(readJsonl(path.join(s.state.partition, 'usage.jsonl'))).toHaveLength(5); + }); + }); + + describe('the share reminder, which only the user scope allows', () => { + const s = scenario({ userScope: true, projectHintOff: true }); + beforeAll(async () => { + if (!fs.existsSync(CLI)) throw new Error(`CLI binary not found at ${CLI}. Run "npm run build" first.`); + await s.setup(); + }, 120_000); + afterAll(() => s.cleanup()); + + it('is not shown on the Stop of a removed worktree\'s session', () => { + expect(s.state.stopOutput).not.toContain('worth documenting'); + }); + + it('is not delivered from a stash on the next prompt either', async () => { + // A tool whose Stop cannot print stashes the hint for its next prompt. + s.writeSessionState({ pendingHint: '[teamai] STASHED-REMINDER' }); + const output = await s.hook('prompt-submit', { hook_event_name: 'UserPromptSubmit', prompt: 'next' }, '*', 'codebuddy'); + expect(output).not.toContain('STASHED-REMINDER'); + }); + + it('is not shown by the legacy contribute-check command either', async () => { + s.writeSessionState({ smartScore: 30, toolCount: 20, lastEvaluated: Date.now(), + friction: { interrupt: 1, toolReject: 0, correction: 0, toolError: 0 }, promptSummary: 'work in b' }); + const r = await runCLI(['contribute-check', '--stdin', '--tool', 'claude'], s.state.home, s.state.home, + JSON.stringify({ session_id: SESSION, cwd: s.state.worktree, hook_event_name: 'Stop' })); + expect(r.code, r.output).toBe(0); + expect(r.output).not.toContain('worth documenting'); + }); + }); +}); diff --git a/src/__tests__/git-kind-reports.test.ts b/src/__tests__/git-kind-reports.test.ts index ac46a3d34..ed1d12c6c 100644 --- a/src/__tests__/git-kind-reports.test.ts +++ b/src/__tests__/git-kind-reports.test.ts @@ -12,7 +12,7 @@ import { getDataHome, getReportsDir, REPORTS_WORKTREE_DIRNAME, type LocalConfig import { commitAndPushReports, ensureReportsWorktree, refreshReportsWorktree, updateReports } from '../utils/reports-branch.js'; import { pushRepoDirectly } from '../utils/git.js'; import { reportUsageToTeam } from '../team-push.js'; -import { dataHomeKey } from '../dashboard-collector.js'; +import { dataHomeKey, resolveHookConfig } from '../dashboard-collector.js'; import { listMembers } from '../members.js'; import { resolveProjectDataHome, saveLocalConfigForScope } from '../config.js'; import { buildHandlerRegistry } from '../hook-handlers.js'; @@ -660,7 +660,9 @@ describe('skill usage stays in the scope that recorded it (#748)', () => { async function useSkill(cwd: string, skill: string): Promise { const track = buildHandlerRegistry().find((r) => r.handler.name === 'track'); if (!track) throw new Error('track handler is not registered'); - await track.handler.execute({ session_id: `s-${skill}`, cwd, tool_name: 'Skill', tool_input: { skill } }, 'claude', null); + // The handler records under the scope the dispatcher resolved for the hook (#810). + const payload = { session_id: `s-${skill}`, cwd, tool_name: 'Skill', tool_input: { skill } }; + await track.handler.execute(payload, 'claude', await resolveHookConfig(payload, 'claude')); } async function reportedSkills(origin: string): Promise { diff --git a/src/__tests__/hook-config-810.test.ts b/src/__tests__/hook-config-810.test.ts new file mode 100644 index 000000000..9311e1a64 --- /dev/null +++ b/src/__tests__/hook-config-810.test.ts @@ -0,0 +1,78 @@ +/** + * resolveHookConfig (#810): a hook whose cwd is gone keeps the scope its + * session recorded, but only when the config at the recorded repo is still the + * scope of that data home. The e2e test covers the common case; these cover + * the answers that must stay today's. + */ +import { afterEach, beforeEach, describe, expect, it } 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 { dataHomeKey, resolveHookConfig } from '../dashboard-collector.js'; +import { getDataHome } from '../types.js'; + +let tmp = ''; +let home = ''; +let originalHome: string | undefined; + +beforeEach(() => { + tmp = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'teamai-hook-config-810-'))); + home = path.join(tmp, 'home'); + fs.mkdirSync(path.join(home, '.teamai', 'dashboard'), { recursive: true }); + originalHome = process.env.HOME; + process.env.HOME = home; +}); + +afterEach(() => { + if (originalHome === undefined) delete process.env.HOME; + else process.env.HOME = originalHome; + fs.rmSync(tmp, { recursive: true, force: true }); +}); + +const git = (cwd: string, ...args: string[]) => execFileSync('git', args, { + cwd, stdio: 'pipe', env: { ...process.env, GIT_CONFIG_GLOBAL: '/dev/null', GIT_CONFIG_SYSTEM: '/dev/null' }, +}); + +const writeConfig = (dataHome: string, config: Record) => { + fs.mkdirSync(dataHome, { recursive: true }); + fs.writeFileSync(path.join(dataHome, 'config.yaml'), YAML.stringify({ + repo: { localPath: path.join(dataHome, 'team-repo'), remote: 'https://example.test/team.git' }, + username: 'tester', + ...config, + })); +}; + +const record = (events: Array>) => fs.writeFileSync( + path.join(home, '.teamai', 'dashboard', 'events.jsonl'), + events.map((e) => JSON.stringify({ type: 'prompt_submit', timestamp: new Date().toISOString(), tool: 'claude', ...e })).join('\n') + '\n', +); + +describe('resolveHookConfig for a removed worktree (#810)', () => { + it('does not hand a worktree\'s own legacy scope to its main checkout\'s scope', async () => { + const main = path.join(tmp, 'main'); + const worktree = path.join(tmp, 'wt'); + fs.mkdirSync(main); + git(main, 'init', '-q'); + git(main, '-c', 'user.name=T', '-c', 'user.email=t@e.invalid', 'commit', '-q', '--allow-empty', '-m', 'init'); + git(main, 'worktree', 'add', '-q', worktree); + // Two un-migrated installs: one in the main checkout, one in the worktree. + writeConfig(path.join(main, '.teamai'), { scope: 'project', projectRoot: main }); + writeConfig(path.join(worktree, '.teamai'), { scope: 'project', projectRoot: worktree }); + writeConfig(path.join(home, '.teamai'), { scope: 'user' }); + record([{ sessionId: 's-legacy', cwd: worktree, dataHomeKey: await dataHomeKey(path.join(worktree, '.teamai')), projectAnchor: main }]); + git(main, 'worktree', 'remove', '--force', worktree); + + const config = await resolveHookConfig({ session_id: 's-legacy', cwd: worktree }, 'claude'); + // Today's answer (the user scope), not the main checkout's other scope. + expect(config && getDataHome(config)).toBe(path.join(home, '.teamai')); + }); + + it('keeps today\'s answer for a session that recorded nothing', async () => { + writeConfig(path.join(home, '.teamai'), { scope: 'user' }); + record([]); + const config = await resolveHookConfig({ session_id: 's-new', cwd: path.join(tmp, 'gone') }, 'claude'); + expect(config && getDataHome(config)).toBe(path.join(home, '.teamai')); + }); +}); diff --git a/src/__tests__/hook-handlers.test.ts b/src/__tests__/hook-handlers.test.ts index 68648db1e..01c2466ab 100644 --- a/src/__tests__/hook-handlers.test.ts +++ b/src/__tests__/hook-handlers.test.ts @@ -40,6 +40,9 @@ vi.mock('../dashboard-collector.js', () => ({ dashboardReport: mockDashboardReport, dataHomeKey: vi.fn().mockResolvedValue('key'), eventProjectAnchor: vi.fn().mockResolvedValue(undefined), + // The payload's cwd: what hookScopeDir answers for a cwd that exists (#810). + hookScopeDir: async (stdin: Record) => + (await vi.importActual('../utils/hook-cwd.js')).resolveHookCwd(stdin), })); // Use the REAL resolveSkillUse (pure Skill/Read+SKILL.md logic, no I/O) so the @@ -1546,7 +1549,8 @@ describe('track-slash handler: dotted and colon skill names', () => { await handler.execute( { prompt: '/org.setup some args', hook_event_name: 'UserPromptSubmit' }, - 'claude', null, + // Registered with requiresConfig: the dispatcher always hands it a scope. + 'claude', scope, ); expect(appendUsageEvent).toHaveBeenCalledOnce(); @@ -1566,7 +1570,8 @@ describe('track-slash handler: dotted and colon skill names', () => { await handler.execute( { prompt: '/ns:deploy some args', hook_event_name: 'UserPromptSubmit' }, - 'claude', null, + // Registered with requiresConfig: the dispatcher always hands it a scope. + 'claude', scope, ); expect(appendUsageEvent).toHaveBeenCalledOnce(); diff --git a/src/config.ts b/src/config.ts index a58752c1b..1470f7ed2 100644 --- a/src/config.ts +++ b/src/config.ts @@ -351,7 +351,9 @@ export async function resolveDataHomeForScope(scope: Scope, projectRoot?: string * not belong to (#748). The hook dispatcher and every usage reader and writer * resolve through this, so they always agree on the scope. A directory that no * longer exists (a hook payload naming a deleted worktree) holds no project - * config; git refuses to open it, so it is not asked. + * config; git refuses to open it, so it is not asked. Hooks go through + * resolveHookConfig (dashboard-collector.ts), which gives such a payload the + * scope its session last recorded (#810). */ export async function resolveConfigForDir(dir?: string): Promise { const target = dir ?? process.cwd(); diff --git a/src/contribute-check.ts b/src/contribute-check.ts index 6a1626082..3564df0e4 100644 --- a/src/contribute-check.ts +++ b/src/contribute-check.ts @@ -4,7 +4,7 @@ import { execFileSync } from 'node:child_process'; import { log } from './utils/logger.js'; import { RELAY_TO_USER_PREFIX, relayWhenHidden } from './utils/hook-output.js'; import { readJson, writeJson, ensureDir } from './utils/fs.js'; -import { readEvents, aggregateSessionMetrics, scanTranscriptStop } from './dashboard-collector.js'; +import { readEvents, aggregateSessionMetrics, scanTranscriptStop, hookScopeDir } from './dashboard-collector.js'; import { readRecallQuality } from './recall-quality.js'; import { deriveSessionId } from './utils/session-id.js'; import { resolveHookCwd } from './utils/hook-cwd.js'; @@ -342,6 +342,7 @@ async function readStdinAndDeriveSession(): Promise<{ sessionId: string; cwd?: string; transcriptPath?: string; + hookData: Record; } | null> { if (process.stdin.isTTY) return null; @@ -360,7 +361,7 @@ async function readStdinAndDeriveSession(): Promise<{ const transcriptPath = typeof hookData.transcript_path === 'string' ? hookData.transcript_path : undefined; - return { sessionId, cwd, transcriptPath }; + return { sessionId, cwd, transcriptPath, hookData }; } catch { return null; } @@ -688,18 +689,20 @@ export async function contributeCheck(toolArg?: string): Promise { return; } // Hooks of older installs still call this command in every project; a - // directory without teamai has no team to share with (#748). + // directory without teamai has no team to share with (#748). The session's + // cwd, never the one this process started in; a removed worktree's session + // keeps its recorded scope (#810). + const scopeDir = await hookScopeDir(stdinData.hookData, toolArg ?? 'claude'); const { resolveConfigForDir } = await import('./config.js'); - if (!(await resolveConfigForDir(stdinData.cwd))) { + if (!(await resolveConfigForDir(scopeDir))) { log.debug('contribute-check: teamai is not set up here, skipping'); return; } // The same gate as the dispatcher's handler: hooks written before it still - // call this command, and must not nudge towards a `share` that refuses. It - // is asked about the session's cwd, never the one this process started in. + // call this command, and must not nudge towards a `share` that refuses. const { contributeHintAllowed } = await import('./skill-content.js'); - if (!(await contributeHintAllowed(stdinData.cwd))) return; + if (!(await contributeHintAllowed(scopeDir))) return; const { stopStdoutUnsupported } = await import('./utils/tool-names.js'); const tool = toolArg?.toLowerCase() ?? 'claude'; diff --git a/src/dashboard-collector.ts b/src/dashboard-collector.ts index 916777ef1..30b9415ef 100644 --- a/src/dashboard-collector.ts +++ b/src/dashboard-collector.ts @@ -3,7 +3,7 @@ import path from 'node:path'; import readline from 'node:readline'; import { createHash, randomUUID } from 'node:crypto'; import { log } from './utils/logger.js'; -import { deriveSessionId } from './utils/session-id.js'; +import { deriveDispatchSessionId } 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'; @@ -35,6 +35,7 @@ import { type TokenSnapshotScope, type SessionMetrics, type RequestCostMetrics, + type LocalConfig, } from './types.js'; import { getUserHome } from './utils/home.js'; import { estimateClaudeRequest } from './model-pricing.js'; @@ -1170,7 +1171,7 @@ export async function parseHookEvent( } const isCopilot = tool.toLowerCase() === COPILOT_TOOL_ID; - const derivedSessionId = deriveSessionId(hookData, { includeCwd: !isCopilot }); + const derivedSessionId = deriveDispatchSessionId(hookData, tool); // Copilot IDs are persisted, so reject path-like IDs even when supplied directly. const sessionId = isCopilot && !COPILOT_SESSION_ID_RE.test(derivedSessionId) ? `pid-${process.ppid ?? process.pid}` @@ -2074,15 +2075,95 @@ export async function compactEvents(eventsPath?: string): Promise { } } +/** What a session last recorded: its scope and the repo it was in. */ +interface RecordedScope { + /** The last event's data home key, with the `projectAnchor` of that same event. */ + scope?: { key: string; anchor: string | undefined }; + /** The last `projectAnchor` the session recorded (#809's attribution rule). */ + projectAnchor?: string; +} + +const recordedScopes = new Map>(); + +/** + * What the session last recorded at `cwd`, else anywhere. A detached hook (a + * Stop's) can run after the session moved on, so its own directory comes first. + * Read once per process, and only for a hook whose cwd is gone: every other + * hook resolves its scope from its cwd without touching events.jsonl. + */ +function recordedScope(sessionId: string, cwd: string): Promise { + const key = `${sessionId}\0${cwd}`; + let known = recordedScopes.get(key); + if (!known) { + known = readEvents().then((events) => { + const own = events.filter((e) => e.sessionId === sessionId).reverse(); + const here = own.filter((e) => e.cwd === cwd); + const scoped = here.find((e) => e.dataHomeKey) ?? own.find((e) => e.dataHomeKey); + return { + scope: scoped?.dataHomeKey ? { key: scoped.dataHomeKey, anchor: scoped.projectAnchor } : undefined, + projectAnchor: (here.find((e) => e.projectAnchor) ?? own.find((e) => e.projectAnchor))?.projectAnchor, + }; + }); + recordedScopes.set(key, known); + } + return known; +} + +/** + * The directory a hook's scope is resolved from (#810): its cwd. A cwd that no + * longer exists (a removed worktree) would resolve to the user scope, so it is + * instead the checkout of the repo this session last recorded (at that cwd + * first, else anywhere: see recordedScope), when the config + * there is still the scope that event's data home key names, or cannot be read (so + * the hook gets no scope, never the user scope, #748). With nothing recorded to + * match, it is the cwd, today's answer. A cwd that exists costs nothing more. + */ +export async function hookScopeDir(hookData: Record, tool: string): Promise { + const cwd = resolveHookCwd(hookData); + if (!cwd || fs.existsSync(cwd)) return cwd; + const { scope } = await recordedScope(deriveDispatchSessionId(hookData, tool), cwd); + const checkout = scope?.anchor ? await checkoutOf(scope.anchor) : undefined; + if (scope && checkout) { + const { resolveConfigForDir } = await import('./config.js'); + const config = await resolveConfigForDir(checkout); + if (!config || await dataHomeKey(getDataHome(config)) === scope.key) return checkout; + } + return cwd; +} + +/** + * The scope a hook reports to: resolveConfigForDir at {@link hookScopeDir}. The + * dispatcher and the legacy `dashboard-report`, `track` and `track-slash` entry + * points all resolve through here. + */ +export async function resolveHookConfig(hookData: Record, tool: string): Promise { + const { resolveConfigForDir } = await import('./config.js'); + return resolveConfigForDir(await hookScopeDir(hookData, tool)); +} + +/** + * A checkout of the repo anchored at `anchor` to resolve its scope from: the + * anchor itself, or for a bare repo (whose anchor is the git directory, which + * detection cannot read) the first of its worktrees that still exists. + */ +async function checkoutOf(anchor: string): Promise { + if (!fs.existsSync(anchor)) return undefined; + if (fs.existsSync(path.join(anchor, '.git'))) return anchor; + const { listWorktrees } = await import('./utils/git.js'); + return (await listWorktrees(anchor)).find((dir) => dir !== anchor && fs.existsSync(dir)); +} + /** * 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. + * (Copilot), and outside git. For a cwd that no longer exists, which git refuses + * to open, it is the last anchor the session recorded there, else anywhere + * (#810). Both event writers go through here. */ -export async function eventProjectAnchor(cwd: string | undefined): Promise { - if (!cwd || !fs.existsSync(cwd)) return undefined; +export async function eventProjectAnchor(cwd: string | undefined, sessionId: string): Promise { + if (!cwd) return undefined; + if (!fs.existsSync(cwd)) return (await recordedScope(sessionId, cwd)).projectAnchor; const { resolveAnchors } = await import('./utils/git.js'); return (await resolveAnchors(cwd))?.projectAnchor; } @@ -2116,8 +2197,7 @@ export async function dashboardReport(toolArg?: string): Promise { } catch { // parseHookEvent reports the malformed payload below; nothing to gate on yet. } - const { resolveConfigForDir } = await import('./config.js'); - const config = await resolveConfigForDir(resolveHookCwd(hookData)); + const config = await resolveHookConfig(hookData, toolArg ?? 'claude'); if (!config) { log.debug('dashboard-report: teamai is not set up here, skipping'); return; @@ -2127,7 +2207,7 @@ export async function dashboardReport(toolArg?: string): Promise { if (!event) return; event.dataHomeKey = await dataHomeKey(getDataHome(config)); - event.projectAnchor = await eventProjectAnchor(event.cwd); + event.projectAnchor = await eventProjectAnchor(event.cwd, event.sessionId); await appendEvent(event); // Trigger compaction check (non-blocking) diff --git a/src/hook-dispatch-cli.ts b/src/hook-dispatch-cli.ts index b9bb6cc29..4ea80addc 100644 --- a/src/hook-dispatch-cli.ts +++ b/src/hook-dispatch-cli.ts @@ -26,8 +26,7 @@ import { buildHandlerRegistry, filterHandlersForConfig } from './hook-handlers.j import { resolveHookCwd } from './utils/hook-cwd.js'; import { windowsPowerShell } from './utils/powershell.js'; import { log, setStderrOnly } from './utils/logger.js'; -import { deriveSessionId } from './utils/session-id.js'; -import { COPILOT_TOOL_ID } from './types.js'; +import { deriveDispatchSessionId } from './utils/session-id.js'; /** * Max time to wait for STDIN EOF before proceeding with whatever was received. @@ -384,13 +383,7 @@ async function runDispatch( return result.output; } -/** Keep the detached Copilot fallback stable without persisting a workspace path. */ -export function deriveDispatchSessionId( - stdin: Record, - tool: string, -): string { - return deriveSessionId(stdin, { includeCwd: tool.toLowerCase() !== COPILOT_TOOL_ID }); -} +export { deriveDispatchSessionId }; /** * Main CLI handler for hook-dispatch. @@ -417,8 +410,9 @@ export async function hookDispatchCli( // (contribute / mr-hint / votes). The project-scope config of the host's // working directory wins (#264), so filterHandlersForConfig can honour a // project-level repo.kind; a host that sends no cwd (OpenClaw) runs the - // hook in its workspace, so the process cwd stands in. - const { resolveConfigForDir } = await import('./config.js'); + // hook in its workspace, so the process cwd stands in. A cwd that is gone + // (a removed worktree) keeps the scope its session recorded (#810). + const { resolveHookConfig } = await import('./dashboard-collector.js'); const cwd = resolveHookCwd(stdin); if (cwd) { try { @@ -427,7 +421,7 @@ export async function hookDispatchCli( log.debug(`hook-dispatch: chdir to ${cwd} failed: ${(e as Error).message}`); } } - const localConfig = await resolveConfigForDir(cwd); + const localConfig = await resolveHookConfig(stdin, tool); const handlers = filterHandlersForConfig(buildHandlerRegistry(), localConfig); const dispatcher = createDispatcher({ handlers, localConfig }); diff --git a/src/hook-handlers.ts b/src/hook-handlers.ts index 877d56498..4433115ce 100644 --- a/src/hook-handlers.ts +++ b/src/hook-handlers.ts @@ -202,7 +202,7 @@ const dashboardReportHandler: HookHandler = { // The dispatcher's scope, which knows the project even when the event // records no cwd (Copilot) or a symlinked one (#785). event.dataHomeKey = await dataHomeKey(getDataHome(config)); - event.projectAnchor = await eventProjectAnchor(event.cwd); + event.projectAnchor = await eventProjectAnchor(event.cwd, event.sessionId); await appendEvent(event); // Non-blocking compaction compactEvents().catch(() => {}); @@ -213,9 +213,11 @@ const dashboardReportHandler: HookHandler = { const trackHandler: HookHandler = { name: 'track', - async execute(stdin, tool) { + async execute(stdin, tool, config) { + // Registered with requiresConfig: the dispatcher's scope, which knows the + // project of a removed worktree (#810). + if (!config) return null; const { resolveSkillUse, appendUsageEvent, updateKnownSkills } = await import('./usage-tracker.js'); - const { resolveConfigForDir } = await import('./config.js'); const rawToolName = stdin.tool_name; if (typeof rawToolName !== 'string') return null; @@ -228,8 +230,6 @@ const trackHandler: HookHandler = { const resolved = resolveSkillUse(toolName, toolInput as Record); if (!resolved) return null; - const config = await resolveConfigForDir(resolveHookCwd(stdin)); - if (!config) return null; await appendUsageEvent({ skill: resolved.skillName, timestamp: new Date().toISOString(), @@ -242,9 +242,10 @@ const trackHandler: HookHandler = { const trackSlashHandler: HookHandler = { name: 'track-slash', - async execute(stdin, tool) { + async execute(stdin, tool, config) { + // Registered with requiresConfig: the dispatcher's scope (#810). + if (!config) return null; const { isValidSkillName, appendUsageEvent, updateKnownSkills } = await import('./usage-tracker.js'); - const { resolveConfigForDir } = await import('./config.js'); const prompt = stdin.prompt; if (typeof prompt !== 'string' || !prompt.startsWith('/')) return null; @@ -259,8 +260,6 @@ const trackSlashHandler: HookHandler = { const skillName = match[1]; if (!isValidSkillName(skillName)) return null; - const config = await resolveConfigForDir(resolveHookCwd(stdin)); - if (!config) return null; await appendUsageEvent({ skill: skillName, timestamp: new Date().toISOString(), tool }, config); await updateKnownSkills(skillName); return null; @@ -308,8 +307,10 @@ const contributeCheckHandler: HookHandler = { async execute(stdin, tool) { // The payload's cwd, not the process's: hook-dispatch changes into it, but // that can fail, and the gate must not then read the launcher's directory. + // A removed worktree's session keeps its recorded scope (#810). const { contributeHintAllowed } = await import('./skill-content.js'); - if (!(await contributeHintAllowed(resolveHookCwd(stdin)))) return null; + const { hookScopeDir } = await import('./dashboard-collector.js'); + if (!(await contributeHintAllowed(await hookScopeDir(stdin, tool)))) return null; const { contributeCheckForSession } = await import('./contribute-check.js'); const { formatStopHookOutput, relayWhenHidden } = await import('./utils/hook-output.js'); @@ -355,7 +356,8 @@ const pendingHintHandler: HookHandler = { // mechanism (#723); only the contribute hint remains. Uses the upstream // contributeHintAllowed(cwd) signature (moved to skill-content). const { contributeHintAllowed } = await import('./skill-content.js'); - const hint = (await contributeHintAllowed(resolveHookCwd(stdin))) ? stashed : null; + const { hookScopeDir } = await import('./dashboard-collector.js'); + const hint = (await contributeHintAllowed(await hookScopeDir(stdin, tool))) ? stashed : null; if (!hint) return null; return JSON.stringify({ diff --git a/src/types.ts b/src/types.ts index c24d47bd3..0c00109ce 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1258,10 +1258,10 @@ export interface DashboardEvent { /** * 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`. + * after its worktree is removed (#809). An event whose `cwd` no longer exists + * carries the session's last anchor (#810). Absent outside git, on events that + * record no `cwd` (Copilot), 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) */ diff --git a/src/usage-tracker.ts b/src/usage-tracker.ts index 68e37be17..3d9d2c49d 100644 --- a/src/usage-tracker.ts +++ b/src/usage-tracker.ts @@ -61,7 +61,7 @@ function getKnownSkillsPath(): string { // [toolArg → toolSource; Read+SKILL.md → 'cursor'] // │ // ▼ -// [resolveConfigForDir(cwd)] ─null─▶ skip (#748) +// [resolveHookConfig(payload)] ─null─▶ skip (#748) // │ // ▼ // appendFile(, JSON line) @@ -647,7 +647,8 @@ export async function trackFromStdin(toolArg?: string): Promise { return; } - const config = await resolveConfigForDir(resolveHookCwd(hookData)); + const { resolveHookConfig } = await import('./dashboard-collector.js'); + const config = await resolveHookConfig(hookData, toolArg ?? 'claude'); if (!config) return; const event: UsageEvent = { @@ -701,12 +702,14 @@ export async function trackSlashCommand(toolArg?: string): Promise { } const hookCwd = resolveHookCwd(hookData); - const config = await resolveConfigForDir(hookCwd); + const { resolveHookConfig } = await import('./dashboard-collector.js'); + const config = await resolveHookConfig(hookData, toolArg ?? 'claude'); if (!config) return; // The same root resolution import and the local agent use, from the hook's // directory: a project set up before a user-scope relocation has no record // of its own and follows user scope. - const toolRoots = await resolveMemberToolRoots(hookCwd); + // The scope's own record first: a removed worktree's cwd leads nowhere (#810). + const toolRoots = config.toolRoots ?? await resolveMemberToolRoots(hookCwd); for (const match of matches) { const skillName = match[1]; diff --git a/src/utils/repo-attribution.ts b/src/utils/repo-attribution.ts index 3584d61dc..cfc907e11 100644 --- a/src/utils/repo-attribution.ts +++ b/src/utils/repo-attribution.ts @@ -69,8 +69,8 @@ export function attributeRepo(cwd: string | undefined): string { * 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. + * anchor, and a session keeps it after its worktree is removed: the events + * written after that carry the session's last anchor (#810), or none. */ export function repoKeys(events: DashboardEvent[]): Map { const anchors = new Map(); diff --git a/src/utils/session-id.ts b/src/utils/session-id.ts index effe70b43..725a79735 100644 --- a/src/utils/session-id.ts +++ b/src/utils/session-id.ts @@ -7,6 +7,7 @@ */ import { resolveHookCwd } from './hook-cwd.js'; +import { COPILOT_TOOL_ID } from '../types.js'; export interface DeriveSessionIdOptions { /** When true, include the working directory in the PID fallback. */ @@ -46,3 +47,15 @@ export function deriveSessionId( return `pid-${ppid}`; } + +/** + * The session id a hook's events carry. Copilot's fallback takes no cwd, so it + * persists no workspace path. The dispatcher (and its detached child) and the + * dashboard's event writers all derive it here, so they always agree. + */ +export function deriveDispatchSessionId( + data: Record, + tool: string, +): string { + return deriveSessionId(data, { includeCwd: tool.toLowerCase() !== COPILOT_TOOL_ID }); +}