diff --git a/CHANGELOG.md b/CHANGELOG.md index 467bda7bc..045e00090 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,9 +29,10 @@ All notable changes to this project will be documented in this file. See [standa - `teamai stats` no longer counts a session twice, and no longer counts another project's sessions. Its dashboard section added the whole machine's local `events.jsonl` metrics to the scope's already-reported totals from the team repo, so every session that a `pull` had reported — and that stays in the event log until compaction — was counted once by the team total and once again locally, and sessions whose `cwd` belonged to a different project were added to this scope's as well. It now filters the event log the way `teamai pull` reports it (only the sessions recorded in the current scope, see #785) and adds only what that scope has not reported yet, derived from the same per-scope `reported-*` snapshots the report path advances — so the local figure agrees with the team's instead of exceeding it. When the reported totals could not be read at all (no stats file, an unreadable one, a reports worktree that is not there), or when they exist but hold nothing yet, nothing is subtracted — a snapshot can name a session the team file never received, so a non-empty team total is what licenses trusting them, and a session the member can see happening is never hidden. The per-repo and by-hour breakdowns read that same filtered log, so they stay inside the scope; they answer a different question from the headline — what this machine's retained event log holds, per repo — and both the headings and the `--by-repo` / `--by-time` flag descriptions now name that source instead of presenting them as a split of the headline (for [#768](https://github.com/Tencent/teamai-cli/issues/768)). - The data-partition migration no longer retires a legacy `/.teamai/` while the partition's `config.yaml` cannot be read. `teamai init`, `pull` and `push` took a partition config that merely existed as a finished migration and renamed the legacy directory to `.teamai.bak`, although it held the only config that still loaded. A partition `config.yaml` that is empty or cannot be opened, does not parse, does not validate, or is not `scope: project` now leaves the legacy directory in place and names the file to fix; once it is fixed, the next of those commands retires it as before. A partition directory whose `config.yaml` was moved aside is no longer replaced by a fresh copy of the legacy directory, which deleted what the partition held; the migration waits and says to restore the file or move the directory aside (for [#797](https://github.com/Tencent/teamai-cli/issues/797)). +- `teamai pull` syncs nothing in a project whose config exists but cannot be read, and says why. Detection skipped the broken file and pulled whatever loaded next: the user scope, or a legacy `.teamai/` behind a broken partition that may belong to another team, whose skills, rules and docs were deployed and to which the project's usage was reported. It now prints ``Nothing was synced: : . Fix the file, or move it aside and run `teamai init` to write a new one.`` and exits 1. A session start there runs no pull, seeds no agent directory and stashes no package hint; `teamai pull --silent`, which hooks from before `hook-dispatch` still run, prints nothing, writes the reason to `~/.teamai/debug.log` and exits 1. This is the rule team hooks and usage follow since [#748](https://github.com/Tencent/teamai-cli/issues/748) (for [#784](https://github.com/Tencent/teamai-cli/issues/784)). - The legacy `teamai dashboard-report` command no longer records dashboard events in a directory that never set up teamai. A current install writes only `teamai hook-dispatch`, whose dashboard-report handler declares `requiresConfig` and is dropped when no config resolves for the hook's `cwd`; the old subcommand stayed ungated, so a hook left behind by an earlier install kept recording events for every project it fired in, and those sessions were then reported by whichever scope pulled next. It now applies the same gate `teamai contribute-check` was given, asked about the session's `cwd` — or, for a host that sends none, the directory the hook runs in (for [#768](https://github.com/Tencent/teamai-cli/issues/768)). - The lock behind `pull`, `push`, the reports and learnings worktrees, learnings publishing, migration, self-mode bootstrap and the update check no longer hands one lock to two live processes. Reclaiming a stale lock renamed over whatever file was there once it had judged the lock stale, and it judged live locks stale: one that had just been released (and could be re-created by a third process before the rename), one whose owner had created it but not yet written it, one owned by a process running as another user (for example a `sudo teamai` run), and one it could not read. Under 16 processes contending on one lock, about 1% of acquisitions overlapped another holder, enough for two pulls to report the same usage twice. Now only a lock whose owner is provably gone is reclaimed; a lock that vanished gets one more exclusive create, and a new lock is published with its content already in place (written to a temp file, then hard-linked to the lock name; a filesystem without hard links falls back to the previous create). A lock that names no owner (empty, partly written, unreadable) is never reclaimed: if a crash left one, `pull` and `push` report busy until it is removed, and a warning names the file. Migration skips the lock's temporary files, which a contending pull creates and removes while the copy runs. With the change, the same stress run shows no overlap (for [#760](https://github.com/Tencent/teamai-cli/issues/760)). -- Team hooks stay out of projects that never set up teamai. A project-scope install puts its hooks in the home directory, so they fire in every project on the machine, and with no config for the directory they used to run anyway: the end-of-session share reminder (shown there even with recall off, a case a configured team never sees), the TodoWrite recall nudge, and the local recording of sessions and skill usage that a later report from another project pushed to its team. A handler that needs a team now declares `requiresConfig`, and the dispatcher drops it when neither a project nor a user config resolves for the hook's `cwd`; only machine-level work runs there (CLI update check, session-start pull, local agent, package hints the pull stashed). A config that exists but fails to parse reads the same way, so it withholds team prompts rather than running all of them, and for team hooks and skill usage an unreadable project config never falls back to the user scope, nor to a lower-priority project config such as a legacy `.teamai/` behind a broken partition (the session-start pull still resolves its project on its own). A host that sends no `cwd` (OpenClaw) resolves the project from the directory it runs the hook in, a `cwd` that no longer exists resolves to the user scope instead of failing the hook, and the legacy `teamai contribute-check` command that older installs still call follows the same rule (for [#748](https://github.com/Tencent/teamai-cli/issues/748)). +- Team hooks stay out of projects that never set up teamai. A project-scope install puts its hooks in the home directory, so they fire in every project on the machine, and with no config for the directory they used to run anyway: the end-of-session share reminder (shown there even with recall off, a case a configured team never sees), the TodoWrite recall nudge, and the local recording of sessions and skill usage that a later report from another project pushed to its team. A handler that needs a team now declares `requiresConfig`, and the dispatcher drops it when neither a project nor a user config resolves for the hook's `cwd`; only machine-level work runs there (CLI update check, session-start pull, local agent, package hints the pull stashed). A config that exists but fails to parse reads the same way, so it withholds team prompts rather than running all of them, and for team hooks and skill usage an unreadable project config never falls back to the user scope, nor to a lower-priority project config such as a legacy `.teamai/` behind a broken partition. A host that sends no `cwd` (OpenClaw) resolves the project from the directory it runs the hook in, a `cwd` that no longer exists resolves to the user scope instead of failing the hook, and the legacy `teamai contribute-check` command that older installs still call follows the same rule (for [#748](https://github.com/Tencent/teamai-cli/issues/748)). - Every team hook handler now works in the scope `hook-dispatch` resolved for the hook's `cwd`. The team correction keywords, votes and webhooks read their config again from the directory the hook process ran in, so when that `cwd` no longer existed (a deleted worktree) and the host started the hook inside another project, that project's keywords were applied, the session's votes were recorded under its member and pushed to its team, and its webhooks fired. The background handlers (session-start pull, webhooks, update check) also run again when that `cwd` no longer exists: on macOS and Linux their detached process was started in it, so the start failed silently and none of them ran. It now starts in the temp directory, as on Windows (for [#752](https://github.com/Tencent/teamai-cli/issues/752)). - Skill usage stays with the team of the project it was recorded in. Every scope used to append to one `~/.teamai/usage.jsonl`, so whichever project pulled next reported every project's skills to its own team, including skills that exist only in an unrelated private repo. Usage now goes to the data directory of the scope that resolves for the session's directory (`/usage.jsonl`: the project partition, or `/.teamai` for an install not yet migrated to one, and `~/.teamai/user-usage.jsonl` for the user scope), each report reads and truncates only its own file, and `teamai stats` shows the current scope's usage. Events in `~/.teamai/usage.jsonl` name no project, whether an earlier release left them there or writes them again after a rollback, so they are never read or reported. Stats already pushed are not rewritten (for [#748](https://github.com/Tencent/teamai-cli/issues/748)). - `teamai init` no longer hangs without a terminal. When the provider had no session it spawned `gh auth login --web` (or `gf auth login`, `cnb login`) with inherited stdio and waited for a browser device flow that nobody could complete, about five minutes for GitHub, then exited with the provider's error and no hint of the missing credential. Each login now refuses up front when the run is not interactive and names the credential to prepare (`GITHUB_TOKEN` / `GH_TOKEN`, `CNB_TOKEN`, or for TGit a prior `gf auth login`, since a `TGIT_TOKEN` PAT is REST-API-only and cannot clone). A run is non-interactive when stdin is not a TTY or when `CI` or `TEAMAI_NONINTERACTIVE` is set, so an agent sandbox with a pseudo-terminal can declare itself unattended, and every prompt in the CLI follows the same rule. `git` also runs with its prompts closed in that case — `GIT_TERMINAL_PROMPT=0`, `GIT_ASKPASS=echo` and `GCM_INTERACTIVE=never`, each only where the caller set nothing — so a missing clone credential fails at once instead of waiting on a terminal prompt or an askpass or credential-manager dialog. `ssh` keeps its own settings: its batch flag is only reachable through `GIT_SSH_COMMAND`, which would override each repository's `core.sshCommand` (for [#711](https://github.com/Tencent/teamai-cli/issues/711)). diff --git a/docs/designs/skill-serving.md b/docs/designs/skill-serving.md index a20dd5f43..7b28fe417 100644 --- a/docs/designs/skill-serving.md +++ b/docs/designs/skill-serving.md @@ -96,7 +96,7 @@ path (measured here from a 77-character one). too, which detection alone would skip in favour of the user config (`findUnreadableProjectConfig`), including one that is not `scope: project`. The refusal then says what failed (for a file that does not parse, which file and where; for one that fails validation, - which field and why), since nothing else reports it. The + which field and why), as `teamai pull` does there (#784). The Stop-hook share reminder asks the same gate (`contributeHintAllowed`, called by the hook dispatcher and by the legacy `teamai contribute-check`), because it points at this command, with one difference: with no config at all it stays diff --git a/docs/usage-guide.md b/docs/usage-guide.md index 82e12adb6..dc0bf7da5 100644 --- a/docs/usage-guide.md +++ b/docs/usage-guide.md @@ -568,7 +568,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; the session-start pull still resolves its project on its own. 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. 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 35156bdcd..8f07c6009 100644 --- a/docs/usage-guide.zh-CN.md +++ b/docs/usage-guide.zh-CN.md @@ -526,7 +526,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`);SessionStart 时的 pull 仍自行解析其项目。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 目录、不暂存包提示。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/skill-data/core/references/troubleshooting.md b/skill-data/core/references/troubleshooting.md index bc5666e92..a7c255a17 100644 --- a/skill-data/core/references/troubleshooting.md +++ b/skill-data/core/references/troubleshooting.md @@ -48,6 +48,12 @@ This is the #1 onboarding issue. In order: belongs in the team repo's `manifest/roles.yaml` or `manifest/projects.yaml`, which the error names by entry — tell the user to ask a team admin. Do not delete the manifest or edit the local clone to get past it. +7. **`pull` says `Nothing was synced: : `.** The project's teamai + config exists but cannot be read, so no scope syncs there, not even the user + scope, and the session-start hook syncs nothing either. Show the user the + file and the reason; `teamai doctor` checks another config and can pass + here. Moving it aside and re-running `teamai init` replaces their settings + for that project: do it only with their consent. ## Permission / access denied diff --git a/src/__tests__/hook-dispatch-scope.test.ts b/src/__tests__/hook-dispatch-scope.test.ts index 4ec8bbd82..2d7dd11cd 100644 --- a/src/__tests__/hook-dispatch-scope.test.ts +++ b/src/__tests__/hook-dispatch-scope.test.ts @@ -27,6 +27,7 @@ vi.mock('../utils/reports-branch.js', () => ({ updateReports: vi.fn(async () => const { hookDispatchCli } = await import('../hook-dispatch-cli.js'); const { resolveProjectDataHome, saveLocalConfigForScope } = await import('../config.js'); +const { pull } = await import('../pull.js'); let tmp: string; let originalHome: string | undefined; @@ -41,6 +42,8 @@ beforeEach(() => { }); afterEach(() => { + // Drops a queued pull implementation a test's hook never consumed. + vi.mocked(pull).mockReset(); process.chdir(originalCwd); if (originalHome === undefined) delete process.env.HOME; else process.env.HOME = originalHome; @@ -198,4 +201,30 @@ describe('hook runs and the scope they belong to (#748)', () => { expect(fs.existsSync(path.join(legacy, 'usage.jsonl'))).toBe(false); }); + + it('a session start in a project whose config cannot be read seeds no agent directory and stashes no package hint (#784)', async () => { + const root = gitRepo('project-a'); + const partition = await resolveProjectDataHome(root); + fs.mkdirSync(partition, { recursive: true }); + fs.writeFileSync(path.join(partition, 'config.yaml'), 'repo: [not: a, valid config\n'); + const legacy = path.join(root, '.teamai'); + const legacyRepo = path.join(legacy, 'team-repo'); + fs.mkdirSync(legacyRepo, { recursive: true }); + fs.writeFileSync(path.join(legacyRepo, 'teamai.yaml'), 'team: other-team\nrepo: https://example.test/acme/other-team.git\n'); + fs.writeFileSync(path.join(legacy, 'config.yaml'), + `repo:\n localPath: ${legacyRepo}\n remote: https://example.test/acme/other-team.git\nusername: tester\nscope: project\n`); + // Whatever changes the other team's package declarations while the session starts. + vi.mocked(pull).mockImplementationOnce(async () => { + fs.writeFileSync(path.join(legacyRepo, 'teamai.yaml'), + 'team: other-team\nrepo: https://example.test/acme/other-team.git\npackages:\n npm:\n - { name: typescript, version: "*" }\n'); + }); + + vi.mocked(pull).mockClear(); + + await hook('session-start', '*', { session_id: 'sid-a', cwd: root, hook_event_name: 'SessionStart' }); + + expect(pull).not.toHaveBeenCalled(); + expect(fs.existsSync(path.join(root, '.claude'))).toBe(false); + expect(fs.existsSync(path.join(teamaiHome(), 'package-hints'))).toBe(false); + }); }); diff --git a/src/__tests__/hook-handlers.test.ts b/src/__tests__/hook-handlers.test.ts index 63fa96a48..923319f44 100644 --- a/src/__tests__/hook-handlers.test.ts +++ b/src/__tests__/hook-handlers.test.ts @@ -248,6 +248,21 @@ describe('hook-handlers registry', () => { expect(mockSeedProjectAgentRoot).toHaveBeenCalledWith('claude', '/from-cwd'); }); + it('session-start pull runs nothing where the project config cannot be read (#784)', async () => { + const registry = buildHandlerRegistry(); + const handler = registry.find( + (r) => r.event === 'session-start' && r.handler.name === 'pull', + )!.handler; + mockFindUnreadableProjectConfig.mockResolvedValueOnce('/tmp/p/.teamai/config.yaml: bad indentation'); + + await handler.execute({ session_id: 's-pull', cwd: '/tmp' }, 'claude', null); + + expect(mockFindUnreadableProjectConfig).toHaveBeenCalledWith('/tmp'); + expect(mockSeedProjectAgentRoot).not.toHaveBeenCalled(); + expect(mockPull).not.toHaveBeenCalled(); + expect(mockStashPackageHint).not.toHaveBeenCalled(); + }); + it('stop has update, contribute-check, and dashboard-report handlers', () => { const registry = buildHandlerRegistry(); const stopHandlers = registry diff --git a/src/__tests__/pull-unreadable-config.test.ts b/src/__tests__/pull-unreadable-config.test.ts new file mode 100644 index 000000000..29117684e --- /dev/null +++ b/src/__tests__/pull-unreadable-config.test.ts @@ -0,0 +1,189 @@ +/** + * `teamai pull` in a project whose config cannot be read (#784): real config + * files in a sandbox HOME, so detection runs for real. Only what reaches the + * network or another process is stubbed; which team repos a pull fetches is + * what these tests observe. + */ +import { afterEach, beforeEach, 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'; + +vi.mock('../utils/git.js', async (importOriginal) => ({ + ...(await importOriginal()), + pullRepo: vi.fn().mockResolvedValue('already up to date'), + getHeadRev: vi.fn().mockResolvedValue('abc1234'), +})); +vi.mock('../utils/logger.js', () => ({ + log: { info: vi.fn(), success: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn(), dim: vi.fn(), persist: vi.fn() }, + spinner: vi.fn(() => ({ + start: vi.fn().mockReturnThis(), + succeed: vi.fn().mockReturnThis(), + fail: vi.fn().mockReturnThis(), + warn: vi.fn().mockReturnThis(), + info: vi.fn().mockReturnThis(), + stop: vi.fn().mockReturnThis(), + })), + setStderrOnly: vi.fn(() => false), +})); +vi.mock('../team-push.js', () => ({ reportUsageToTeam: vi.fn().mockResolvedValue(true) })); +vi.mock('../source.js', () => ({ pullSources: vi.fn().mockResolvedValue(undefined) })); +vi.mock('../hooks.js', () => ({ + injectHooksToAllTools: vi.fn().mockResolvedValue(undefined), + reconcileTeamHooksForConfig: vi.fn().mockResolvedValue([]), +})); +vi.mock('../mcp-reconcile.js', () => ({ + reconcileMcpForConfig: vi.fn().mockResolvedValue({ changes: [], wrote: false }), +})); +vi.mock('../update.js', () => ({ + acquireLock: vi.fn().mockResolvedValue(true), + releaseLock: vi.fn().mockResolvedValue(undefined), +})); + +const { pull } = await import('../pull.js'); +const { resolveProjectDataHome, saveLocalConfigForScope } = await import('../config.js'); +const { pullRepo } = await import('../utils/git.js'); +const { reportUsageToTeam } = await import('../team-push.js'); +const { log } = await import('../utils/logger.js'); + +let tmp: string; +let originalHome: string | undefined; +let originalCwd: string; +let originalExitCode: typeof process.exitCode; + +beforeEach(() => { + vi.clearAllMocks(); + tmp = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'teamai-pull-unreadable-'))); + originalHome = process.env.HOME; + originalCwd = process.cwd(); + originalExitCode = process.exitCode; + process.env.HOME = path.join(tmp, 'home'); + fs.mkdirSync(process.env.HOME); +}); + +afterEach(() => { + process.chdir(originalCwd); + process.exitCode = originalExitCode; + if (originalHome === undefined) delete process.env.HOME; + else process.env.HOME = originalHome; + fs.rmSync(tmp, { recursive: true, force: true }); +}); + +function teamRepo(dir: string, team: string): string { + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, 'teamai.yaml'), `team: ${team}\nrepo: https://example.test/acme/${team}.git\n`); + return dir; +} + +function userScope(): string { + const home = path.join(tmp, 'home', '.teamai'); + const repo = teamRepo(path.join(home, 'team-repo'), 'user-team'); + fs.writeFileSync(path.join(home, 'config.yaml'), + `repo:\n localPath: ${repo}\n remote: https://example.test/acme/user-team.git\nusername: tester\nscope: user\n`); + return repo; +} + +function gitRepo(name: string): string { + const dir = path.join(tmp, name); + fs.mkdirSync(dir); + execFileSync('git', ['init', '-q'], { cwd: dir }); + return dir; +} + +async function brokenPartition(root: string): Promise { + const partition = await resolveProjectDataHome(root); + fs.mkdirSync(partition, { recursive: true }); + const configPath = path.join(partition, 'config.yaml'); + fs.writeFileSync(configPath, 'repo: [not: a, valid config\n'); + return configPath; +} + +function pulledRepos(): string[] { + return vi.mocked(pullRepo).mock.calls.map(([repo]) => repo); +} + +describe('pull in a project whose config cannot be read (#784)', () => { + it('syncs nothing from a legacy .teamai/ of another team behind a broken partition, and says why', async () => { + const root = gitRepo('project-a'); + const configPath = await brokenPartition(root); + const legacy = path.join(root, '.teamai'); + const legacyRepo = teamRepo(path.join(legacy, 'team-repo'), 'other-team'); + fs.writeFileSync(path.join(legacy, 'config.yaml'), + `repo:\n localPath: ${legacyRepo}\n remote: https://example.test/acme/other-team.git\nusername: tester\nscope: project\n`); + process.chdir(root); + + await pull({}); + + expect(pulledRepos()).toEqual([]); + expect(reportUsageToTeam).not.toHaveBeenCalled(); + expect(process.exitCode).toBe(1); + const errors = vi.mocked(log.error).mock.calls.map(([msg]) => msg); + expect(errors).toHaveLength(1); + expect(errors[0].startsWith(`Nothing was synced: ${configPath}: `)).toBe(true); + expect(errors[0].endsWith('. Fix the file, or move it aside and run `teamai init` to write a new one.')).toBe(true); + // A parse error's code frame spans several lines; only the first is printed. + expect(errors[0]).not.toContain('\n'); + expect(errors[0]).not.toContain('teamai doctor'); + }); + + it('does not pull or report the user scope in its place', async () => { + userScope(); + const root = gitRepo('project-a'); + await brokenPartition(root); + process.chdir(root); + + await pull({}); + + expect(pulledRepos()).toEqual([]); + expect(reportUsageToTeam).not.toHaveBeenCalled(); + expect(process.exitCode).toBe(1); + }); + + it('syncs nothing with --silent either and prints nothing: the reason goes to debug.log, the exit code is still 1', async () => { + userScope(); + const root = gitRepo('project-a'); + await brokenPartition(root); + process.chdir(root); + + await pull({ silent: true }); + + expect(pulledRepos()).toEqual([]); + expect(reportUsageToTeam).not.toHaveBeenCalled(); + expect(log.error).not.toHaveBeenCalled(); + expect(vi.mocked(log.persist).mock.calls.map(([msg]) => msg)).toEqual([ + expect.stringMatching(/^Nothing was synced: /), + ]); + // A pre-dispatch hook runs it as `teamai pull --silent … || true`. + expect(process.exitCode).toBe(1); + }); + + it('pulls the project scope of a readable project config as before', async () => { + userScope(); + const root = gitRepo('project-a'); + const dataHome = await resolveProjectDataHome(root); + const repo = teamRepo(path.join(dataHome, 'team-repo'), 'team-a'); + await saveLocalConfigForScope({ + repo: { localPath: repo, remote: 'https://example.test/acme/team-a.git' }, + username: 'tester', scope: 'project', projectRoot: root, additionalRoles: [], dataHome, + }); + process.chdir(root); + + await pull({}); + + expect(pulledRepos()).toEqual([repo]); + expect(process.exitCode).toBe(originalExitCode); + expect(log.error).not.toHaveBeenCalled(); + }); + + it('pulls the user scope where there is no project config, as before', async () => { + const userRepo = userScope(); + process.chdir(gitRepo('project-b')); + + await pull({}); + + expect(pulledRepos()).toEqual([userRepo]); + expect(process.exitCode).toBe(originalExitCode); + expect(log.error).not.toHaveBeenCalled(); + }); +}); diff --git a/src/__tests__/skill-list-uninitialized.test.ts b/src/__tests__/skill-list-uninitialized.test.ts index a68ab4e88..8023325c1 100644 --- a/src/__tests__/skill-list-uninitialized.test.ts +++ b/src/__tests__/skill-list-uninitialized.test.ts @@ -7,12 +7,12 @@ const { autoDetectInit, findUnreadableProjectConfig, logDim, logError, NotInitia logError: vi.fn(), NotInitializedError: class NotInitializedError extends Error {}, })); -vi.mock('../config.js', () => ({ +vi.mock('../config.js', async (importOriginal) => ({ autoDetectInit, findUnreadableProjectConfig, requireInit: vi.fn(), NotInitializedError, - BROKEN_CONFIG_ADVICE: 'Fix the file, or move it aside and run `teamai init` to write a new one.', + describeUnreadableConfig: (await importOriginal()).describeUnreadableConfig, })); vi.mock('../utils/logger.js', () => ({ log: { info: vi.fn(), success: vi.fn(), warn: vi.fn(), error: logError, debug: vi.fn(), dim: logDim }, diff --git a/src/config.ts b/src/config.ts index 40845014e..e4519c17e 100644 --- a/src/config.ts +++ b/src/config.ts @@ -147,6 +147,15 @@ export type TeamaiInit = { localConfig: LocalConfig; teamConfig: TeamaiConfig }; /** What to do about a config file that exists but cannot be used. */ export const BROKEN_CONFIG_ADVICE = 'Fix the file, or move it aside and run `teamai init` to write a new one.'; +/** + * A problem `findUnreadableProjectConfig` (or its sink) reported, as a member + * reads it: a parse error spans several lines (a code frame), and its first + * names the file, the line and the column, which is what the member acts on. + */ +export function describeUnreadableConfig(problem: string): string { + return `${problem.trim().split('\n')[0].trim().replace(/:$/, '')}. ${BROKEN_CONFIG_ADVICE}`; +} + /** * Require that teamai is initialized (local config exists) */ diff --git a/src/hook-handlers.ts b/src/hook-handlers.ts index f1c8b0437..6f0936069 100644 --- a/src/hook-handlers.ts +++ b/src/hook-handlers.ts @@ -17,6 +17,7 @@ import { deriveSessionId } from './utils/session-id.js'; import { log } from './utils/logger.js'; import { normalizeToolName } from './utils/tool-names.js'; import { resolveHookCwd } from './utils/hook-cwd.js'; +import { pathExists } from './utils/fs.js'; // ─── Public types ─────────────────────────────────────── @@ -109,8 +110,16 @@ export const PULL_TIMEOUT_MS = 120_000; const pullHandler: HookHandler = { name: 'pull', - async execute(stdin, tool) { + async execute(stdin, tool, config) { const cwd = resolveHookCwd(stdin); + // No config resolved: teamai is not set up here, or the project config + // cannot be read. Only the second stops the pull, since what detection + // loads after that file may be another team's (#784). A cwd that no longer + // exists holds no project config, and git refuses to open it. + if (!config && (cwd === undefined || await pathExists(cwd))) { + const { findUnreadableProjectConfig } = await import('./config.js'); + if (await findUnreadableProjectConfig(cwd) !== null) return null; + } const hintCwd = cwd ?? process.cwd(); const packageHints = await import('./pkg/pkg-hint.js'); const packageHashBeforePull = await packageHints.packageManifestHashForCwd(hintCwd); diff --git a/src/pull.ts b/src/pull.ts index f13b1e0e3..6196b50af 100644 --- a/src/pull.ts +++ b/src/pull.ts @@ -2,7 +2,7 @@ import path from 'node:path'; import { readFile } from 'node:fs/promises'; import matter from 'gray-matter'; import { selectAgentsForDirectory } from './resources/agents.js'; -import { requireInit, loadState, saveState, detectProjectConfig, loadLocalConfigForScope, loadTeamConfig, loadStateForScope, saveStateForScope } from './config.js'; +import { requireInit, loadState, saveState, detectProjectConfig, describeUnreadableConfig, loadLocalConfigForScope, loadTeamConfig, loadStateForScope, saveStateForScope } from './config.js'; import { pullRepo, getHeadRev, createGit, getDefaultBranch } from './utils/git.js'; import { publishQueuedLearnings } from './utils/learnings-publish.js'; import { pendingLearningsDir } from './utils/pending-learnings.js'; @@ -1896,11 +1896,26 @@ export async function pull( // 1. Detect project scope first. Its presence decides whether user scope is // processed at all (issue #73: project install isolates from user). let projectConfig: LocalConfig | null = null; + const unreadable: string[] = []; try { - projectConfig = await detectProjectConfig(); + projectConfig = await detectProjectConfig(undefined, (configPath, error) => { unreadable.push(`${configPath}: ${error}`); }); } catch (e) { log.warn(`Project-scope detection error: ${(e as Error).message}`); } + // Detection skips a project config it cannot read and answers with what + // loads next — a legacy `.teamai/` that may name another team, or the user + // scope — so pulling would sync and report for a team this project may not + // belong to (#784). The same rule hooks and usage follow (#748). + const [problem] = unreadable; + if (problem !== undefined) { + const message = `Nothing was synced: ${describeUnreadableConfig(problem)}`; + // A pre-dispatch hook still runs `teamai pull --silent` in the foreground: + // debug.log keeps the record, and its `|| true` absorbs the exit code. + if (options.silent) log.persist(message); + else log.error(message); + process.exitCode = 1; + return; + } const projectMode = projectConfig !== null; const inheritUserScope = projectConfig?.inheritUserScope === true; diff --git a/src/skill-content.ts b/src/skill-content.ts index 31e4c242e..31b28c8d2 100644 --- a/src/skill-content.ts +++ b/src/skill-content.ts @@ -98,7 +98,7 @@ export type TeamDetection = | { kind: 'unusable'; detail: string }; export async function detectTeam(cwd?: string): Promise { - const { autoDetectInit, findUnreadableProjectConfig, requireInit, NotInitializedError, BROKEN_CONFIG_ADVICE } = + const { autoDetectInit, findUnreadableProjectConfig, requireInit, NotInitializedError, describeUnreadableConfig } = await import('./config.js'); // Loading the config can migrate it and say so with `log.info`. That line // must not land in the skill content, the JSON these commands print on @@ -120,11 +120,7 @@ export async function detectTeam(cwd?: string): Promise { } } const unreadable = await findUnreadableProjectConfig(cwd); - if (unreadable) { - // A parse error spans several lines (a code frame); its first names the - // file, the line and the column, which is what the member acts on. - return { kind: 'unusable', detail: `${firstLine(unreadable)}. ${BROKEN_CONFIG_ADVICE}` }; - } + if (unreadable) return { kind: 'unusable', detail: describeUnreadableConfig(unreadable) }; return { kind: 'team', init: await autoDetectInit(cwd) }; } catch (e) { if (e instanceof NotInitializedError) return { kind: 'none' };