diff --git a/docs/usage-guide.md b/docs/usage-guide.md index b354d651a..7dc6cd5a8 100644 --- a/docs/usage-guide.md +++ b/docs/usage-guide.md @@ -162,10 +162,72 @@ there stops if it finds a team rule or skill that differs from the team repo, si cannot tell a teammate's update from your edit. `teamai pull` replaces those files, so copy any you edited somewhere safe, pull, put your edits back and push again. Per-agent project roots (`.claude/`, `.cursor/`, `.codebuddy/`, …) are still created inside the -workspace on **SessionStart** for the tool that just opened. For example, opening -Claude Code creates `.claude/`, then pull writes into it. A bare `teamai pull` still -skips tools whose project root does not exist, so it never invents agent directories -for tools you have not opened in this project. +workspace. `teamai init` creates the root of each tool you choose, then ends with a +pull that fills it: name the tools with `--agent `, or, in a terminal without +`--agent`, pick them from the same picker single-repo mode uses (option 1, **Auto**, +is the tools installed under your home dir and the Enter default). The choice is +added to `enabledAgents`, so a re-run adds tools without dropping earlier ones. +Otherwise **SessionStart** creates the root of the tool that just opened: opening +Claude Code creates `.claude/`, then pull writes into it. A bare `teamai pull`, and a +non-interactive `init` without `--agent`, still skip tools whose project root does +not exist, so they never invent agent directories for tools you have not +chosen or opened in this project. + +A new worktree does not wait for that first session. In project scope, `teamai init` +and `teamai pull` install a git hook in the repository's local git config, shared by +every worktree: `hook.teamai-post-checkout` and `hook.teamai-post-merge` (Git 2.54 or +later). Git runs it beside any `core.hooksPath` hook +manager and any `.git/hooks` script. When `git worktree add`, or an app that runs +the same checkout hooks, makes a new checkout, the hook creates the project roots of +`enabledAgents` (when that is empty, the roots the main checkout has) and pulls into +the worktree before the command returns, so the first session there already has the +team's skills, rules and MCP servers. That pull reads the team clone as it is when it +was fetched in the last 24 hours (and fetches it first otherwise), and subscribed +sources from their cached clones; a full `teamai pull --silent` then runs in the +background to fetch the team repo, sources, learnings and reports. A branch switch does nothing. +Hosts that skip checkout hooks need a setup step that finishes `teamai pull` before +the AI tool starts. For Codex CLI 0.160.0, create the checkout with `git worktree add`, run +`teamai pull` there, then launch `codex exec -C `; its native +`codex exec --worktree` path skips `post-checkout`. +After `git pull` (`post-merge`), the hook fetches the team repo, waiting at most 5 seconds, +and delivers its changes before `git pull` returns; past 5 seconds, and for sources, +learnings and reports, the same background pull takes over. In single-repo mode it +delivers the knowledge `git pull` just brought, with no network. The hook prints nothing and always exits 0, so a failed pull never +fails the git command. A failure inside it (the team repo fetch failed, or stopped at the +5-second cap and the background pull did not finish it; another teamai process held the +project's sync lock longer than the hook waits, 5 seconds after `git pull` (including +single-repo mode) and 60 seconds for a new worktree; incomplete resource, hook or MCP +delivery) is written to `~/.teamai/debug.log` and recorded: `teamai doctor` +names it with its fix, and each interactive `teamai pull` mentions it until one completes. The +background pull retries, and a hook or interactive pull clears the record only after all startup delivery +stages succeed. `teamai doctor` +also reports whether the hook is installed and, when it is not, why. It follows the scope rules below: no project config, or one +that cannot be read, means no sync; an unreadable config's reason is kept in +`~/.teamai/debug.log`. The command is one `sh` line that runs +`teamai hook-dispatch --tool git` with Git's arguments, finding `teamai` +through `~/.teamai/bin` as the agent hooks do. + +With Git older than 2.54 and no `core.hooksPath`, teamai instead adds a block between +`# >>> teamai git hook` and `# <<< teamai git hook <<<` markers to `.git/hooks/post-checkout` +and `.git/hooks/post-merge`, right after the shebang, creating the script when there is +none; the script's other lines are kept. The block runs the same command, silently, and +does not change the script's exit status. With `core.hooksPath` set (a hook manager), or +a hook script that is a symlink or not an executable shell script, teamai writes nothing, and `teamai doctor` +advises: upgrade Git to 2.54 or later; or, if the team agrees to commit it, run +`command -v teamai >/dev/null 2>&1 && teamai hook-dispatch --tool git "$@" >/dev/null 2>&1 || true` +from the post-checkout and post-merge hooks your manager defines (with `post-checkout` or +`post-merge` as ``), wrapped in `sh -c '...'` when its config is not a shell script. +That line does nothing on a machine without teamai. +Existing hook contents and permissions are preserved. Reading or writing a hook can +fail: `init` and `hooks inject` propagate that error; a Git-started pull records it +and the next `teamai pull` retries. + +Once Git is 2.54 or later, the next `teamai pull` installs the config hook and takes the +block out, so the hook does not run twice. `teamai pull --dry-run` says when it would +install or update the hook and writes nothing. `teamai uninstall` in the project removes +the `hook.teamai-post-checkout` and `hook.teamai-post-merge` entries and the marked +blocks; other hooks and script lines stay. A script left with only its shebang is the +one teamai created, and is deleted. > **Upgrading from an older teamai?** The first `teamai init` / `pull` / `push` / > `contribute` (or `import --from-mr`) after upgrading automatically migrates an existing `/.teamai/` into the partition @@ -645,7 +707,7 @@ resolve: `teamai skill get team-wiki-codebase` serves `wiki`. ### Auto-sync -`teamai init` already injected Hooks into your AI tools. **`teamai pull` runs automatically every time you start an AI session** — no manual action needed. In project scope, that SessionStart hook first creates the current agent's project root (e.g. `/.claude` when Claude Code opens the repo) if it is missing, then pulls. +`teamai init` already injected Hooks into your AI tools and ended with a pull, so your first session has the team's skills, rules and MCP servers. **`teamai pull` runs automatically every time you start an AI session** — no manual action needed. In project scope, that SessionStart hook first creates the current agent's project root (e.g. `/.claude` when Claude Code opens the repo) if it is missing, then pulls. *(Note: Automatic sync on session start requires an agent that supports lifecycle hooks, such as [CC], Codex, GitHub Copilot CLI, Cursor, CodeBuddy, WorkBuddy, Qoder, Kiro, OpenCode, Oh My Pi, Pi, Hermes, or OpenClaw. Kiro runs the hook when a TeamAI-rendered custom agent is activated in an interactive CLI session; its in-memory built-in default agent is not writable, and non-interactive mode does not fire `agentSpawn`. For tools without a teamai-writable hooks surface such as JoyCode or Gemini CLI, run `teamai pull` manually.)* @@ -2865,6 +2927,7 @@ What gets removed: - Team-synced rules, including the copies older releases left in `.codex/rules/`, also of rules the team has since removed. Cleanup follows the recorded `toolRoots` location and the publisher's local filenames. A copy there you edited is kept and named in a warning. A removed rule's copy is deleted only if it matches its recorded delivery hash; without that record, it is kept and named too. Codex's `*.rules` files are kept - Team-synced custom agents and CLI built-in agents (your own agents are preserved) - The env block in your shell profile — every candidate file (`.zshrc`, `.bashrc`, `.bash_profile`, `.bash_login`, `.profile`) carrying a block that sources this scope's own `env.sh` is cleaned, not only the one file `pull` would choose today; a block sourcing a different scope's `env.sh` is left alone +- In a project, teamai's git hook: the `hook.teamai-post-checkout` and `hook.teamai-post-merge` entries in the repository's git config, and the marked block in `.git/hooks/post-checkout` and `post-merge` (a script left with only its shebang, the one teamai created, is deleted). Other hooks are kept - The `~/.teamai/` directory ### Uninstall a single tool (`--agent `) @@ -2889,7 +2952,6 @@ To rejoin after uninstalling: ```bash teamai init --repo https://github.com/yourorg/yourrepo --scope user --role --force -teamai pull ``` --- @@ -2910,7 +2972,7 @@ teamai init --repo https://github.com/yourorg/yourrepo --force **Q: After `teamai init` in a project, there is no `.claude/` (or `.cursor/`, `.codebuddy/`) directory?** -That is expected for a built-in tool: `init` does not know which agent you will open. Open Claude Code / Cursor / CodeBuddy in the project: the SessionStart hook creates that tool's project root and then pulls. A bare `teamai pull` will not create missing agent roots. The exception is a custom agent defined only in `teamai.yaml`'s `toolPaths` (not one of the built-in tools) — `init --agent ` creates that agent's root itself, since nothing else ever would. This only works for git-backed init (default or `--self`): an HTTP init (`--http`) never clones a local `teamai.yaml`, so it has no custom paths to seed from and only ever creates roots for built-in tools that are already installed. +That is expected for a built-in tool when `init` ran without `--agent` and without a terminal (no picker): it does not know which agent you will open. Run `teamai init --agent claude` (or `cursor`, `codebuddy`, …) to create that tool's root and fill it before init exits, or open the tool in the project: the SessionStart hook creates that tool's project root and then pulls. A bare `teamai pull` will not create missing agent roots. The exception is a custom agent defined only in `teamai.yaml`'s `toolPaths` (not one of the built-in tools) — `init --agent ` creates that agent's root itself, since nothing else ever would. This only works for git-backed init (default or `--self`): an HTTP init (`--http`) never clones a local `teamai.yaml`, so it has no custom paths to seed from and only ever creates roots for built-in tools that are already installed. **Q: Hooks aren't firing automatically?** diff --git a/docs/usage-guide.zh-CN.md b/docs/usage-guide.zh-CN.md index 6e6d2f3ad..669e18a30 100644 --- a/docs/usage-guide.zh-CN.md +++ b/docs/usage-guide.zh-CN.md @@ -157,9 +157,51 @@ teamai init https://github.com/yourorg/yourrepo `teamai pull` 会向它完整同步一次,即使团队仓库自另一个检出 pull 之后并未变化。worktree 尚未 pull 过时, 若 `teamai push` 发现与团队仓库不同的团队 rule 或 skill 就会停止,因为无法区分队友的更新和你的修改。 `teamai pull` 会覆盖这些文件:如有修改,请先另存一份,再在该 worktree 中执行 `teamai pull`,放回修改后重新 push。各 Agent 的项目根目录 -(`.claude/`、`.cursor/`、`.codebuddy/` 等)仍在工作区内、于 **SessionStart** 时按刚打开的 -工具创建。例如,打开 Claude Code 时会创建 `.claude/`,再由 pull 写入。单独执行 `teamai pull` -仍会跳过项目里还不存在根目录的工具,因此不会给尚未在本项目打开过的 Agent 凭空建目录。 +(`.claude/`、`.cursor/`、`.codebuddy/` 等)仍在工作区内创建。`teamai init` 会为你选择的每个工具 +创建根目录,并在结束时执行一次 pull 将其填充:用 `--agent ` 指定工具;或在终端中不带 `--agent` +运行时,从与单仓库模式相同的选择器中勾选(第 1 项 **Auto** 为你 home 目录下已安装的工具,也是回车默认项)。 +所选工具会追加到 `enabledAgents`,因此重新执行只会新增工具,不会丢掉之前的选择。否则由 **SessionStart** 按刚打开的 +工具创建:打开 Claude Code 时会创建 `.claude/`,再由 pull 写入。单独执行 `teamai pull`,以及非交互且不带 +`--agent` 的 `init`,仍会跳过项目里还不存在根目录的工具,因此不会给尚未在本项目选择或打开过的 Agent 凭空建目录。 + +新 worktree 不必等到第一次会话。在项目 scope 下,`teamai init` 与 `teamai pull` 会在仓库的本地 +git 配置中安装一个 git hook,所有 worktree 共用:`hook.teamai-post-checkout` 与 +`hook.teamai-post-merge`(需要 Git 2.54 或更高版本)。Git 会在任何 +`core.hooksPath` hook 管理器和 `.git/hooks` 脚本之外一并运行它。当 `git worktree add`,或运行相同 +checkout hook 的应用,新建一个检出时,该 hook 会创建 `enabledAgents` 的项目根目录(为空时,取主检出已有的根目录), +并在命令返回前向该 worktree 执行 pull,因此其中的第一次会话就已具备团队的 skill、rule 与 MCP 服务器。 +团队仓库克隆若在 24 小时内 fetch 过,这次 pull 直接读取它(否则先 fetch),订阅的 source 读取其缓存克隆; +随后在后台运行一次完整的 `teamai pull --silent`,fetch 团队仓库、source、learnings 与 reports。 +切换分支不会触发任何操作。跳过 checkout hook 的宿主需要在 AI 工具启动前完成 `teamai pull` 的准备步骤。 +Codex CLI 0.160.0 请先用 `git worktree add` 创建检出,在其中执行 `teamai pull`,再用 +`codex exec -C ` 启动;原生 `codex exec --worktree` 路径会跳过 `post-checkout`。 +`git pull` 之后(`post-merge`),该 hook 会 fetch 团队仓库(最多等待 5 秒), +并在 `git pull` 返回前交付其变更;超过 5 秒时,以及 source、learnings 与 reports,交给同样的后台 pull。 +单仓库模式下,它交付 `git pull` 刚带来的知识,不访问网络。该 hook 不输出任何内容且始终以 0 退出, +因此 pull 失败也不会让 git 命令失败。hook 内的失败(团队仓库 fetch 失败,或在 5 秒上限处被中止而后台 pull +也未完成;另一个 teamai 进程持有项目的同步锁,超过 hook 的等待时间:`git pull` 之后 5 秒(包括单仓库模式),新 worktree 60 秒;资源、hook 或 MCP 未完整交付) +会写入 `~/.teamai/debug.log` 并被记录:`teamai doctor` 会指出它及其修复方法,每次交互式 `teamai pull` +都会提示,直到某次完成为止。后台 pull 会重试,只有所有启动交付阶段都成功后,hook pull 或交互式 pull 才会清除该记录。`teamai doctor` 还会报告 hook +是否已安装,未安装时说明原因。它遵循下文的 scope 规则:没有项目配置,或项目配置无法读取, +都不会同步;无法读取配置的原因保留在 `~/.teamai/debug.log` 中。其命令是一行 `sh`,带着 Git 传入的参数运行 `teamai hook-dispatch --tool git`, +与 Agent hook 一样通过 `~/.teamai/bin` 找到 `teamai`。 + +Git 低于 2.54 且未设置 `core.hooksPath` 时,teamai 改为在 `.git/hooks/post-checkout` 与 +`.git/hooks/post-merge` 的 shebang 之后插入一段位于 `# >>> teamai git hook` 与 `# <<< teamai git hook <<<` +标记之间的代码块(脚本不存在时会创建),脚本的其他行保持不变。该代码块运行同一条命令,不输出任何内容, +也不改变脚本的退出码。设置了 `core.hooksPath`(hook 管理器),或 hook 脚本是符号链接或不是可执行的 shell 脚本时,teamai +不写入任何内容,`teamai doctor` 会建议:将 Git 升级到 2.54 或更高版本;或者,如果团队同意提交它,在管理器定义的 +post-checkout 与 post-merge hook 中运行 +`command -v teamai >/dev/null 2>&1 && teamai hook-dispatch --tool git "$@" >/dev/null 2>&1 || true` +(`` 分别为 `post-checkout` 与 `post-merge`),管理器的配置不是 shell 脚本时用 `sh -c '...'` 包裹。 +在没有 teamai 的机器上,这一行什么也不做。 +已有 hook 的内容和权限保持不变。读取或写入 hook 失败时,`init` 与 `hooks inject` 会传播该错误; +由 Git 启动的 pull 会记录错误,下一次 `teamai pull` 会重试。 + +Git 升级到 2.54 或更高版本后,下一次 `teamai pull` 会安装配置 hook 并移除该代码块,避免 hook 运行两次。 +`teamai pull --dry-run` 会说明是否将安装或更新该 hook,但不写入任何内容。在项目中运行 `teamai uninstall` +会移除 `hook.teamai-post-checkout` 与 `hook.teamai-post-merge` 条目以及带标记的代码块;其他 hook 和脚本行保持不变。 +移除后只剩 shebang 的脚本是 teamai 创建的,会被删除。 > **从旧版 teamai 升级?** 升级后首次执行 `teamai init` / `pull` / `push` / `contribute` > (或 `import --from-mr`)会自动把已有的 @@ -571,7 +613,7 @@ teamai skill path wiki # 打印打包目录,用于运行 skill ### 自动同步 -`teamai init` 时已注入 Hooks 到你的 AI 工具中。**每次启动 AI 会话时会自动执行 `teamai pull`**,无需手动操作。在 project scope 下,该 SessionStart hook 会先为当前 Agent 创建项目根目录(例如用 Claude Code 打开仓库时创建 `/.claude`),然后再 pull。 +`teamai init` 时已注入 Hooks 到你的 AI 工具中,并在结束时执行了一次 pull,因此你的第一个会话就已拥有团队的 skill、rule 和 MCP server。**每次启动 AI 会话时会自动执行 `teamai pull`**,无需手动操作。在 project scope 下,该 SessionStart hook 会先为当前 Agent 创建项目根目录(例如用 Claude Code 打开仓库时创建 `/.claude`),然后再 pull。 *(注:会话启动自动同步依赖工具的生命周期 Hooks 支持,如 [CC]、Codex、GitHub Copilot CLI、Cursor、CodeBuddy、WorkBuddy、Qoder、Kiro、OpenCode、Oh My Pi、Pi、Hermes、OpenClaw 等。Kiro 仅在交互式 CLI 会话激活由 TeamAI 渲染的自定义 agent 时触发该 Hook;其内存中的内置默认 agent 无法写入,非交互模式也不会触发 `agentSpawn`。对于暂无 teamai 可写入 Hooks 的工具(如 JoyCode、Gemini CLI 等),需手动执行 `teamai pull`。)* @@ -2676,6 +2718,7 @@ teamai uninstall --agent claude - 团队同步的 rules,包括旧版本留在 `.codex/rules/` 中的副本,团队此后已删除的 rule 的副本也包括在内。清理使用记录的 `toolRoots` 位置和发布者本地的文件名。其中你改过的副本会保留,并在警告中点名。已删除 rule 的副本只有与记录的投递哈希一致时才会删除;没有该记录时也会保留并点名。Codex 的 `*.rules` 文件保留 - 团队同步的自定义 agents 和 CLI 内置 agents(保留用户自建 agents) - Shell profile 中的 env 块——会清理每一个候选文件(`.zshrc`、`.bashrc`、`.bash_profile`、`.bash_login`、`.profile`)中、代码块指向本作用域自身 `env.sh` 的那些,而不仅仅是当前 `pull` 会选中的那一个;指向其他作用域 `env.sh` 的代码块不受影响 +- 项目中 teamai 的 git hook:仓库 git 配置中的 `hook.teamai-post-checkout` 与 `hook.teamai-post-merge` 条目,以及 `.git/hooks/post-checkout` 与 `post-merge` 中带标记的代码块(移除后只剩 shebang 的脚本是 teamai 创建的,会被删除)。其他 hook 保留 - `~/.teamai/` 目录 ### 只卸载单个工具(`--agent `) @@ -2700,7 +2743,6 @@ teamai uninstall --agent claude ```bash teamai init --repo https://github.com/yourorg/yourrepo --scope user --role --force -teamai pull ``` --- @@ -2721,7 +2763,7 @@ teamai init --repo https://github.com/yourorg/yourrepo --force **Q: 在项目里执行 `teamai init` 后没有 `.claude/`(或 `.cursor/`、`.codebuddy/`)目录?** -对内置工具而言这是预期行为:`init` 不知道你会打开哪个 Agent。在项目中打开 Claude Code / Cursor / CodeBuddy:SessionStart hook 会创建该工具的项目根目录并随后 pull。单独执行 `teamai pull` 不会为缺失的 Agent 根目录建目录。例外是仅在 `teamai.yaml` 的 `toolPaths` 中定义的自定义 Agent(不属于内置工具)——`init --agent ` 会自行创建该 Agent 的根目录,因为没有其他流程会为它创建。这仅在 git 模式的 init(默认或 `--self`)下生效:HTTP init(`--http`)不会在本地克隆 `teamai.yaml`,因此没有自定义路径可供创建,只会为已安装的内置工具创建根目录。 +对内置工具而言,`init` 未带 `--agent` 且没有终端(不弹选择器)时这是预期行为:它不知道你会打开哪个 Agent。执行 `teamai init --agent claude`(或 `cursor`、`codebuddy` 等)会在 init 结束前创建该工具的根目录并填充;或者在项目中打开该工具:SessionStart hook 会创建该工具的项目根目录并随后 pull。单独执行 `teamai pull` 不会为缺失的 Agent 根目录建目录。例外是仅在 `teamai.yaml` 的 `toolPaths` 中定义的自定义 Agent(不属于内置工具)——`init --agent ` 会自行创建该 Agent 的根目录,因为没有其他流程会为它创建。这仅在 git 模式的 init(默认或 `--self`)下生效:HTTP init(`--http`)不会在本地克隆 `teamai.yaml`,因此没有自定义路径可供创建,只会为已安装的内置工具创建根目录。 **Q: Hooks 没有自动触发?** diff --git a/skill-data/core/SKILL.md b/skill-data/core/SKILL.md index 02ba9a7d3..8ddec5235 100644 --- a/skill-data/core/SKILL.md +++ b/skill-data/core/SKILL.md @@ -127,6 +127,24 @@ changed since, or push says so for that copy, merge that change into the copy fi the copy and run `teamai pull --force`. The first pull after upgrading, and a new worktree's first pull, still overwrite: nothing is recorded yet. +In project scope, `init` and `pull` also install a git hook in the repository's +local git config (`hook.teamai-post-checkout`, `hook.teamai-post-merge`; Git +2.54+; older Git without `core.hooksPath` gets a marked block in `.git/hooks/` +scripts, and with it `teamai doctor` advises), beside any `core.hooksPath` manager or `.git/hooks` script. When a +worktree is created by `git worktree add` or an app that runs checkout hooks, it creates the project roots +of `enabledAgents` (else the ones the main checkout has) and pulls into it before +the command returns, from the team clone as last fetched when that was within +24 h; a full pull then runs in the background. A branch switch does nothing. +After `git pull` it fetches the team repo (5 s cap, then the background pull) and +delivers; in single-repo mode it delivers what `git pull` brought, offline. It prints nothing and always +exits 0; a failure inside it is recorded, and `teamai doctor` names it (`Last git +hook run failed: ...`) with its fix, as does the next interactive `teamai pull`, once. +`teamai doctor` also reports whether the hook is installed, and why not. +`pull --dry-run` says when it would install or update the hook, writing nothing; +`teamai uninstall` removes only teamai's hook entries and blocks. For hosts that +skip checkout hooks, prepare the worktree before launch; see the new-worktree +section in `references/troubleshooting.md`. + A team agent (`agents/.yaml`) can set `model: strong`, `model: fast`, or an alias the team defines, instead of one tool's model. The team maps each alias per tool in `models/aliases.yaml`, in that tool's own model value, with an optional effort: diff --git a/skill-data/core/references/troubleshooting.md b/skill-data/core/references/troubleshooting.md index a1a37513d..81928ecc5 100644 --- a/skill-data/core/references/troubleshooting.md +++ b/skill-data/core/references/troubleshooting.md @@ -16,8 +16,11 @@ reports before anything else. This is the #1 onboarding issue. In order: -1. **Open a fresh session.** Resources sync on **session start** via a hook, not - at init time. An empty skills folder right after `teamai init` is normal. +1. **Did init pick this tool?** `teamai init` ends with a pull, but a project-scope + init creates only the directories of tools named with `--agent` or picked in + its interactive tool picker. Run without a terminal and without `--agent`, it + creates none, and a tool's project directory appears when that tool opens a session there. Re-run + `teamai init --agent ` to add the tool and fill it now. 2. **Sync manually to confirm:** ```bash teamai pull @@ -65,6 +68,34 @@ This is the #1 onboarding issue. In order: `recall` refuses the same way with `Nothing was searched: : `: no team knowledge was searched, so do not report that the team has none. +## "Last git hook run failed: ..." / a new worktree lacks team resources + +In project scope, teamai's git hook syncs on `git worktree add` and `git pull` +silently and always exits 0, so its failures surface only here: `teamai doctor` +names the last one with its fix, and the next interactive `teamai pull` says it +once. The causes are a team repo fetch that failed or hit the 5 s post-merge +cap without the background pull finishing it, and another teamai process +holding the project's sync lock longer than the hook waits, or incomplete resource, +hook or MCP delivery. Only a complete startup sync clears the recorded failure. +Run `teamai pull` +in the checkout (after a stuck pull ends, or once the team repo is reachable); +`~/.teamai/debug.log` has the details. If doctor reports `Git hook syncs new +worktrees and git pull` as failing, follow its fix: `teamai pull` installs it. +Git older than 2.54 has no config hooks: teamai then adds a marked block to +`.git/hooks/post-checkout` and `post-merge`, unless `core.hooksPath` is set (or a +hook there is a symlink or not an executable shell script), in which case doctor's fix says to upgrade Git +or, if the team agrees, to commit its guarded `command -v teamai ... || true` +line into the manager's post-checkout and post-merge hooks. +Existing hook contents and permissions stay unchanged; read/write errors propagate +from `init` and `hooks inject`, and Git-started pulls record them. An unreadable +project config prevents sync and keeps its reason in `~/.teamai/debug.log`. + +Hosts that skip checkout hooks need `teamai pull` in the new checkout before the AI +tool starts. For Codex CLI 0.160.0, use `git worktree add`, run `teamai pull` there, then +launch `codex exec -C `. Its native `codex exec --worktree` path creates +the checkout without `post-checkout`, so SessionStart sync arrives after startup +discovery. + ## "KEY is not set. Run `teamai env set KEY`" `pull`, `teamai mcp list`, `teamai env list`, `teamai doctor` and diff --git a/skill-data/setup/SKILL.md b/skill-data/setup/SKILL.md index 683133311..3ea247d9f 100644 --- a/skill-data/setup/SKILL.md +++ b/skill-data/setup/SKILL.md @@ -49,10 +49,13 @@ and create-repo URLs, and the per-provider caveats, and points at auto-start TeamAI, and which detected tools were skipped and why (e.g. Codex trust-gate, CodeBuddy design). Verify the real per-tool result with `teamai doctor` and `teamai hooks list`. -3. **After init, resources appear on the NEXT session.** `teamai init` injects a - session-start hook that auto-runs `teamai pull`. Empty skills/rules directories - right after init are normal; they fill in when the user opens a fresh session in - this tool. To sync immediately, run `teamai pull`. +3. **`teamai init` ends with a pull.** In user scope, and in project scope for each + tool named with `--agent` (or picked in init's tool picker when a person runs it + in a terminal), the team's skills, rules and MCP servers are in place when init + exits; there is no need to run `teamai pull` after it. Run from an agent shell + (no terminal), a project-scope init without `--agent` creates no tool directory: a tool's directory appears and + fills when the user opens that tool in the project. Init also injects a + session-start hook that keeps resources synced from then on. 4. **Finish with `teamai doctor`.** Every setup or onboarding flow ends by running it and resolving what it reports before you call the job done. diff --git a/skill-data/setup/references/join-member.md b/skill-data/setup/references/join-member.md index 0107517ab..e766a313d 100644 --- a/skill-data/setup/references/join-member.md +++ b/skill-data/setup/references/join-member.md @@ -126,11 +126,14 @@ section "Which tools actually get hooks". ## Step 6 — Confirm the skills actually arrived -Team resources sync on **session start**, so they may be empty right after init. -To confirm now: +`teamai init` ends with a pull, so the team's skills, rules and MCP servers are +already in place in user scope, and in project scope for each tool named with +`--agent` or picked in init's interactive tool picker. A project-scope init run +without a terminal and without `--agent` creates no tool directory; a +tool's directory fills when the user first opens that tool in the project. To +confirm: ```bash -teamai pull # sync immediately teamai list # see the team skills / rules / docs you now have ``` diff --git a/skill-data/setup/references/setup-admin.md b/skill-data/setup/references/setup-admin.md index 6194c71b9..e947b110b 100644 --- a/skill-data/setup/references/setup-admin.md +++ b/skill-data/setup/references/setup-admin.md @@ -266,9 +266,12 @@ carries counts + tool names only, on a separate branch of that same repo.) URL filled in. The `/teamai` prefix stays as-is; translate the rest: `/teamai Help me join my team's TeamAI, repo URL is ` Tell them to send the URL + this line to each member. -3. Remind them (in their language): **new resources appear only after opening a - fresh session** in the AI tool. Right after init the skills folder may look - empty — that is expected. To sync now, run `teamai pull`. +3. Remind them (in their language): a member's `teamai init` ends with a pull, so + the team's resources are in place when it exits (in project scope, for each + tool named with `--agent` or picked in init's tool picker; otherwise a tool's + directory fills when the + member first opens that tool in the project). Resources the team adds later + arrive at the next session start. ## Step 9 — What's next (guide them, don't just list commands) diff --git a/skill-data/setup/references/uninstall.md b/skill-data/setup/references/uninstall.md index 08f2f4a23..64932c6ae 100644 --- a/skill-data/setup/references/uninstall.md +++ b/skill-data/setup/references/uninstall.md @@ -85,6 +85,10 @@ and give it your team repo URL."* and neither should you. - If the user only wants to stop auto-sync for one tool but keep TeamAI otherwise, that is the `--agent ` form, not a full uninstall. +- In a project, uninstall also removes teamai's git hook: the + `hook.teamai-post-checkout` / `hook.teamai-post-merge` entries in the repo's git + config and the `# >>> teamai git hook` block in `.git/hooks/post-checkout` and + `post-merge`. Other hooks stay; a script left with only its shebang is deleted. - In a project, uninstall also takes teamai's lines out of `.git/info/exclude` (the `# [teamai:mcp-exclude:start]` block) for MCP configs it proves hold no resolved `${VAR}` value. A line names the path a write lands in: for a config diff --git a/src/__tests__/detached-processes.test.ts b/src/__tests__/detached-processes.test.ts new file mode 100644 index 000000000..caa1f2863 --- /dev/null +++ b/src/__tests__/detached-processes.test.ts @@ -0,0 +1,26 @@ +import { expect, it } from 'vitest'; +import { spawnSync } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { trackDetachedProcesses } from './helpers/detached-processes.js'; + +it('waits for a detached writer after its spawning parent has exited', async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'teamai-detached-fixture-')); + const tracker = trackDetachedProcesses(root); + const output = path.join(root, 'late-write'); + try { + const childCode = `setTimeout(() => require('node:fs').writeFileSync(${JSON.stringify(output)}, 'finished'), 300)`; + const parent = spawnSync(process.execPath, ['-e', ` + require('node:child_process').spawn(process.execPath, ['-e', ${JSON.stringify(childCode)}], { + detached: true, stdio: 'ignore' + }).unref(); + `], { env: { ...process.env, NODE_OPTIONS: tracker.nodeOptions } }); + expect(parent.status, parent.stderr.toString()).toBe(0); + await tracker.waitForExit(); + expect(fs.readFileSync(output, 'utf8')).toBe('finished'); + } finally { + await tracker.waitForExit(); + fs.rmSync(root, { recursive: true, force: true }); + } +}); diff --git a/src/__tests__/e2e/git-hook-new-worktree.test.ts b/src/__tests__/e2e/git-hook-new-worktree.test.ts new file mode 100644 index 000000000..602540f36 --- /dev/null +++ b/src/__tests__/e2e/git-hook-new-worktree.test.ts @@ -0,0 +1,658 @@ +/** + * E2E: a new worktree gets the team's resources before `git worktree add` + * returns. `teamai init` (project scope) installs a named hook in the + * repository's git config; real git runs it on `post-checkout`, and it calls + * the real CLI's dispatcher, which creates the tool roots and pulls into the + * new worktree. `git pull` (`post-merge`) brings the team's change the same way. + * + * The team remote is a local bare repo reached through a synthetic HTTPS URL + * (`url..insteadOf` in the sandbox HOME), as in init-project-all.test.ts. + */ +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { execFileSync, spawnSync } from 'node:child_process'; +import fs from 'node:fs'; +import net from 'node:net'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { trackDetachedProcesses } from '../helpers/detached-processes.js'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const ROOT = path.resolve(__dirname, '..', '..', '..'); +const CLI = path.join(ROOT, 'dist', 'index.js'); + +const FAKE_URL = 'https://git.example.com/team/hook-team.git'; +const ZERO_OID = '0'.repeat(40); + +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', +}; + +const configHooks = (() => { + const m = /(\d+)\.(\d+)/.exec(execFileSync('git', ['--version'], { encoding: 'utf8' })); + const [major, minor] = m ? [Number(m[1]), Number(m[2])] : [0, 0]; + return major > 2 || (major === 2 && minor >= 54); +})(); + +interface Run { + code: number | null; + output: string; +} + +describe.skipIf(!configHooks)('git hook: a new worktree gets the team\'s resources (git worktree add)', () => { + let sandbox: string; + let home: string; + let remote: string; + let claudeProject: string; + let detached: ReturnType; + + const env = (extra: Record = {}): NodeJS.ProcessEnv => { + const base: NodeJS.ProcessEnv = { ...process.env, ...GIT_ENV, HOME: home, USERPROFILE: home, FORCE_COLOR: '0', ...extra }; + delete base.CLAUDE_CONFIG_DIR; + delete base.CODEX_HOME; + base.NODE_OPTIONS = [base.NODE_OPTIONS, detached.nodeOptions].filter(Boolean).join(' '); + return base; + }; + + const run = (command: string, args: string[], cwd: string, extra: Record = {}): Run => { + const r = spawnSync(command, args, { cwd, encoding: 'utf8', env: env(extra) }); + return { code: r.status, output: `${r.stdout ?? ''}${r.stderr ?? ''}` }; + }; + const git = (args: string[], cwd: string, extra: Record = {}): Run => run('git', args, cwd, extra); + const gitOk = (args: string[], cwd: string): string => { + const r = git(args, cwd); + if (r.code !== 0) throw new Error(`git ${args.join(' ')} failed: ${r.output}`); + return r.output.trim(); + }; + const teamai = (args: string[], cwd: string, extra: Record = {}): Run => run('node', [CLI, ...args], cwd, extra); + + /** A business repo with one commit, `teamai init`-ed in project scope. */ + const project = (name: string, initArgs: string[]): string => { + const dir = path.join(sandbox, name); + fs.mkdirSync(dir); + fs.writeFileSync(path.join(dir, '.gitignore'), '.teamai/\n'); + gitOk(['init', '-q', '-b', 'main'], dir); + gitOk(['add', '-A'], dir); + gitOk(['commit', '-q', '-m', 'project'], dir); + const init = teamai(['init', FAKE_URL, '--scope', 'project', '--force', ...initArgs], dir); + if (init.code !== 0) throw new Error(`teamai init failed: ${init.output}`); + return dir; + }; + + const worktreeAdd = (repo: string, name: string, extra: Record = {}): { dir: string } & Run => { + const dir = path.join(sandbox, name); + return { dir, ...git(['worktree', 'add', '-q', dir], repo, extra) }; + }; + + const delivered = (dir: string) => ({ + skill: fs.existsSync(path.join(dir, '.claude', 'skills', 'team-skill', 'SKILL.md')), + agent: fs.existsSync(path.join(dir, '.claude', 'agents', 'team-agent.md')), + hook: fs.existsSync(path.join(home, '.claude', 'settings.json')) + && fs.readFileSync(path.join(home, '.claude', 'settings.json'), 'utf8').includes(`echo team-hook-v1`) + && fs.readFileSync(path.join(home, '.claude', 'settings.json'), 'utf8').includes(dir), + rule: fs.existsSync(path.join(dir, '.claude', 'rules', 'team-rule.md')), + mcp: fs.existsSync(path.join(dir, '.mcp.json')) && fs.readFileSync(path.join(dir, '.mcp.json'), 'utf8').includes('team-api'), + }); + const ALL = { skill: true, agent: true, hook: true, rule: true, mcp: true }; + const NOTHING = { skill: false, agent: false, hook: false, rule: false, mcp: false }; + + /** The project partition (data home) `init` created for the repo at `root`. */ + const partitionOf = (root: string): string => { + const projectsDir = path.join(home, '.teamai', 'projects'); + const found = fs.readdirSync(projectsDir).map((d) => path.join(projectsDir, d)).find((d) => { + const config = path.join(d, 'config.yaml'); + return fs.existsSync(config) && fs.readFileSync(config, 'utf8').includes(`projectRoot: ${root}`); + }); + if (!found) throw new Error(`No project partition for ${root}`); + return found; + }; + + /** Entries the worktree has beyond what the branch tracks. */ + const untracked = (dir: string) => + fs.readdirSync(dir).filter((entry) => entry !== '.git' && entry !== '.gitignore').sort(); + + beforeAll(() => { + if (!fs.existsSync(CLI)) throw new Error(`CLI binary not found at ${CLI}. Run "npm run build" first.`); + + sandbox = fs.realpathSync.native(fs.mkdtempSync(path.join(os.tmpdir(), 'teamai-git-hook-e2e-'))); + detached = trackDetachedProcesses(sandbox); + home = path.join(sandbox, 'home'); + remote = path.join(sandbox, 'team.git'); + const seed = path.join(sandbox, 'seed'); + fs.mkdirSync(home); + const write = (rel: string, content: string) => { + fs.mkdirSync(path.dirname(path.join(seed, rel)), { recursive: true }); + fs.writeFileSync(path.join(seed, rel), content); + }; + write('teamai.yaml', `team: git-hook-e2e\nrepo: ${FAKE_URL}\nprovider: git\nreviewers: []\nsharing:\n mcp:\n autoApply: true\n hooks:\n autoApply: true\n requireTeamScripts: false\n`); + write('skills/team-skill/SKILL.md', '---\nname: team-skill\ndescription: Team skill fixture\n---\n\n# Team skill\n'); + write('rules/team-rule.md', '# Team rule\n'); + write('agents/team-agent.yaml', 'name: team-agent\ndescription: Startup agent fixture\ninstructions: Team agent v1\n'); + write('hooks/hooks.yaml', 'hooks:\n - id: startup-guard\n description: Startup hook fixture\n event: SessionStart\n command: echo team-hook-v1\n'); + write('mcp/mcp.yaml', 'servers:\n - name: team-api\n transport: http\n url: https://team.example.com/mcp\n'); + gitOk(['init', '-q', '-b', 'main'], seed); + gitOk(['add', '-A'], seed); + gitOk(['commit', '-q', '-m', 'seed'], seed); + gitOk(['clone', '-q', '--bare', seed, remote], sandbox); + gitOk(['config', '--global', `url.${remote}.insteadOf`, FAKE_URL], sandbox); + + claudeProject = project('claude-project', ['--agent', 'claude']); + }, 60_000); + + afterAll(async () => { + // The parent hook exits before its child finishes. Join every child before + // deleting HOME; a moment without a sync lock does not mean it has exited. + if (detached) await detached.waitForExit(); + if (sandbox) fs.rmSync(sandbox, { recursive: true, force: true }); + }, 65_000); + + it('init installs one named hook per git event in the repository config', () => { + expect(gitOk(['hook', 'list', 'post-checkout'], claudeProject)).toBe('teamai-post-checkout'); + expect(gitOk(['hook', 'list', 'post-merge'], claudeProject)).toBe('teamai-post-merge'); + }); + + it('delivers skills, agents, rules, MCP and team hooks for enabledAgents before git worktree add returns, silently', () => { + const wt = worktreeAdd(claudeProject, 'wt-claude'); + + expect(wt.code).toBe(0); + expect(wt.output).toBe(''); + expect(delivered(wt.dir)).toEqual(ALL); + expect(fs.existsSync(path.join(wt.dir, '.codex'))).toBe(false); + }); + + it('does the same for a worktree an app creates from a script, without a login PATH or a session', () => { + // Only git on PATH: teamai is found through the wrapper in ~/.teamai/bin. + const onlyGit = path.join(sandbox, 'only-git'); + fs.mkdirSync(onlyGit, { recursive: true }); + const realGit = execFileSync('sh', ['-c', 'command -v git'], { encoding: 'utf8' }).trim(); + if (!fs.existsSync(path.join(onlyGit, 'git'))) fs.symlinkSync(realGit, path.join(onlyGit, 'git')); + const dir = path.join(sandbox, 'wt-app'); + const script = path.join(sandbox, 'app.sh'); + fs.writeFileSync(script, `#!/bin/sh\ncd "$1" && git worktree add -q "$2"\n`, { mode: 0o755 }); + + const r = run('/bin/sh', [script, claudeProject, dir], sandbox, { PATH: `${onlyGit}:/usr/bin:/bin` }); + + expect(r.code, r.output).toBe(0); + expect(r.output).toBe(''); + expect(delivered(dir)).toEqual(ALL); + }); + + it('a branch switch in an existing checkout triggers no sync', () => { + const wt = worktreeAdd(claudeProject, 'wt-switch'); + expect(delivered(wt.dir)).toEqual(ALL); + fs.rmSync(path.join(wt.dir, '.claude'), { recursive: true, force: true }); + + const r = git(['checkout', '-q', '-b', 'feature-switch'], wt.dir); + + expect(r.code, r.output).toBe(0); + expect(r.output).toBe(''); + expect(fs.existsSync(path.join(wt.dir, '.claude'))).toBe(false); + }); + + it('runs beside an existing .git/hooks/post-checkout script', () => { + const marker = path.join(sandbox, 'hooks-dir-ran'); + const hookFile = path.join(claudeProject, '.git', 'hooks', 'post-checkout'); + fs.writeFileSync(hookFile, `#!/bin/sh\necho "$1" > "${marker}"\n`, { mode: 0o755 }); + try { + const wt = worktreeAdd(claudeProject, 'wt-hooks-dir'); + + expect(wt.code, wt.output).toBe(0); + expect(fs.readFileSync(marker, 'utf8').trim()).toBe(ZERO_OID); + expect(delivered(wt.dir)).toEqual(ALL); + } finally { + fs.rmSync(hookFile, { force: true }); + } + }); + + it('runs beside a core.hooksPath hook manager', () => { + const marker = path.join(sandbox, 'hooks-path-ran'); + const managerDir = path.join(sandbox, 'manager-hooks'); + fs.mkdirSync(managerDir, { recursive: true }); + fs.writeFileSync(path.join(managerDir, 'post-checkout'), `#!/bin/sh\necho "$1" > "${marker}"\n`, { mode: 0o755 }); + gitOk(['config', '--local', 'core.hooksPath', managerDir], claudeProject); + try { + const wt = worktreeAdd(claudeProject, 'wt-hooks-path'); + + expect(wt.code, wt.output).toBe(0); + expect(fs.readFileSync(marker, 'utf8').trim()).toBe(ZERO_OID); + expect(delivered(wt.dir)).toEqual(ALL); + } finally { + gitOk(['config', '--local', '--unset', 'core.hooksPath'], claudeProject); + } + }); + + it('prints nothing and exits 0 when the pull fails', () => { + const away = `${remote}.away`; + fs.renameSync(remote, away); + try { + const wt = worktreeAdd(claudeProject, 'wt-offline'); + + expect(wt.code).toBe(0); + expect(wt.output).toBe(''); + expect(fs.existsSync(path.join(wt.dir, '.gitignore'))).toBe(true); + } finally { + fs.renameSync(away, remote); + } + }); + + it('an unreadable project config means no sync', () => { + const config = path.join(partitionOf(claudeProject), 'config.yaml'); + const original = fs.readFileSync(config, 'utf8'); + fs.writeFileSync(config, 'repo: [unclosed\n'); + try { + const wt = worktreeAdd(claudeProject, 'wt-unreadable'); + + expect(wt.code).toBe(0); + expect(wt.output).toBe(''); + expect(fs.existsSync(path.join(wt.dir, '.claude'))).toBe(false); + } finally { + fs.writeFileSync(config, original); + } + }); + + it('a repository without teamai config does nothing with a hook left behind', () => { + const other = path.join(sandbox, 'no-teamai'); + fs.mkdirSync(other); + gitOk(['init', '-q', '-b', 'main'], other); + gitOk(['commit', '-q', '--allow-empty', '-m', 'init'], other); + gitOk(['config', '--local', 'hook.teamai-post-checkout.command', + gitOk(['config', '--local', '--get', 'hook.teamai-post-checkout.command'], claudeProject)], other); + gitOk(['config', '--local', 'hook.teamai-post-checkout.event', 'post-checkout'], other); + fs.mkdirSync(path.join(other, '.claude')); + + const wt = worktreeAdd(other, 'wt-no-teamai'); + + expect(wt.code).toBe(0); + expect(wt.output).toBe(''); + expect(untracked(wt.dir)).toEqual([]); + }); + + it('a worktree teamai creates under its own data home is left alone', () => { + const dir = path.join(partitionOf(claudeProject), 'own-worktree'); + + const r = git(['worktree', 'add', '-q', '--detach', dir], claudeProject); + + expect(r.code, r.output).toBe(0); + expect(fs.existsSync(path.join(dir, '.claude'))).toBe(false); + }); + + it('clears the repository Git exports to the hook before running git', () => { + // A worktree created with the hook off, then the dispatcher run the way git + // runs it for `git --git-dir=... worktree add`: GIT_DIR names the business + // repo, and the team clone's pull must not act on it. + const dir = path.join(sandbox, 'wt-git-dir'); + gitOk(['-c', 'hook.teamai-post-checkout.enabled=false', 'worktree', 'add', '-q', dir], claudeProject); + expect(delivered(dir)).toEqual(NOTHING); + const head = gitOk(['rev-parse', 'HEAD'], dir); + + const r = teamai(['hook-dispatch', 'post-checkout', '--tool', 'git', ZERO_OID, head, '1'], dir, { + GIT_DIR: path.join(claudeProject, '.git'), + GIT_WORK_TREE: claudeProject, + }); + + expect(r.code).toBe(0); + expect(r.output).toBe(''); + expect(delivered(dir)).toEqual(ALL); + }); + + const stampOf = (root: string) => path.join(partitionOf(root), 'last-fetch.json'); + /** Commit a new skill to the team remote; returns its name. */ + const pushSkill = (name: string): void => { + const work = path.join(sandbox, `push-${name}`); + gitOk(['clone', '-q', remote, work], sandbox); + fs.mkdirSync(path.join(work, 'skills', name), { recursive: true }); + fs.writeFileSync(path.join(work, 'skills', name, 'SKILL.md'), `---\nname: ${name}\ndescription: ${name}\n---\n`); + gitOk(['add', '-A'], work); + gitOk(['commit', '-q', '-m', name], work); + gitOk(['push', '-q', 'origin', 'HEAD:main'], work); + }; + const hasSkill = (dir: string, name: string) => fs.existsSync(path.join(dir, '.claude', 'skills', name, 'SKILL.md')); + const waitFor = async (check: () => boolean, ms = 30_000) => { + const end = Date.now() + ms; + while (Date.now() < end) { + if (check()) return true; + await new Promise((r) => setTimeout(r, 200)); + } + return check(); + }; + /** + * Wait for the detached pulls of the project to finish: no partition lock + * for a full second (one may not have taken it yet). + */ + const settle = async (root: string) => { + const lock = path.join(partitionOf(root), '.sync-lock'); + let freeSince = Date.now(); + await waitFor(() => { + if (fs.existsSync(lock)) freeSince = Date.now(); + return Date.now() - freeSince >= 1000; + }); + }; + + describe('inline pass reads the team clone, the detached pull fetches the rest', () => { + it('a clone fetched within the TTL: no fetch while the hook runs; the detached pull fetches afterwards', async () => { + const repo = project('fresh-project', ['--agent', 'claude']); + await settle(repo); + expect(fs.existsSync(stampOf(repo))).toBe(true); + pushSkill('late-skill'); + const trace = path.join(sandbox, 'fresh-trace.log'); + + const wt = worktreeAdd(repo, 'wt-fresh', { GIT_TRACE: trace }); + + expect(wt.code, wt.output).toBe(0); + expect(wt.output).toBe(''); + expect(delivered(wt.dir)).toEqual(ALL); + expect(hasSkill(wt.dir, 'late-skill')).toBe(false); + const traced = fs.readFileSync(trace, 'utf8'); + expect(traced).toMatch(/hook-dispatch post-checkout/); + expect(traced).not.toMatch(/\b(fetch|upload-pack|pull)\b/); + + expect(await waitFor(() => hasSkill(wt.dir, 'late-skill'))).toBe(true); + await settle(repo); + }); + + it('a clone fetched more than 24 h ago: the hook fetches the team repo before delivering', async () => { + const repo = project('stale-project', ['--agent', 'claude']); + await settle(repo); + fs.writeFileSync(stampOf(repo), JSON.stringify({ lastFetch: new Date(Date.now() - 25 * 3600_000).toISOString() })); + pushSkill('stale-skill'); + + const wt = worktreeAdd(repo, 'wt-stale'); + + expect(wt.code, wt.output).toBe(0); + expect(hasSkill(wt.dir, 'stale-skill')).toBe(true); + await settle(repo); + }); + + it('delivers what a full pull delivers at the same team revision', async () => { + const repo = project('equal-project', ['--agent', 'claude']); + await settle(repo); + const tree = (dir: string): string[] => { + const out: string[] = []; + const walk = (rel: string) => { + for (const e of fs.readdirSync(path.join(dir, rel), { withFileTypes: true })) { + const r = path.join(rel, e.name); + if (e.isDirectory()) walk(r); + else out.push(`${r}:${fs.readFileSync(path.join(dir, r), 'utf8')}`); + } + }; + for (const top of ['.claude', '.mcp.json']) { + if (!fs.existsSync(path.join(dir, top))) continue; + if (fs.statSync(path.join(dir, top)).isDirectory()) walk(top); + else out.push(`${top}:${fs.readFileSync(path.join(dir, top), 'utf8')}`); + } + return out.sort(); + }; + const hooked = worktreeAdd(repo, 'wt-equal-hook'); + const snapshot = tree(hooked.dir); + await settle(repo); + const manual = path.join(sandbox, 'wt-equal-pull'); + gitOk(['-c', 'hook.teamai-post-checkout.enabled=false', 'worktree', 'add', '-q', manual], repo); + fs.mkdirSync(path.join(manual, '.claude')); // the root the hook creates; pull skips a tool without one + const pulled = teamai(['pull', '--silent'], manual); + expect(pulled.code, pulled.output).toBe(0); + + expect(snapshot.length).toBeGreaterThan(0); + expect(snapshot).toEqual(tree(manual)); + }); + }); + + describe('git pull (post-merge) brings the team\'s change before the next session', () => { + /** Give `root` an origin a teammate pushes to; returns a function that pushes one commit. */ + const withOrigin = (root: string): (() => void) => { + const bare = `${root}.git`; + gitOk(['clone', '-q', '--bare', root, bare], sandbox); + gitOk(['remote', 'add', 'origin', bare], root); + gitOk(['fetch', '-q', 'origin'], root); + gitOk(['branch', '-q', '-u', 'origin/main'], root); + const mate = `${root}-mate`; + gitOk(['clone', '-q', bare, mate], sandbox); + let n = 0; + return () => { + fs.writeFileSync(path.join(mate, `change-${++n}.txt`), `${n}\n`); + gitOk(['add', '-A'], mate); + gitOk(['commit', '-q', '-m', `change ${n}`], mate); + gitOk(['push', '-q', 'origin', 'HEAD:main'], mate); + }; + }; + + it('separate team repo: published resources are delivered before git pull returns', async () => { + const repo = project('merge-project', ['--agent', 'claude']); + const businessChange = withOrigin(repo); + await settle(repo); + pushSkill('merged-skill'); + const teamChange = path.join(sandbox, 'push-merged-skill'); + fs.writeFileSync(path.join(teamChange, 'agents', 'team-agent.yaml'), 'name: team-agent\ndescription: Startup agent fixture\ninstructions: Team agent v2\n'); + fs.writeFileSync(path.join(teamChange, 'rules', 'team-rule.md'), '# Team rule v2\n'); + fs.writeFileSync(path.join(teamChange, 'mcp', 'mcp.yaml'), 'servers:\n - name: team-api\n transport: http\n url: https://team-v2.example.com/mcp\n'); + fs.writeFileSync(path.join(teamChange, 'hooks', 'hooks.yaml'), 'hooks:\n - id: startup-guard\n description: Startup hook fixture\n event: SessionStart\n command: echo team-hook-v2\n'); + gitOk(['add', '-A'], teamChange); + gitOk(['commit', '-q', '-m', 'update all startup resources'], teamChange); + gitOk(['push', '-q', 'origin', 'HEAD:main'], teamChange); + businessChange(); + + const r = git(['pull', '-q'], repo); + + expect(r.code, r.output).toBe(0); + expect(r.output).toBe(''); + expect(fs.existsSync(path.join(repo, 'change-1.txt'))).toBe(true); + expect(hasSkill(repo, 'merged-skill')).toBe(true); + expect(fs.readFileSync(path.join(repo, '.claude', 'agents', 'team-agent.md'), 'utf8')).toContain('Team agent v2'); + expect(fs.readFileSync(path.join(repo, '.claude', 'rules', 'team-rule.md'), 'utf8')).toContain('Team rule v2'); + expect(fs.readFileSync(path.join(repo, '.mcp.json'), 'utf8')).toContain('https://team-v2.example.com/mcp'); + expect(fs.readFileSync(path.join(home, '.claude', 'settings.json'), 'utf8')).toContain('echo team-hook-v2'); + await settle(repo); + }); + + it('separate team repo unreachable: git pull returns within the cap and exits as git would', async () => { + const repo = project('hang-project', ['--agent', 'claude']); + const businessChange = withOrigin(repo); + await settle(repo); + businessChange(); + // A team remote that accepts the connection and never answers. + const sockets: net.Socket[] = []; + const server = net.createServer((socket) => { sockets.push(socket); }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + const port = (server.address() as net.AddressInfo).port; + const rewrite = `url.${remote}.insteadOf`; + gitOk(['config', '--global', '--unset', rewrite], sandbox); + gitOk(['config', '--global', `url.http://127.0.0.1:${port}/team.git.insteadOf`, FAKE_URL], sandbox); + try { + const started = Date.now(); + const r = git(['pull', '-q'], repo); + const elapsed = Date.now() - started; + + expect(r.code, r.output).toBe(0); + expect(r.output).toBe(''); + expect(fs.existsSync(path.join(repo, 'change-1.txt'))).toBe(true); + expect(elapsed).toBeGreaterThanOrEqual(4_000); // the fetch really hung until the cap + expect(elapsed).toBeLessThan(10_000); + } finally { + gitOk(['config', '--global', '--unset', `url.http://127.0.0.1:${port}/team.git.insteadOf`], sandbox); + gitOk(['config', '--global', rewrite, FAKE_URL], sandbox); + server.close(); + for (const socket of sockets) socket.destroy(); + } + await settle(repo); + }); + + it('self mode: git pull brings a changed rule into the checkout with no network from the hook', async () => { + const repo = path.join(sandbox, 'self-project'); + fs.mkdirSync(repo); + fs.writeFileSync(path.join(repo, 'README'), 'x\n'); + gitOk(['init', '-q', '-b', 'main'], repo); + gitOk(['add', '-A'], repo); + gitOk(['commit', '-q', '-m', 'project'], repo); + // init parses the origin as a provider URL; the pull itself uses a local remote. + gitOk(['remote', 'add', 'origin', 'http://127.0.0.1:9/team/self.git'], repo); + const init = teamai(['init', '--self', '--agent', 'claude', '--force'], repo); + expect(init.code, init.output).toBe(0); + gitOk(['remote', 'remove', 'origin'], repo); + withOrigin(repo); + const mate = `${repo}-mate`; + fs.mkdirSync(path.join(mate, '.teamai', 'rules'), { recursive: true }); + fs.writeFileSync(path.join(mate, '.teamai', 'rules', 'self-rule.md'), '# Self rule\n'); + gitOk(['add', '-A'], mate); + gitOk(['commit', '-q', '-m', 'rule'], mate); + gitOk(['push', '-q', 'origin', 'HEAD:main'], mate); + const trace = path.join(sandbox, 'self-trace.log'); + + const r = git(['pull', '-q'], repo, { GIT_TRACE: trace }); + + expect(r.code, r.output).toBe(0); + expect(r.output).toBe(''); + expect(fs.readFileSync(path.join(repo, '.claude', 'rules', 'self-rule.md'), 'utf8')).toContain('# Self rule'); + const traced = fs.readFileSync(trace, 'utf8'); + const fromHook = traced.slice(traced.indexOf('hook-dispatch post-merge')); + expect(fromHook).toMatch(/hook-dispatch post-merge/); + expect(fromHook).not.toMatch(/\b(fetch|upload-pack|ls-remote|push|pull)\b/); + }); + }); + + describe('a failure inside the hook is visible', () => { + /** Make the next post-checkout fetch the team repo, and fail doing it (with the detached retry). */ + const failNextHook = (repo: string): (() => void) => { + fs.writeFileSync(stampOf(repo), JSON.stringify({ lastFetch: new Date(Date.now() - 25 * 3600_000).toISOString() })); + const away = `${remote}.away`; + fs.renameSync(remote, away); + return () => fs.renameSync(away, remote); + }; + + it('doctor reports the hook installed, and missing with the reason', () => { + const repo = project('doctor-hook-project', ['--agent', 'claude']); + expect(teamai(['doctor'], repo).output).toContain('✔ Git hook syncs new worktrees and git pull'); + + gitOk(['config', '--local', '--unset', 'hook.teamai-post-checkout.command'], repo); + const missing = teamai(['doctor'], repo).output; + + expect(missing).toContain('✖ Git hook syncs new worktrees and git pull'); + expect(missing).toMatch(/not in this repository's git config.*Run `teamai pull`/); + }); + + it('git worktree add exits 0; doctor names the failure; the next interactive pull mentions it once', async () => { + const repo = project('fail-project', ['--agent', 'claude']); + await settle(repo); + const restore = failNextHook(repo); + let wt: { dir: string } & Run; + try { + wt = worktreeAdd(repo, 'wt-fail'); + expect(wt.code).toBe(0); + expect(wt.output).toBe(''); + await settle(repo); + } finally { + restore(); + } + + const doctor = teamai(['doctor'], repo).output; + expect(doctor).toMatch(/✖ Last git hook run failed: post-checkout could not fetch the team repo/); + expect(doctor).toMatch(/→ .*teamai pull/); + + const first = teamai(['pull'], wt.dir); + expect(first.output).toMatch(/Last git hook run failed: post-checkout could not fetch the team repo/); + const second = teamai(['pull'], wt.dir); + expect(second.output).not.toMatch(/git hook run failed/); + expect(teamai(['doctor'], repo).output).toContain('✔ No git hook failure recorded'); + }); + + it('an interactive pull that fails too keeps the recorded failure for doctor', async () => { + const repo = project('fail-again-project', ['--agent', 'claude']); + await settle(repo); + const restore = failNextHook(repo); + try { + const wt = worktreeAdd(repo, 'wt-fail-again'); + await settle(repo); + expect(teamai(['pull'], wt.dir).output).toMatch(/Last git hook run failed/); + } finally { + restore(); + } + expect(teamai(['doctor'], repo).output).toMatch(/✖ Last git hook run failed/); + }); + + it('a partition lock another pull holds: post-merge waits no longer than its cap and records why it skipped', async () => { + const repo = project('locked-project', ['--agent', 'claude']); + const bare = `${repo}.git`; + gitOk(['clone', '-q', '--bare', repo, bare], sandbox); + gitOk(['remote', 'add', 'origin', bare], repo); + gitOk(['fetch', '-q', 'origin'], repo); + gitOk(['branch', '-q', '-u', 'origin/main'], repo); + const mate = `${repo}-mate`; + gitOk(['clone', '-q', bare, mate], sandbox); + fs.writeFileSync(path.join(mate, 'change.txt'), '1\n'); + gitOk(['add', '-A'], mate); + gitOk(['commit', '-q', '-m', 'change'], mate); + gitOk(['push', '-q', 'origin', 'HEAD:main'], mate); + await settle(repo); + // A live holder: this test process. + const lock = path.join(partitionOf(repo), '.sync-lock'); + fs.writeFileSync(lock, JSON.stringify({ pid: process.pid, startedAt: new Date().toISOString(), owner: 'e2e' })); + try { + const started = Date.now(); + const r = git(['pull', '-q'], repo); + const elapsed = Date.now() - started; + + expect(r.code, r.output).toBe(0); + expect(r.output).toBe(''); + expect(elapsed).toBeLessThan(15_000); + // The detached pull it hands over to meets the same lock and skips. + await new Promise((resolve) => setTimeout(resolve, 3_000)); + } finally { + fs.rmSync(lock, { force: true }); + } + + const doctor = teamai(['doctor'], repo).output; + expect(doctor).toMatch(/✖ Last git hook run failed: post-merge skipped its sync: another teamai process held the project's sync lock/); + }, 60_000); + + it('a successful hook run clears the recorded failure', async () => { + const repo = project('recover-project', ['--agent', 'claude']); + await settle(repo); + const restore = failNextHook(repo); + try { + worktreeAdd(repo, 'wt-recover-fail'); + await settle(repo); + } finally { + restore(); + } + expect(teamai(['doctor'], repo).output).toMatch(/✖ Last git hook run failed/); + + const wt = worktreeAdd(repo, 'wt-recover-ok'); + expect(wt.code).toBe(0); + await settle(repo); + + expect(teamai(['doctor'], repo).output).toContain('✔ No git hook failure recorded'); + }); + }); + + it('pull --dry-run names a missing hook without writing it; uninstall removes only teamai\'s hooks', async () => { + const repo = project('uninstall-project', ['--agent', 'claude']); + await settle(repo); + gitOk(['config', '--local', 'hook.mine.command', 'echo mine'], repo); + gitOk(['config', '--local', 'hook.mine.event', 'post-checkout'], repo); + gitOk(['config', '--local', '--remove-section', 'hook.teamai-post-checkout'], repo); + const before = gitOk(['config', '--local', '--list'], repo); + + const dry = teamai(['pull', '--dry-run'], repo); + expect(dry.output).toContain('Would install or update the teamai git hook'); + expect(gitOk(['config', '--local', '--list'], repo)).toBe(before); + + const r = teamai(['uninstall', '--force'], repo); + expect(r.code, r.output).toBe(0); + expect(gitOk(['config', '--get-regexp', '^hook\\.'], repo).split('\n')) + .toEqual(['hook.mine.command echo mine', 'hook.mine.event post-checkout']); + }); + + it('with no enabledAgents, creates the tool roots the main checkout has, and only those', () => { + const codexProject = project('codex-project', []); + fs.mkdirSync(path.join(codexProject, '.codex')); + + const wt = worktreeAdd(codexProject, 'wt-codex'); + + expect(wt.code).toBe(0); + expect(wt.output).toBe(''); + expect(fs.statSync(path.join(wt.dir, '.codex')).isDirectory()).toBe(true); + expect(untracked(wt.dir).filter((entry) => entry.startsWith('.') && fs.statSync(path.join(wt.dir, entry)).isDirectory())) + .toEqual(['.codex']); + }); +}); diff --git a/src/__tests__/e2e/init-ends-with-pull.test.ts b/src/__tests__/e2e/init-ends-with-pull.test.ts new file mode 100644 index 000000000..8ae00c881 --- /dev/null +++ b/src/__tests__/e2e/init-ends-with-pull.test.ts @@ -0,0 +1,254 @@ +/** + * E2E: `teamai init` ends with a pull, so the member's first session already + * has the team's skills, rules and MCP servers (sync-before-session, ticket 02). + * + * Claude reads rules and MCP once, at session start, before teamai's + * SessionStart hook can sync. Unless init itself delivers, the first session + * after init runs without them; in project scope nothing at all landed, + * because no tool root existed in the checkout yet. + * + * The team remote is a synthetic HTTPS URL that git's `insteadOf` rewrites to a + * local bare repo in the sandbox HOME (see init-project-all.test.ts), so no + * network is touched. + */ +import { afterAll, beforeAll, beforeEach, 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'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const ROOT = path.resolve(__dirname, '..', '..', '..'); +const CLI = path.join(ROOT, 'dist', 'index.js'); + +const FAKE_URL = 'https://git.example.com/team/team.git'; + +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; +} + +let sandbox: string; +let home: string; +let remote: string; +let binDir: string; + +/** + * A `git` ahead of PATH that reports the synthetic HTTPS origin for + * `remote get-url` (so a re-init reuses the clone; real `insteadOf` expansion + * would make it look like another repo), and with TEST_FAIL_FETCH=1 refuses to + * refresh a clone, as an unreachable remote would. + */ +function writeGitWrapper(): void { + binDir = path.join(sandbox, 'bin'); + fs.mkdirSync(binDir, { recursive: true }); + const realGit = execFileSync('sh', ['-c', 'command -v git'], { encoding: 'utf8' }).trim(); + fs.writeFileSync(path.join(binDir, 'git'), [ + '#!/bin/sh', + 'if [ "$1" = "remote" ] && [ "$2" = "get-url" ]; then', + ` printf '%s\\n' '${FAKE_URL}'; exit 0`, + 'fi', + 'if [ "$TEST_FAIL_FETCH" = "1" ]; then', + ' for arg in "$@"; do', + ' case "$arg" in', + ' fetch|pull) echo "fatal: unable to access remote (test)" >&2; exit 128 ;;', + ' esac', + ' done', + 'fi', + `exec '${realGit}' "$@"`, + '', + ].join('\n'), { mode: 0o755 }); +} + +function git(args: string[], cwd: string, extraEnv: Record = {}): string { + return execFileSync('git', args, { + cwd, + encoding: 'utf8', + env: { ...process.env, ...GIT_ENV, ...extraEnv }, + }).trim(); +} + +function runCLI(args: string[], cwd: string, extraEnv: Record = {}): Promise { + const env: Record = { + ...process.env, + ...GIT_ENV, + HOME: home, + USERPROFILE: home, + PATH: `${binDir}${path.delimiter}${process.env.PATH ?? ''}`, + FORCE_COLOR: '0', + GIT_CONFIG_NOSYSTEM: '1', + ...extraEnv, + }; + // The sandbox HOME decides where tools live; a developer's override must not. + delete env.CLAUDE_CONFIG_DIR; + delete env.CODEX_HOME; + return new Promise((resolve) => { + // stdin ignored: no terminal, so init never prompts. + const child = spawn('node', [CLI, ...args], { cwd, env, stdio: ['ignore', '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 })); + }); +} + +function writeSeed(seed: string, relativePath: string, content: string): void { + const file = path.join(seed, relativePath); + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, content); +} + +/** A git business repo with no tool roots of its own. */ +function makeBusinessRepo(name: string): string { + const dir = path.join(sandbox, name); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, 'README.md'), '# app\n'); + git(['init', '-q', '-b', 'main'], dir); + git(['add', '-A'], dir); + git(['commit', '-q', '-m', 'app'], dir); + return dir; +} + +const TOOL_ROOTS = ['.claude', '.codex', '.cursor', '.codebuddy', '.opencode', '.config']; + +describe.skipIf(process.platform === 'win32')('teamai init ends with a pull', () => { + beforeAll(() => { + if (!fs.existsSync(CLI)) throw new Error(`CLI binary not found at ${CLI}. Run "npm run build" first.`); + sandbox = fs.realpathSync.native(fs.mkdtempSync(path.join(os.tmpdir(), 'teamai-init-pull-e2e-'))); + remote = path.join(sandbox, 'team.git'); + const seed = path.join(sandbox, 'seed'); + + writeSeed(seed, 'teamai.yaml', [ + 'team: init-pull-e2e', + `repo: ${FAKE_URL}`, + 'provider: git', + 'reviewers: []', + 'sharing:', + ' mcp:', + ' autoApply: true', + ' hooks:', + ' autoApply: true', + ' requireTeamScripts: false', + '', + ].join('\n')); + writeSeed(seed, 'skills/team-skill/SKILL.md', + '---\nname: team-skill\ndescription: Team skill fixture\n---\n\n# Team skill\n'); + writeSeed(seed, 'rules/team-rule.md', '# Team rule\n'); + writeSeed(seed, 'agents/team-agent.yaml', 'name: team-agent\ndescription: Startup agent fixture\ninstructions: Team agent v1\n'); + writeSeed(seed, 'hooks/hooks.yaml', 'hooks:\n - id: startup-guard\n description: Startup hook fixture\n event: SessionStart\n command: echo team-hook-v1\n'); + writeSeed(seed, 'mcp/mcp.yaml', [ + 'servers:', + ' - name: team-api', + ' transport: http', + ' url: https://team.example.com/mcp', + '', + ].join('\n')); + git(['init', '-q', '-b', 'main'], seed); + git(['add', '-A'], seed); + git(['commit', '-q', '-m', 'seed'], seed); + git(['clone', '-q', '--bare', seed, remote], sandbox); + writeGitWrapper(); + }); + + beforeEach(() => { + home = path.join(sandbox, `home-${Math.random().toString(36).slice(2)}`); + fs.mkdirSync(home, { recursive: true }); + // Claude and Codex are installed on this machine. + fs.mkdirSync(path.join(home, '.claude'), { recursive: true }); + fs.mkdirSync(path.join(home, '.codex'), { recursive: true }); + git(['config', '--global', `url.${remote}.insteadOf`, FAKE_URL], sandbox, { HOME: home, USERPROFILE: home }); + }); + + afterAll(() => { + if (sandbox) fs.rmSync(sandbox, { recursive: true, force: true }); + }); + + it('user scope: skills, agents, rules, MCP and team hooks are delivered when init exits', async () => { + const cwd = path.join(sandbox, 'elsewhere'); + fs.mkdirSync(cwd, { recursive: true }); + const result = await runCLI(['init', FAKE_URL, '--scope', 'user', '--agent', 'claude', '--force'], cwd); + expect(result.code, result.output).toBe(0); + expect(result.output).toMatch(/teamai initialized successfully/); + + expect(fs.existsSync(path.join(home, '.claude', 'skills', 'team-skill', 'SKILL.md')), result.output).toBe(true); + expect(fs.readFileSync(path.join(home, '.claude', 'rules', 'team-rule.md'), 'utf8')).toContain('Team rule'); + const claudeJson = JSON.parse(fs.readFileSync(path.join(home, '.claude.json'), 'utf8')) as { + mcpServers?: Record; + }; + expect(Object.keys(claudeJson.mcpServers ?? {})).toContain('team-api'); + expect(fs.readFileSync(path.join(home, '.claude', 'agents', 'team-agent.md'), 'utf8')).toContain('Team agent v1'); + expect(fs.readFileSync(path.join(home, '.claude', 'settings.json'), 'utf8')).toContain('echo team-hook-v1'); + }, 90_000); + + it('user scope run inside a project-scoped checkout pulls the user scope it just configured', async () => { + const project = makeBusinessRepo(`app-${Math.random().toString(36).slice(2)}`); + const first = await runCLI(['init', FAKE_URL, '--scope', 'project', '--agent', 'codex', '--force'], project); + expect(first.code, first.output).toBe(0); + + const result = await runCLI(['init', FAKE_URL, '--scope', 'user', '--agent', 'claude', '--force'], project); + expect(result.code, result.output).toBe(0); + expect(fs.existsSync(path.join(home, '.claude', 'skills', 'team-skill', 'SKILL.md')), result.output).toBe(true); + expect(fs.readFileSync(path.join(home, '.claude', 'rules', 'team-rule.md'), 'utf8')).toContain('Team rule'); + }, 120_000); + + it('project scope with --agent claude: .claude/ is created and filled, .mcp.json holds the team server', async () => { + const project = makeBusinessRepo(`app-${Math.random().toString(36).slice(2)}`); + const result = await runCLI(['init', FAKE_URL, '--scope', 'project', '--agent', 'claude', '--force'], project); + expect(result.code, result.output).toBe(0); + expect(result.output).toMatch(/teamai initialized successfully/); + + expect(fs.existsSync(path.join(project, '.claude', 'skills', 'team-skill', 'SKILL.md')), result.output).toBe(true); + expect(fs.readFileSync(path.join(project, '.claude', 'rules', 'team-rule.md'), 'utf8')).toContain('Team rule'); + const mcp = JSON.parse(fs.readFileSync(path.join(project, '.mcp.json'), 'utf8')) as { + mcpServers?: Record; + }; + expect(Object.keys(mcp.mcpServers ?? {})).toContain('team-api'); + expect(fs.readFileSync(path.join(project, '.claude', 'agents', 'team-agent.md'), 'utf8')).toContain('Team agent v1'); + expect(fs.readFileSync(path.join(home, '.claude', 'settings.json'), 'utf8')).toContain('echo team-hook-v1'); + // Only the chosen tool's root, although Codex is installed too. + expect(fs.existsSync(path.join(project, '.codex'))).toBe(false); + }, 90_000); + + it('project scope re-init with another --agent adds that root even if enabledAgents was narrower', async () => { + const project = makeBusinessRepo(`app-${Math.random().toString(36).slice(2)}`); + const first = await runCLI(['init', FAKE_URL, '--scope', 'project', '--agent', 'codex', '--force'], project); + expect(first.code, first.output).toBe(0); + expect(fs.existsSync(path.join(project, '.claude'))).toBe(false); + + const second = await runCLI(['init', FAKE_URL, '--scope', 'project', '--agent', 'claude', '--force'], project); + expect(second.code, second.output).toBe(0); + expect(fs.existsSync(path.join(project, '.claude', 'skills', 'team-skill', 'SKILL.md')), second.output).toBe(true); + }, 120_000); + + it('project scope without a terminal and without --agent: no tool root is created', async () => { + const project = makeBusinessRepo(`app-${Math.random().toString(36).slice(2)}`); + const result = await runCLI(['init', FAKE_URL, '--scope', 'project', '--force'], project); + expect(result.code, result.output).toBe(0); + expect(result.output).toMatch(/teamai initialized successfully/); + for (const root of TOOL_ROOTS) { + expect(fs.existsSync(path.join(project, root)), `${root} was created:\n${result.output}`).toBe(false); + } + }, 90_000); + + it('a pull failure inside init is reported the way pull reports it, and init still succeeds', async () => { + // init's own clone works; the pull that follows cannot refresh the clone. + const cwd = path.join(sandbox, 'elsewhere'); + fs.mkdirSync(cwd, { recursive: true }); + const result = await runCLI( + ['init', FAKE_URL, '--scope', 'user', '--agent', 'claude', '--force'], + cwd, + { TEST_FAIL_FETCH: '1' }, + ); + expect(result.output).toMatch(/\[user\] Pull failed:/); + expect(result.output).toMatch(/teamai initialized successfully/); + expect(result.code, result.output).toBe(0); + }, 90_000); +}); diff --git a/src/__tests__/git-hook.test.ts b/src/__tests__/git-hook.test.ts new file mode 100644 index 000000000..890665d66 --- /dev/null +++ b/src/__tests__/git-hook.test.ts @@ -0,0 +1,323 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { execFileSync, spawnSync } from 'node:child_process'; +import fs from 'node:fs'; +import fse from 'fs-extra'; +import os from 'node:os'; +import path from 'node:path'; + +import { describeMissingGitHook, gitHookStatus, guardedGitHookLine, installGitHook, removeGitHook } from '../git-hook.js'; + +const gitVersion = (): [number, number] => { + const m = /(\d+)\.(\d+)/.exec(execFileSync('git', ['--version'], { encoding: 'utf8' })); + return m ? [Number(m[1]), Number(m[2])] : [0, 0]; +}; +const [major, minor] = gitVersion(); +const configHooks = major > 2 || (major === 2 && minor >= 54); + +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', +}; + +describe.skipIf(!configHooks)('teamai git hook in the repository config', () => { + let sandbox: string; + let repo: string; + let home: string; + + const git = (args: string[], cwd = repo) => + spawnSync('git', args, { cwd, encoding: 'utf8', env: { ...process.env, ...GIT_ENV, HOME: home } }); + + /** A `teamai` on the wrapper path that records its arguments, then exits with `code`. */ + const fakeTeamai = (code: number) => { + const bin = path.join(home, '.teamai', 'bin'); + fs.mkdirSync(bin, { recursive: true }); + fs.writeFileSync( + path.join(bin, 'teamai'), + `#!/bin/sh\necho "$@" >> "${path.join(sandbox, 'calls.txt')}"\necho noise\necho noise >&2\nexit ${code}\n`, + { mode: 0o755 }, + ); + }; + const calls = () => { + const file = path.join(sandbox, 'calls.txt'); + return fs.existsSync(file) ? fs.readFileSync(file, 'utf8').trim().split('\n') : []; + }; + + beforeEach(() => { + sandbox = fs.realpathSync.native(fs.mkdtempSync(path.join(os.tmpdir(), 'teamai-git-hook-'))); + repo = path.join(sandbox, 'repo'); + home = path.join(sandbox, 'home'); + fs.mkdirSync(repo); + fs.mkdirSync(home); + git(['init', '-q', '-b', 'main']); + git(['commit', '-q', '--allow-empty', '-m', 'init']); + }); + + afterEach(() => { + fs.rmSync(sandbox, { recursive: true, force: true }); + }); + + it('registers one named hook per event, shared by every worktree', async () => { + await installGitHook(repo); + + expect(git(['hook', 'list', 'post-checkout']).stdout.trim()).toBe('teamai-post-checkout'); + expect(git(['hook', 'list', 'post-merge']).stdout.trim()).toBe('teamai-post-merge'); + // Written to the common config: a linked worktree sees the same hooks. + git(['worktree', 'add', '-q', path.join(sandbox, 'wt')]); + expect(git(['hook', 'list', 'post-checkout'], path.join(sandbox, 'wt')).stdout.trim()).toBe('teamai-post-checkout'); + }); + + it('removal drops only teamai\'s hook entries; a dry run reports them and writes nothing', async () => { + git(['config', '--local', 'hook.mine.command', 'echo mine']); + git(['config', '--local', 'hook.mine.event', 'post-checkout']); + expect(await installGitHook(repo, { dryRun: true })).toEqual({ installed: true, changed: true }); + expect(git(['config', '--get-regexp', '^hook\\.teamai']).stdout).toBe(''); + await installGitHook(repo); + + const planned = await removeGitHook(repo, { dryRun: true }); + expect(planned).toEqual(['hook.teamai-post-checkout', 'hook.teamai-post-merge']); + expect(git(['config', '--get-regexp', '^hook\\.teamai']).stdout).not.toBe(''); + + expect(await removeGitHook(repo)).toEqual(planned); + expect(git(['config', '--get-regexp', '^hook\\.']).stdout.trim().split('\n')) + .toEqual(['hook.mine.command echo mine', 'hook.mine.event post-checkout']); + }); + + it('is idempotent', async () => { + await installGitHook(repo); + await installGitHook(repo); + + expect(git(['config', '--local', '--get-all', 'hook.teamai-post-checkout.event']).stdout.trim()).toBe('post-checkout'); + expect(git(['config', '--local', '--get-all', 'hook.teamai-post-merge.event']).stdout.trim()).toBe('post-merge'); + }); + + it('passes the event and Git\'s arguments to the dispatcher, silently', async () => { + await installGitHook(repo); + fakeTeamai(0); + + const r = git(['hook', 'run', 'post-checkout', '--', 'old', 'new', '1']); + + expect(r.status).toBe(0); + expect(r.stdout + r.stderr).toBe(''); + expect(calls()).toEqual(['hook-dispatch post-checkout --tool git old new 1']); + }); + + it('exits 0 and prints nothing when the dispatcher fails or teamai is missing', async () => { + await installGitHook(repo); + fakeTeamai(3); + const failing = git(['hook', 'run', 'post-merge', '--', '0']); + expect(failing.status).toBe(0); + expect(failing.stdout + failing.stderr).toBe(''); + expect(calls()).toEqual(['hook-dispatch post-merge --tool git 0']); + + // Only git on PATH, so no globally installed teamai can answer. + fs.rmSync(path.join(home, '.teamai'), { recursive: true, force: true }); + const onlyGit = path.join(sandbox, 'only-git'); + fs.mkdirSync(onlyGit); + const realGit = execFileSync('sh', ['-c', 'command -v git'], { encoding: 'utf8' }).trim(); + fs.symlinkSync(realGit, path.join(onlyGit, 'git')); + const missing = spawnSync(realGit, ['hook', 'run', 'post-merge', '--', '0'], { + cwd: repo, + encoding: 'utf8', + env: { ...process.env, HOME: home, PATH: `${onlyGit}:/usr/bin:/bin` }, + }); + expect(missing.status).toBe(0); + expect(missing.stdout + missing.stderr).toBe(''); + }); +}); + +describe('teamai hook script on a Git without config hooks', () => { + let sandbox: string; + let repo: string; + let home: string; + let saved: { PATH?: string; HOME?: string }; + + const run = (args: string[], cwd = repo) => + spawnSync('git', args, { cwd, encoding: 'utf8', env: { ...process.env, ...GIT_ENV } }); + const hookFile = (event: string) => path.join(repo, '.git', 'hooks', event); + const calls = () => { + const file = path.join(sandbox, 'calls.txt'); + return fs.existsSync(file) ? fs.readFileSync(file, 'utf8').trim().split('\n') : []; + }; + + beforeEach(() => { + sandbox = fs.realpathSync.native(fs.mkdtempSync(path.join(os.tmpdir(), 'teamai-git-hook-old-'))); + repo = path.join(sandbox, 'repo'); + home = path.join(sandbox, 'home'); + fs.mkdirSync(repo); + fs.mkdirSync(home); + // Old Git, simulated where teamai reads the version: a `git` on PATH that + // reports 2.39 and hands every other command to the real one. + const realGit = execFileSync('sh', ['-c', 'command -v git'], { encoding: 'utf8' }).trim(); + const shim = path.join(sandbox, 'old-git'); + fs.mkdirSync(shim); + fs.writeFileSync( + path.join(shim, 'git'), + `#!/bin/sh\nif [ "$1" = --version ]; then echo "git version 2.39.5"; exit 0; fi\nexec "${realGit}" "$@"\n`, + { mode: 0o755 }, + ); + saved = { PATH: process.env.PATH, HOME: process.env.HOME }; + process.env.PATH = `${shim}${path.delimiter}${process.env.PATH}`; + process.env.HOME = home; + run(['init', '-q', '-b', 'main']); + run(['commit', '-q', '--allow-empty', '-m', 'init']); + }); + + afterEach(() => { + process.env.PATH = saved.PATH; + process.env.HOME = saved.HOME; + fs.rmSync(sandbox, { recursive: true, force: true }); + }); + + const fakeTeamai = () => { + fs.mkdirSync(path.join(home, '.teamai', 'bin'), { recursive: true }); + fs.writeFileSync( + path.join(home, '.teamai', 'bin', 'teamai'), + `#!/bin/sh\necho "$@" >> "${path.join(sandbox, 'calls.txt')}"\necho noise\nexit 3\n`, + { mode: 0o755 }, + ); + }; + + it('adds a marked block to an existing hook script without changing its other lines', async () => { + const original = '#!/bin/sh\n# the team\'s own hook\necho mine >> "$0.log"\n'; + fs.writeFileSync(hookFile('post-checkout'), original, { mode: 0o755 }); + + expect(await installGitHook(repo)).toEqual({ installed: true, changed: true }); + const text = fs.readFileSync(hookFile('post-checkout'), 'utf8'); + const block = /# >>> teamai[^\n]*\n[\s\S]*?# <<< teamai[^\n]*\n/.exec(text); + expect(block).not.toBeNull(); + expect(text.replace(block![0], '')).toBe(original); + // A second run leaves it alone. + expect(await installGitHook(repo)).toEqual({ installed: true, changed: false }); + expect(fs.readFileSync(hookFile('post-checkout'), 'utf8')).toBe(text); + // post-merge had no script: a new executable one. + expect(fs.statSync(hookFile('post-merge')).mode & 0o111).not.toBe(0); + expect(await gitHookStatus(repo)).toEqual({ installed: true }); + }); + + it('runs the dispatcher with Git\'s arguments, silently, keeping the script\'s own work and exit status', async () => { + fs.writeFileSync(hookFile('post-checkout'), '#!/bin/sh\necho mine >> "$0.log"\n', { mode: 0o755 }); + await installGitHook(repo); + fakeTeamai(); + + const r = run(['worktree', 'add', '-q', path.join(sandbox, 'wt')]); + + expect(r.status).toBe(0); + expect(r.stdout + r.stderr).toBe(''); + expect(calls()).toHaveLength(1); + expect(calls()[0]).toMatch(/^hook-dispatch post-checkout --tool git 0+ [0-9a-f]{40} 1$/); + expect(fs.readFileSync(`${hookFile('post-checkout')}.log`, 'utf8')).toBe('mine\n'); + }); + + it('does not replace an existing hook when reading it fails', async () => { + const file = hookFile('post-checkout'); + const original = '#!/bin/sh\necho owner-hook-content\n'; + fs.writeFileSync(file, original, { mode: 0o755 }); + const read = fse.readFile.bind(fse); + const spy = vi.spyOn(fse, 'readFile').mockImplementation(((...args: Parameters) => { + if (args[0] === file) return Promise.reject(Object.assign(new Error('permission denied'), { code: 'EACCES' })); + return read(...args); + }) as typeof read); + try { + await expect(installGitHook(repo)).rejects.toThrow('permission denied'); + expect(fs.readFileSync(file, 'utf8')).toBe(original); + expect(fs.existsSync(hookFile('post-merge'))).toBe(false); + } finally { + spy.mockRestore(); + } + }); + + it.skipIf(process.platform === 'win32')('leaves a disabled owner hook and its mode untouched', async () => { + const file = hookFile('post-checkout'); + const original = '#!/bin/sh\nexit 42\n'; + fs.writeFileSync(file, original, { mode: 0o644 }); + expect(await installGitHook(repo)).toEqual({ installed: false, reason: 'other-hook' }); + expect(fs.readFileSync(file, 'utf8')).toBe(original); + expect(fs.statSync(file).mode & 0o777).toBe(0o644); + expect(fs.existsSync(hookFile('post-merge'))).toBe(false); + expect(run(['worktree', 'add', '-q', path.join(sandbox, 'disabled-wt')]).status).toBe(0); + }); + + it.skipIf(process.platform === 'win32')('leaves a symlinked hook and the script it points to untouched', async () => { + const shared = path.join(sandbox, 'shared-post-checkout'); + const original = '#!/bin/sh\nexit 0\n'; + fs.writeFileSync(shared, original, { mode: 0o755 }); + fs.symlinkSync(shared, hookFile('post-checkout')); + expect(await installGitHook(repo)).toEqual({ installed: false, reason: 'other-hook' }); + expect(fs.readFileSync(shared, 'utf8')).toBe(original); + expect(fs.lstatSync(hookFile('post-checkout')).isSymbolicLink()).toBe(true); + expect(fs.existsSync(hookFile('post-merge'))).toBe(false); + }); + + it.skipIf(process.platform === 'win32')('preserves the permissions of an executable owner hook', async () => { + fs.writeFileSync(hookFile('post-checkout'), '#!/bin/sh\nexit 0\n', { mode: 0o700 }); + await installGitHook(repo); + expect(fs.statSync(hookFile('post-checkout')).mode & 0o777).toBe(0o700); + }); + + it('leaves a core.hooksPath manager\'s files alone, and doctor advises upgrading or a guarded line', async () => { + const managed = path.join(sandbox, 'managed'); + fs.mkdirSync(managed); + fs.writeFileSync(path.join(managed, 'post-checkout'), '#!/bin/sh\nexit 0\n', { mode: 0o755 }); + run(['config', 'core.hooksPath', managed]); + + expect(await installGitHook(repo)).toEqual({ installed: false, reason: 'hooks-path' }); + expect(fs.readdirSync(managed)).toEqual(['post-checkout']); + expect(fs.readFileSync(path.join(managed, 'post-checkout'), 'utf8')).toBe('#!/bin/sh\nexit 0\n'); + expect(fs.existsSync(hookFile('post-checkout'))).toBe(false); + + const status = await gitHookStatus(repo); + expect(status).toMatchObject({ installed: false, reason: 'hooks-path' }); + const advice = describeMissingGitHook(status as Exclude); + const upgrade = advice.indexOf('Upgrade Git'); + const guarded = advice.indexOf('command -v teamai >/dev/null 2>&1 && teamai hook-dispatch post-checkout --tool git "$@"'); + expect(upgrade).toBeGreaterThan(-1); + expect(guarded).toBeGreaterThan(upgrade); + expect(advice).toContain('teamai hook-dispatch post-merge --tool git "$@"'); + expect(advice).toContain('sh -c'); + }); + + it('a dry run reports the change without writing the hook', async () => { + expect(await installGitHook(repo, { dryRun: true })).toEqual({ installed: true, changed: true }); + expect(fs.existsSync(hookFile('post-checkout'))).toBe(false); + expect(fs.existsSync(hookFile('post-merge'))).toBe(false); + }); + + it('removal takes out only the block, and the script teamai created', async () => { + const original = '#!/bin/sh\necho mine >> "$0.log"\n'; + fs.writeFileSync(hookFile('post-checkout'), original, { mode: 0o755 }); + await installGitHook(repo); + + const planned = await removeGitHook(repo, { dryRun: true }); + expect(planned).toHaveLength(2); + expect(fs.readFileSync(hookFile('post-checkout'), 'utf8')).not.toBe(original); + + expect(await removeGitHook(repo)).toEqual(planned); + expect(fs.readFileSync(hookFile('post-checkout'), 'utf8')).toBe(original); + expect(fs.existsSync(hookFile('post-merge'))).toBe(false); + expect(await removeGitHook(repo)).toEqual([]); + }); + + it.skipIf(!configHooks)('after a Git upgrade, the config hook replaces the block (no double dispatch)', async () => { + const original = '#!/bin/sh\necho mine >> "$0.log"\n'; + fs.writeFileSync(hookFile('post-checkout'), original, { mode: 0o755 }); + await installGitHook(repo); + process.env.PATH = saved.PATH; + + expect(await installGitHook(repo)).toEqual({ installed: true, changed: true }); + expect(fs.readFileSync(hookFile('post-checkout'), 'utf8')).toBe(original); + expect(fs.existsSync(hookFile('post-merge'))).toBe(false); + expect(run(['hook', 'list', 'post-checkout']).stdout).toContain('teamai-post-checkout'); + }); + + it('the guarded line does nothing and exits 0 without teamai', () => { + const line = guardedGitHookLine('post-checkout'); + const r = spawnSync('/bin/sh', ['-c', line, 'post-checkout', '0', '1', '1'], { + encoding: 'utf8', + env: { PATH: '/usr/bin:/bin', HOME: home }, + }); + expect(r.status).toBe(0); + expect(r.stdout + r.stderr).toBe(''); + }); +}); diff --git a/src/__tests__/helpers/detached-processes.ts b/src/__tests__/helpers/detached-processes.ts new file mode 100644 index 000000000..03d4d2f5c --- /dev/null +++ b/src/__tests__/helpers/detached-processes.ts @@ -0,0 +1,43 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +/** Track detached children before their parent exits, so a fixture can join them. */ +export function trackDetachedProcesses(root: string): { nodeOptions: string; waitForExit: () => Promise } { + const children = path.join(root, 'detached-children'); + fs.mkdirSync(children); + const preload = path.join(root, 'track-detached.cjs'); + fs.writeFileSync(preload, ` +const fs = require('node:fs'); +const path = require('node:path'); +const cp = require('node:child_process'); +const spawn = cp.spawn; +cp.spawn = function (...args) { + const child = spawn.apply(this, args); + if (args[2]?.detached && child.pid) { + fs.writeFileSync(path.join(${JSON.stringify(children)}, String(child.pid)), ''); + } + return child; +}; +require('node:module').syncBuiltinESMExports(); +`); + return { + nodeOptions: `--require ${JSON.stringify(preload)}`, + async waitForExit() { + const deadline = Date.now() + 60_000; + while (true) { + const pending = fs.readdirSync(children); + if (pending.length === 0) return; + for (const name of pending) { + try { + process.kill(Number(name), 0); + } catch (e) { + if ((e as NodeJS.ErrnoException).code !== 'ESRCH') throw e; + fs.unlinkSync(path.join(children, name)); + } + } + if (Date.now() >= deadline) throw new Error(`Detached fixture processes did not exit: ${fs.readdirSync(children).join(', ')}`); + await new Promise(resolve => setTimeout(resolve, 25)); + } + }, + }; +} diff --git a/src/__tests__/hook-dispatch-cli.test.ts b/src/__tests__/hook-dispatch-cli.test.ts index c86536227..07cdba90d 100644 --- a/src/__tests__/hook-dispatch-cli.test.ts +++ b/src/__tests__/hook-dispatch-cli.test.ts @@ -57,6 +57,25 @@ describe('deriveDispatchSessionId', () => { }); describe('hookDispatchCli', () => { + it('records why a Git hook cannot sync an unreadable project config, without dispatching', async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'git-hook-broken-config-')); + const previousCwd = process.cwd(); + const persist = vi.spyOn(log, 'persist').mockImplementation(() => {}); + try { + execFileSync('git', ['init', '-q'], { cwd: root }); + fs.mkdirSync(path.join(root, '.teamai')); + fs.writeFileSync(path.join(root, '.teamai', 'config.yaml'), 'repo: [invalid'); + process.chdir(root); + mockDispatcher.dispatch.mockClear(); + await hookDispatchCli('post-merge', 'git', '*'); + expect(mockDispatcher.dispatch).not.toHaveBeenCalled(); + expect(persist).toHaveBeenCalledWith(expect.stringMatching(/Nothing was synced:.*config.yaml/s)); + } finally { + process.chdir(previousCwd); + fs.rmSync(root, { recursive: true, force: true }); + persist.mockRestore(); + } + }); it('skips claude hooks only when the other host has its own teamai hooks', async () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), 'host-hooks-')); const home = path.join(root, 'home'); diff --git a/src/__tests__/hook-handlers.test.ts b/src/__tests__/hook-handlers.test.ts index 7ed2fd0a5..7d1d69f9f 100644 --- a/src/__tests__/hook-handlers.test.ts +++ b/src/__tests__/hook-handlers.test.ts @@ -169,6 +169,7 @@ vi.mock('../project-agent-root.js', () => ({ import { buildAdoptedSummary, buildHandlerRegistry, filterHandlersForConfig } from '../hook-handlers.js'; import { createDispatcher } from '../hook-dispatch.js'; +import { GIT_HOOK_EVENTS } from '../git-hook.js'; import type { LocalConfig } from '../types.js'; /** The scope hook-dispatch resolved for the hook's cwd, handed to every handler. */ @@ -234,6 +235,43 @@ describe('hook-handlers registry', () => { expect(sessionStartHandlers).toContain('dashboard-report'); }); + it('caps the self-mode post-merge lock wait at five seconds', async () => { + const handler = buildHandlerRegistry().find(r => r.event === 'post-merge')!.handler; + await handler.execute({ cwd: '/tmp/self-project' }, 'git', { + scope: 'project', projectRoot: '/tmp/self-project', username: 'test', additionalRoles: [], + repo: { kind: 'self', localPath: '/tmp/self-project', remote: '' }, + }); + expect(mockPull).toHaveBeenCalledWith({ silent: true, inline: true, gitHook: 'post-merge', fetchTimeoutMs: 5000 }); + }); + + it('does not sync teamai\'s own knowledge worktree on post-checkout or post-merge', async () => { + const { execFileSync } = await import('node:child_process'); + const main = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'git-hook-own-wt-'))); + const git = (args: string[], cwd: string) => execFileSync('git', args, { + cwd, stdio: 'ignore', + env: { ...process.env, GIT_AUTHOR_NAME: 't', GIT_AUTHOR_EMAIL: 't@t', GIT_COMMITTER_NAME: 't', GIT_COMMITTER_EMAIL: 't@t' }, + }); + try { + git(['init', '-q', '-b', 'main'], main); + git(['commit', '-q', '--allow-empty', '-m', 'init'], main); + const wt = path.join(main, '.teamai', 'knowledge-wt'); + git(['worktree', 'add', '-q', '--detach', wt, 'HEAD'], main); + // As detection resolves it from inside that worktree: its own checkout. + const config = { + scope: 'project', projectRoot: wt, username: 'test', additionalRoles: [], + repo: { kind: 'self', localPath: path.join(wt, '.teamai'), remote: '' }, + } as unknown as LocalConfig; + const registry = buildHandlerRegistry(); + mockPull.mockClear(); + await registry.find(r => r.event === 'post-checkout')!.handler.execute( + { cwd: wt, git_args: ['0'.repeat(40), 'abc', '1'] }, 'git', config); + await registry.find(r => r.event === 'post-merge')!.handler.execute({ cwd: wt, git_args: ['0'] }, 'git', config); + expect(mockPull).not.toHaveBeenCalled(); + } finally { + fs.rmSync(main, { recursive: true, force: true }); + } + }); + it.each(['pi', 'omp', 'hermes', 'codex', 'codex-internal', 'tcodex'])('does not read or sync HTTP prompts for excluded %s', async (tool) => { const registry = buildHandlerRegistry(); const cached = registry.find((r) => r.handler.name === 'http-prompt-instructions')!.handler; @@ -797,10 +835,12 @@ describe('hook-handlers registry', () => { // (inline, blocking) handler must therefore stay well under that ceiling — // unified at <5s — so a slow/unreachable endpoint can never trip the host // timeout on any event. Background (detached) handlers are not awaited by the - // host, so they may keep longer budgets. + // host, so they may keep longer budgets. Git's own events (git-hook.ts) run + // under git, which has no hook timeout. it('every foreground handler timeout is under 5s', () => { const registry = buildHandlerRegistry(); - const foreground = registry.filter((r) => r.background !== true); + const gitEvents: readonly string[] = GIT_HOOK_EVENTS; + const foreground = registry.filter((r) => r.background !== true && !gitEvents.includes(r.event)); expect(foreground.length).toBeGreaterThan(0); for (const reg of foreground) { expect(reg.timeoutMs).toBeLessThan(5_000); diff --git a/src/__tests__/hooks-cmd.test.ts b/src/__tests__/hooks-cmd.test.ts index eaaa92332..bf5b59917 100644 --- a/src/__tests__/hooks-cmd.test.ts +++ b/src/__tests__/hooks-cmd.test.ts @@ -196,6 +196,12 @@ describe('hooksInject', () => { } }); + it('fails, without the success line, when the git hook cannot be installed', async () => { + mockedReconcileForConfig.mockRejectedValue(new Error('Could not install the teamai git hook in /repo: EACCES')); + await expect(hooksInject({})).rejects.toThrow('Could not install the teamai git hook'); + expect(mockedLog.success).not.toHaveBeenCalled(); + }); + it('propagates error when not initialized', async () => { mockedAutoDetectInit.mockRejectedValue(new Error('teamai is not initialized')); await expect(hooksInject({})).rejects.toThrow('not initialized'); diff --git a/src/__tests__/hooks-reconcile-scope.test.ts b/src/__tests__/hooks-reconcile-scope.test.ts index c4a400ec1..71e4046c1 100644 --- a/src/__tests__/hooks-reconcile-scope.test.ts +++ b/src/__tests__/hooks-reconcile-scope.test.ts @@ -9,6 +9,7 @@ vi.mock('../utils/logger.js', () => ({ })); import { reconcileTeamHooksForConfig } from '../hooks.js'; +import * as gitHook from '../git-hook.js'; import type { LocalConfig, TeamaiConfig } from '../types.js'; let project: string; @@ -74,6 +75,24 @@ afterEach(async () => { }); describe('reconcileTeamHooksForConfig — pull/init core path', () => { + it('propagates Git-hook installation failure instead of reporting a successful reconcile', async () => { + const spy = vi.spyOn(gitHook, 'installGitHook').mockRejectedValueOnce(new Error('EACCES: hooks directory')); + try { + await expect(reconcileTeamHooksForConfig(teamConfig, localConfig())).rejects.toThrow('EACCES: hooks directory'); + } finally { + spy.mockRestore(); + } + }); + it('still installs the agent hooks when the Git hook cannot be installed', async () => { + const spy = vi.spyOn(gitHook, 'installGitHook').mockRejectedValueOnce(new Error('EACCES: hooks directory')); + try { + await expect(reconcileTeamHooksForConfig(teamConfig, localConfig())).rejects.toThrow(/teamai git hook/); + expect((await claudeSettings()).hooks.SessionStart).toHaveLength(1); + expect((await codexSettings()).hooks.SessionStart).toHaveLength(1); + } finally { + spy.mockRestore(); + } + }); it('injects built-in + team hooks into each tool, records the manifest', async () => { await writeYaml(` hooks: diff --git a/src/__tests__/init.test.ts b/src/__tests__/init.test.ts index d9f8ad5a5..0289378e1 100644 --- a/src/__tests__/init.test.ts +++ b/src/__tests__/init.test.ts @@ -232,6 +232,7 @@ vi.mock('../utils/prompt.js', async (importOriginal) => ({ } return Promise.resolve(defaultValue ?? false); }), + askSelection: vi.fn(), closePrompt: vi.fn(), })); @@ -867,6 +868,69 @@ describe('init', () => { ); }); + describe('project-scope tool picker', () => { + let originalIsTTY: boolean | undefined; + let logSpy: ReturnType; + + beforeEach(() => { + originalIsTTY = process.stdin.isTTY; + logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + const projectLocalPath = path.join(process.cwd(), '.teamai', 'team-repo'); + let cloneDone = false; + pathExistsFn = (p: string) => { + if (p === projectLocalPath) return cloneDone; + if (p.endsWith(`${path.sep}.git`) || p.endsWith('/.git')) return true; + return false; + }; + mockGfRepoClone.mockImplementation(() => { cloneDone = true; }); + questionAnswers = ['n', '1']; + }); + + afterEach(() => { + logSpy.mockRestore(); + Object.defineProperty(process.stdin, 'isTTY', { value: originalIsTTY, configurable: true }); + }); + + it('asks which tools when interactive without --agent and saves the choice', async () => { + Object.defineProperty(process.stdin, 'isTTY', { value: true, configurable: true }); + const { askSelection } = await import('../utils/prompt.js'); + vi.mocked(askSelection).mockResolvedValueOnce([1]); // option 2 = Claude Code + const { saveLocalConfigForScope } = await import('../config.js'); + + await init({ repo: 'https://git.woa.com/HyperAI/teamai-test.git' }); + + expect(askSelection).toHaveBeenCalledTimes(1); + const printed = logSpy.mock.calls.map((c) => String(c[0])).join('\n'); + expect(printed).toContain('Which AI tools do you use in this project?'); + expect(saveLocalConfigForScope).toHaveBeenCalledWith( + expect.objectContaining({ scope: 'project', enabledAgents: ['claude'] }), + 'project', + process.cwd(), + ); + }); + + it('skips the picker when --agent is given', async () => { + Object.defineProperty(process.stdin, 'isTTY', { value: true, configurable: true }); + const { askSelection } = await import('../utils/prompt.js'); + + await init({ repo: 'https://git.woa.com/HyperAI/teamai-test.git', agent: ['codex'] }); + + expect(askSelection).not.toHaveBeenCalled(); + }); + + it('skips the picker and enables nothing when not interactive', async () => { + Object.defineProperty(process.stdin, 'isTTY', { value: false, configurable: true }); + const { askSelection } = await import('../utils/prompt.js'); + const { saveLocalConfigForScope } = await import('../config.js'); + + await init({ repo: 'https://git.woa.com/HyperAI/teamai-test.git' }); + + expect(askSelection).not.toHaveBeenCalled(); + const saved = vi.mocked(saveLocalConfigForScope).mock.calls[0]?.[0]; + expect(saved?.enabledAgents).toBeUndefined(); + }); + }); + it('should print scope summary without interactive Select scope when --scope is provided', async () => { let cloneDone = false; pathExistsFn = (p: string) => { diff --git a/src/__tests__/project-agent-root.test.ts b/src/__tests__/project-agent-root.test.ts index 710d225fa..37d974d11 100644 --- a/src/__tests__/project-agent-root.test.ts +++ b/src/__tests__/project-agent-root.test.ts @@ -1,9 +1,10 @@ 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 { seedProjectAgentRoot } from '../project-agent-root.js'; +import { createProjectToolRoots, seedProjectAgentRoot } from '../project-agent-root.js'; let tmpDir: string; @@ -130,3 +131,54 @@ describe('seedProjectAgentRoot', () => { expect(fs.existsSync(path.join(projectRoot, '.claude'))).toBe(false); }); }); + +function dotEntries(dir: string): string[] { + return fs.readdirSync(dir).filter((name) => name.startsWith('.')).sort(); +} + +describe('createProjectToolRoots', () => { + it('creates the root of every tool in enabledAgents and nothing else', async () => { + const projectRoot = writeProjectConfig({ enabledAgents: ['claude', 'codex'] }); + + await createProjectToolRoots({ cwd: projectRoot }); + + expect(dotEntries(projectRoot)).toEqual(['.claude', '.codex', '.teamai']); + }); + + describe('in a linked worktree with empty enabledAgents', () => { + function runGit(cwd: string, ...args: string[]): void { + execFileSync('git', args, { cwd, stdio: 'pipe' }); + } + + function linkedWorktree(mainRoots: string[], config: { disabledAgents?: string[] } = {}): string { + const base = fs.realpathSync.native(tmpDir); + const main = path.join(base, 'main'); + fs.mkdirSync(main); + runGit(main, 'init', '-q'); + runGit(main, 'config', 'user.email', 'test@example.com'); + runGit(main, 'config', 'user.name', 'Test'); + runGit(main, 'commit', '--allow-empty', '-q', '-m', 'init'); + for (const root of mainRoots) fs.mkdirSync(path.join(main, root), { recursive: true }); + + const worktree = path.join(base, 'wt'); + runGit(main, 'worktree', 'add', '-q', worktree, 'HEAD'); + return writeProjectConfig({ projectRoot: worktree, ...config }); + } + + it('creates the tool roots the main checkout has', async () => { + const worktree = linkedWorktree(['.claude', '.codex', '.github']); + + await createProjectToolRoots({ cwd: worktree }); + + expect(dotEntries(worktree)).toEqual(['.claude', '.codex', '.git', '.teamai']); + }); + + it('skips a disabled tool even when the main checkout has its root', async () => { + const worktree = linkedWorktree(['.claude', '.cursor'], { disabledAgents: ['cursor'] }); + + await createProjectToolRoots({ cwd: worktree }); + + expect(dotEntries(worktree)).toEqual(['.claude', '.git', '.teamai']); + }); + }); +}); diff --git a/src/__tests__/pull-sync-truth.test.ts b/src/__tests__/pull-sync-truth.test.ts index fe466f140..61a58147f 100644 --- a/src/__tests__/pull-sync-truth.test.ts +++ b/src/__tests__/pull-sync-truth.test.ts @@ -23,6 +23,7 @@ vi.mock('../config.js', async (importOriginal) => ({ vi.mock('../utils/git.js', () => ({ pullRepo: vi.fn().mockResolvedValue('already up to date'), getHeadRev: vi.fn().mockResolvedValue('abc1234'), + listWorktrees: vi.fn().mockResolvedValue([]), })); vi.mock('../utils/logger.js', () => ({ @@ -32,6 +33,7 @@ vi.mock('../utils/logger.js', () => ({ warn: vi.fn(), error: vi.fn(), debug: vi.fn(), + persist: vi.fn(), dim: vi.fn(), }, spinner: vi.fn(() => ({ @@ -67,6 +69,9 @@ import { checkoutKey, pull } from '../pull.js'; import { detectProjectConfig, loadLocalConfigForScope, loadTeamConfig, loadStateForScope, saveStateForScope } from '../config.js'; import { log } from '../utils/logger.js'; import type { TeamaiConfig, LocalConfig } from '../types.js'; +import { recordGitHookFailure, readGitHookFailure } from '../git-hook.js'; +import { reconcileTeamHooksForConfig } from '../hooks.js'; +import { reconcileMcpForConfig } from '../mcp-reconcile.js'; describe('pull reports what reached the tool directory (#585)', () => { let tmpDir: string; @@ -138,6 +143,33 @@ describe('pull reports what reached the tool directory (#585)', () => { await fse.remove(tmpDir); }); + it.each(['docs', 'hooks', 'hook resolution', 'MCP', 'MCP resolution', 'none'])( + 'records partial Git-hook delivery failure in %s and clears it only after a complete retry', async (failure) => { + const projectRoot = path.join(tmpDir, 'project'); + await fse.ensureDir(projectRoot); + const config: LocalConfig = { ...localConfig, scope: 'project', projectRoot }; + vi.mocked(detectProjectConfig).mockResolvedValue(config); + await recordGitHookFailure(config, { + kind: 'hook-error', event: 'post-checkout', at: new Date().toISOString(), error: 'previous failure', + }); + if (failure === 'docs') await fse.outputFile(path.join(projectRoot, 'docs'), 'blocks docs directory'); + if (failure === 'hooks') vi.mocked(reconcileTeamHooksForConfig).mockRejectedValueOnce(new Error('hook write failed')); + if (failure === 'hook resolution') vi.mocked(reconcileTeamHooksForConfig).mockResolvedValueOnce({ ok: false, builtins: 'with-overrides' }); + if (failure === 'MCP') vi.mocked(reconcileMcpForConfig).mockRejectedValueOnce(new Error('MCP write failed')); + if (failure === 'MCP resolution') vi.mocked(reconcileMcpForConfig).mockResolvedValueOnce({ changes: [], wrote: false, unresolved: true }); + await pull({ silent: true, inline: true, gitHook: 'post-checkout', force: true }); + const recorded = await readGitHookFailure(config); + if (failure === 'none') expect(recorded).toBeNull(); + else { + expect(recorded).toMatchObject({ kind: 'hook-error', event: 'post-checkout' }); + expect(recorded && 'error' in recorded && recorded.error).not.toBe('previous failure'); + if (failure === 'docs') await fse.remove(path.join(projectRoot, 'docs')); + await pull({ silent: true, inline: true, gitHook: 'post-checkout' }); + expect(await readGitHookFailure(config)).toBeNull(); + } + }, + ); + /** Every success line this run printed. Read fresh so a prior case cannot leak in. */ function successLines(): string[] { return vi.mocked(log.success).mock.calls.map(([msg]) => String(msg)); diff --git a/src/__tests__/pull-unreadable-config.test.ts b/src/__tests__/pull-unreadable-config.test.ts index f17c98193..85e07acb0 100644 --- a/src/__tests__/pull-unreadable-config.test.ts +++ b/src/__tests__/pull-unreadable-config.test.ts @@ -187,3 +187,18 @@ describe('pull in a project whose config cannot be read (#784)', () => { expect(log.error).not.toHaveBeenCalled(); }); }); + +describe('a pull a git hook started', () => { + it('does not hand TEAMAI_GIT_HOOK on to what it runs (postPull scripts)', async () => { + const cwd = path.join(tmp, 'plain'); + fs.mkdirSync(cwd); + process.chdir(cwd); + process.env.TEAMAI_GIT_HOOK = 'post-merge'; + try { + await pull({ silent: true }); + expect(process.env.TEAMAI_GIT_HOOK).toBeUndefined(); + } finally { + delete process.env.TEAMAI_GIT_HOOK; + } + }); +}); diff --git a/src/builtin-hooks.ts b/src/builtin-hooks.ts index 98b7800e5..e31e41936 100644 --- a/src/builtin-hooks.ts +++ b/src/builtin-hooks.ts @@ -34,7 +34,7 @@ import { bundledShellFor, resetBundledRuntimeCache, resolveCodebuddyNode, resolv // resolver the wrapper writer uses, so write and lookup cannot diverge. // Other tools keep the plain `bash -lc "teamai ..."` form. -const TEAMAI_BIN_DIR = '.teamai/bin'; +export const TEAMAI_BIN_DIR = '.teamai/bin'; const WRAPPER_NAME = 'teamai'; /** diff --git a/src/doctor.ts b/src/doctor.ts index a75c24e0e..cca0d6992 100644 --- a/src/doctor.ts +++ b/src/doctor.ts @@ -515,6 +515,7 @@ export async function buildChecks(ctx: DoctorContext, stage: CheckStage = 'docto fix: 'Run `teamai pull` to publish them. If they stay queued, check that you ' + 'can push to the team repo (run with --verbose to see the push error).', }, + ...await buildGitHookChecks(localConfig, stage), ...buildToolRootChecks(localConfig, teamConfig), ...await buildEnabledToolChecks(ctx), ...await buildHookChecks(toolPaths, hookToolPaths, baseDir, localConfig), @@ -537,6 +538,39 @@ export async function buildChecks(ctx: DoctorContext, stage: CheckStage = 'docto return checks; } +/** + * Project scope: whether teamai's git hook is installed (`doctor` only: pull + * installs it, and a member on an old git would hear it after every pull), and + * the failure its last silent run recorded (git-hook.ts), which `pull` + * mentions itself. A project root outside git has no hook to report. + */ +async function buildGitHookChecks(localConfig: LocalConfig, stage: CheckStage): Promise { + if (localConfig.scope !== 'project' || !localConfig.projectRoot) return []; + const { gitHookStatus, describeMissingGitHook, readGitHookFailure, describeGitHookFailure } = await import('./git-hook.js'); + // A project root that no longer exists cannot be asked (git refuses the cwd). + const status = stage === 'doctor' ? await gitHookStatus(localConfig.projectRoot).catch(() => null) : null; + const failure = await readGitHookFailure(localConfig); + const report = failure ? describeGitHookFailure(failure) : null; + const installed: Check[] = !status || (!status.installed && status.reason === 'not-a-repository') ? [] : [{ + name: 'Git hook syncs new worktrees and git pull', + source: 'local', + check: async () => status.installed, + ...(status.installed ? {} : { fix: describeMissingGitHook(status) }), + }]; + return [ + ...installed, + report + ? { + name: `Last git hook run failed: ${report.message}`, + source: 'local', + reportedByPull: 'git-hook-failure', + check: async () => false, + fix: report.fix, + } + : { name: 'No git hook failure recorded', source: 'local', check: async () => true }, + ]; +} + /** * Run every check once, in registry order. `onResult` reports each one as it * lands, so the human rendering keeps streaming while a slow check (a provider diff --git a/src/git-hook.ts b/src/git-hook.ts new file mode 100644 index 000000000..ebe92178c --- /dev/null +++ b/src/git-hook.ts @@ -0,0 +1,395 @@ +/** + * teamai's git hook: a named hook in a repository's local git config that runs + * `teamai hook-dispatch --tool git` on `post-checkout` and + * `post-merge`, so a new worktree gets the team's resources before + * `git worktree add` returns. + * + * Config hooks (`hook..command` + `hook..event`, Git >= 2.54) live + * in the common config every worktree shares, and run beside `core.hooksPath` + * and `.git/hooks` scripts, so no hook manager's files are touched. + * + * Git gives the command no event name, so each event gets its own named hook. + * Git runs the command as `sh -c ' "$@"' `: its arguments land + * on the command's last simple command. The command is therefore a function + * definition followed by its call, which receives them, and the function ends + * in `|| :` so the hook always exits 0 (a non-zero `post-checkout` becomes the + * exit status of `git worktree add`). + * + * Older Git: without `core.hooksPath`, the same dispatch goes into a + * marker-delimited block in `.git/hooks/`, inserted after the shebang so + * the script's own lines and exit status stay as they were. With + * `core.hooksPath` (a hook manager) nothing is written; `doctor` advises. + */ + +import { ensureTeamaiWrapper, TEAMAI_BIN_DIR } from './builtin-hooks.js'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { getDataHome, type LocalConfig } from './types.js'; +import { execCommand } from './utils/exec.js'; +import { readFileIfExists, readJson, remove, writeJson } from './utils/fs.js'; +import { log } from './utils/logger.js'; + +export const GIT_HOOK_EVENTS = ['post-checkout', 'post-merge'] as const; +export type GitHookEvent = (typeof GIT_HOOK_EVENTS)[number]; + +/** The `--tool` value of a dispatch git runs. */ +export const GIT_HOOK_TOOL = 'git'; + +/** First Git release with config-defined hooks. */ +const MIN_GIT: readonly [number, number] = [2, 54]; + +const ZERO_OID = /^0+$/; + +function hookName(event: GitHookEvent): string { + return `teamai-${event}`; +} + +/** The shell line git runs for `event`. */ +export function gitHookCommand(event: GitHookEvent): string { + return `teamai_git_hook() { PATH="$HOME/${TEAMAI_BIN_DIR}:$PATH" teamai hook-dispatch ${event} --tool ${GIT_HOOK_TOOL} "$@" >/dev/null 2>&1 || :; }; teamai_git_hook`; +} + +/** + * The line a team may commit into its own hook manager's post-checkout and + * post-merge hooks when teamai cannot install its hook: a no-op that exits 0 + * on a machine without teamai. + */ +export function guardedGitHookLine(event: GitHookEvent): string { + return `command -v teamai >/dev/null 2>&1 && teamai hook-dispatch ${event} --tool ${GIT_HOOK_TOOL} "$@" >/dev/null 2>&1 || true`; +} + +// Markers of the block in `.git/hooks/` on Git without config hooks. +const BLOCK_START = '# >>> teamai git hook (managed by teamai) >>>'; +const BLOCK_END = '# <<< teamai git hook <<<'; + +function scriptBlock(event: GitHookEvent): string { + return `${BLOCK_START}\nPATH="$HOME/${TEAMAI_BIN_DIR}:$PATH" teamai hook-dispatch ${event} --tool ${GIT_HOOK_TOOL} "$@" >/dev/null 2>&1 || :\n${BLOCK_END}\n`; +} + +/** `text` without teamai's block. */ +function withoutScriptBlock(text: string): string { + const start = text.indexOf(`${BLOCK_START}\n`); + const end = text.indexOf(`${BLOCK_END}\n`, start); + return start >= 0 && end > start ? text.slice(0, start) + text.slice(end + BLOCK_END.length + 1) : text; +} + +/** `text` with the current block for `event`, or null when it is not a shell script. */ +function withScriptBlock(text: string | null, event: GitHookEvent): string | null { + if (text === null) return `#!/bin/sh\n${scriptBlock(event)}`; + const rest = withoutScriptBlock(text); + const shebang = /^#![^\n]*(\n|$)/.exec(rest)?.[0] ?? ''; + if (shebang && !/\b(ba|da|k|z)?sh\b/.test(shebang)) return null; + return `${shebang}${shebang && !shebang.endsWith('\n') ? '\n' : ''}${scriptBlock(event)}${rest.slice(shebang.length)}`; +} + +export type GitHookInstall = + | { installed: true; changed: boolean } + | { installed: false; reason: 'hooks-path' | 'other-hook' | 'not-a-repository' }; + +export type GitHookStatus = + | { installed: true } + | { installed: false; reason: 'hooks-path' | 'other-hook'; gitVersion: string } + | { installed: false; reason: 'not-a-repository' | 'not-configured' }; + +type Git = (args: string[]) => ReturnType; +const gitIn = (repoDir: string): Git => (args) => execCommand('git', args, { cwd: repoDir, timeoutMs: 10_000 }); + +async function eventsToWrite(git: Git): Promise { + const stale: GitHookEvent[] = []; + for (const event of GIT_HOOK_EVENTS) { + const key = `hook.${hookName(event)}`; + const current = (await git(['config', '--local', '--get', `${key}.command`])).stdout.trim(); + const events = (await git(['config', '--local', '--get-all', `${key}.event`])).stdout.trim(); + if (current !== gitHookCommand(event) || events !== event) stale.push(event); + } + return stale; +} + +/** Whether the hook is in the local config of the repository holding `repoDir`, and why not. */ +export async function gitHookStatus(repoDir: string): Promise { + const git = gitIn(repoDir); + const version = (await git(['--version'])).stdout.trim(); + if ((await git(['rev-parse', '--git-dir'])).code !== 0) return { installed: false, reason: 'not-a-repository' }; + if (!supportsConfigHooks(version)) { + const scripts = await scriptsToWrite(git, repoDir); + if (scripts.blocked) return { installed: false, reason: scripts.blocked, gitVersion: version }; + return scripts.stale.length === 0 ? { installed: true } : { installed: false, reason: 'not-configured' }; + } + return (await eventsToWrite(git)).length === 0 ? { installed: true } : { installed: false, reason: 'not-configured' }; +} + +/** What `doctor` says about a hook that is not installed: the cause, then the next step. */ +export function describeMissingGitHook(status: Exclude): string { + switch (status.reason) { + case 'hooks-path': + case 'other-hook': { + const where = status.reason === 'hooks-path' + ? 'core.hooksPath is set, so teamai leaves the hook manager\'s files alone' + : 'a post-checkout or post-merge hook in .git/hooks is a symlink or not an executable shell script, so teamai leaves it alone'; + const owner = status.reason === 'hooks-path' ? 'your hook manager defines' : 'in .git/hooks'; + return `${status.gitVersion || 'This git'} has no config-based hooks (Git 2.54 or later) and ${where}: new ` + + 'worktrees and `git pull` get the team\'s resources only at the next session. Either: ' + + '1. Upgrade Git to 2.54 or later, then run `teamai pull`. ' + + `2. If the team agrees to commit it, run this line from the post-checkout hook ${owner}, ` + + `\`${guardedGitHookLine('post-checkout')}\`, and this one from the post-merge hook, ` + + `\`${guardedGitHookLine('post-merge')}\`; wrap each in \`sh -c '...'\` when the hook config is not a ` + + 'shell script. Both do nothing on a machine without teamai.'; + } + case 'not-a-repository': + return 'The project root is not a git repository, so there is no git event to hook.'; + case 'not-configured': + return 'The teamai hooks are not in this repository\'s git config. Run `teamai pull` to install them.'; + } +} + +/** + * Write (or refresh) the hook into the local config of the repository holding + * `repoDir`. Idempotent: an up-to-date hook is left untouched. + */ +export async function installGitHook(repoDir: string, opts: { dryRun?: boolean } = {}): Promise { + const git = gitIn(repoDir); + const configHooks = supportsConfigHooks((await git(['--version'])).stdout); + if ((await git(['rev-parse', '--git-dir'])).code !== 0) return { installed: false, reason: 'not-a-repository' }; + if (!configHooks) return installHookScripts(git, repoDir, opts); + + const stale = await eventsToWrite(git); + if (opts.dryRun) return { installed: true, changed: stale.length > 0 }; + // A block an older Git needed would dispatch a second time beside the config hook. + await removeHookScriptBlocks(git, repoDir); + + // The wrapper is what the hook command finds `teamai` through when the app + // that runs git has no login PATH. + ensureTeamaiWrapper(); + + for (const event of stale) { + const key = `hook.${hookName(event)}`; + await ok(git(['config', '--local', `${key}.command`, gitHookCommand(event)]), key); + await ok(git(['config', '--local', '--replace-all', `${key}.event`, event]), key); + } + if (stale.length > 0) log.debug(`git hook: installed teamai hooks in ${repoDir}`); + return { installed: true, changed: stale.length > 0 }; +} + +type ScriptPlan = { blocked: 'hooks-path' | 'other-hook'; stale: [] } | { blocked: null; stale: { file: string; text: string }[] }; + +async function scriptsToWrite(git: Git, repoDir: string): Promise { + if ((await git(['config', '--get', 'core.hooksPath'])).stdout.trim()) return { blocked: 'hooks-path', stale: [] }; + // The common hooks directory, from a linked worktree too. + const dir = path.resolve(repoDir, (await git(['rev-parse', '--git-path', 'hooks'])).stdout.trim()); + const stale: { file: string; text: string }[] = []; + for (const event of GIT_HOOK_EVENTS) { + const file = path.join(dir, event); + // A symlink usually points at a hook manager's script, possibly shared by other repositories. + if (await isSymlink(file)) return { blocked: 'other-hook', stale: [] }; + const current = await readFileIfExists(file); + if (current !== null && process.platform !== 'win32' && ((await fs.stat(file)).mode & 0o111) === 0) { + return { blocked: 'other-hook', stale: [] }; + } + const text = withScriptBlock(current, event); + if (text === null) return { blocked: 'other-hook', stale: [] }; + if (text !== current) stale.push({ file, text }); + } + return { blocked: null, stale }; +} + +async function installHookScripts(git: Git, repoDir: string, opts: { dryRun?: boolean }): Promise { + const plan = await scriptsToWrite(git, repoDir); + if (plan.blocked) return { installed: false, reason: plan.blocked }; + if (opts.dryRun) return { installed: true, changed: plan.stale.length > 0 }; + ensureTeamaiWrapper(); + for (const { file, text } of plan.stale) { + await fs.mkdir(path.dirname(file), { recursive: true }); + // The creation mode applies only to a new file. Existing owner modes stay. + await fs.writeFile(file, text, { mode: 0o755 }); + } + if (plan.stale.length > 0) log.debug(`git hook: installed teamai hook scripts in ${repoDir}`); + return { installed: true, changed: plan.stale.length > 0 }; +} + +/** + * Take teamai's block out of the post-checkout and post-merge scripts in the + * repository's own hooks directory (and in core.hooksPath's, should a block + * predate it). A script left with only the shebang is the one teamai created + * when there was none, so it goes too. Returns the files changed. + */ +async function removeHookScriptBlocks(git: Git, repoDir: string, opts: { dryRun?: boolean } = {}): Promise { + const dirs = new Set(); + for (const args of [['rev-parse', '--git-common-dir'], ['rev-parse', '--git-path', 'hooks']]) { + const { code, stdout } = await git(args); + if (code !== 0) continue; + const out = path.resolve(repoDir, stdout.trim()); + dirs.add(args.length === 2 ? path.join(out, 'hooks') : out); + } + const changed: string[] = []; + for (const dir of dirs) { + for (const event of GIT_HOOK_EVENTS) { + const file = path.join(dir, event); + if (await isSymlink(file)) continue; + const current = await readFileIfExists(file); + if (current === null) continue; + const text = withoutScriptBlock(current); + if (text === current) continue; + changed.push(file); + if (opts.dryRun) continue; + if (/^(#![^\n]*\n?)?$/.test(text)) await fs.rm(file); + else await fs.writeFile(file, text); + } + } + return changed; +} + +/** + * Remove everything installGitHook wrote in the repository holding `repoDir`: + * the `hook.teamai-` config sections and the blocks in hook scripts. + * Other hooks and lines stay. Returns what was (or, on a dry run, would be) + * removed: config section names, then script paths. + */ +export async function removeGitHook(repoDir: string, opts: { dryRun?: boolean } = {}): Promise { + const git = gitIn(repoDir); + if ((await git(['rev-parse', '--git-dir'])).code !== 0) return []; + const removed: string[] = []; + for (const event of GIT_HOOK_EVENTS) { + const section = `hook.${hookName(event)}`; + const present = (await git(['config', '--local', '--get-regexp', `^${section.replace(/\./g, '\\.')}\\.`])).stdout.trim(); + if (!present) continue; + removed.push(section); + if (!opts.dryRun) await ok(git(['config', '--local', '--remove-section', section]), section); + } + removed.push(...await removeHookScriptBlocks(git, repoDir, opts)); + return removed; +} + +async function isSymlink(file: string): Promise { + try { + return (await fs.lstat(file)).isSymbolicLink(); + } catch (e) { + if ((e as NodeJS.ErrnoException).code === 'ENOENT') return false; + throw e; + } +} + +async function ok(result: ReturnType, key: string): Promise { + const { code, stderr } = await result; + if (code !== 0) throw new Error(`git config ${key} failed: ${stderr.trim() || `exit ${code}`}`); +} + +function supportsConfigHooks(versionOutput: string): boolean { + const m = /(\d+)\.(\d+)/.exec(versionOutput); + if (!m) return false; + const [major, minor] = [Number(m[1]), Number(m[2])]; + return major > MIN_GIT[0] || (major === MIN_GIT[0] && minor >= MIN_GIT[1]); +} + +/** + * Whether a `post-checkout` with these arguments created a checkout (a new + * worktree): Git passes an all-zero old ref then. A branch switch passes the + * previous HEAD. + */ +export function isNewCheckout(args: readonly string[]): boolean { + const [oldRef, , branchFlag] = args; + return !!oldRef && ZERO_OID.test(oldRef) && branchFlag === '1'; +} + +/** + * Variables through which Git hands a hook the repository it runs for + * (`git rev-parse --local-env-vars`, minus GIT_CONFIG_COUNT and its + * GIT_CONFIG_KEY/VALUE pairs, which a member sets in their own environment and + * Git never adds for a hook). Every git child teamai starts would inherit them + * and act on the business repo instead of the team clone it names. + */ +const REPOSITORY_ENV = [ + 'GIT_ALTERNATE_OBJECT_DIRECTORIES', + 'GIT_CONFIG', + 'GIT_CONFIG_PARAMETERS', + 'GIT_OBJECT_DIRECTORY', + 'GIT_DIR', + 'GIT_WORK_TREE', + 'GIT_IMPLICIT_WORK_TREE', + 'GIT_GRAFT_FILE', + 'GIT_INDEX_FILE', + 'GIT_NO_REPLACE_OBJECTS', + 'GIT_REPLACE_REF_BASE', + 'GIT_PREFIX', + 'GIT_INTERNAL_SUPER_PREFIX', + 'GIT_SHALLOW_FILE', + 'GIT_COMMON_DIR', +] as const; + +/** Drop the repository Git exported for the hook, before teamai runs any git. */ +export function clearGitHookRepositoryEnv(env: NodeJS.ProcessEnv = process.env): void { + for (const name of REPOSITORY_ENV) delete env[name]; +} + +// ─── Recorded failures ───────────────────────────────── +// +// The hook is silent and exits 0, so a failure inside it is kept here, in the +// project partition every worktree shares, until someone is told: `doctor` +// names it, the next interactive `pull` mentions it once and drops it, and the +// next hook-started pull that succeeds (the detached retry included) drops it. + +export type GitHookFailure = + | { kind: 'fetch-failed'; event: GitHookEvent; at: string; error: string } + | { kind: 'fetch-timeout'; event: GitHookEvent; at: string; capMs: number } + | { kind: 'lock-held'; event: GitHookEvent; at: string; waitedMs: number } + | { kind: 'hook-error'; event: GitHookEvent; at: string; error: string }; + +/** A failure as the member reads it: what happened and why, then the next step. */ +export interface GitHookFailureReport { + message: string; + fix: string; +} + +function failureFile(config: LocalConfig): string { + return path.join(getDataHome(config), 'git-hook-failure.json'); +} + +export function isGitHookEvent(value: unknown): value is GitHookEvent { + return (GIT_HOOK_EVENTS as readonly unknown[]).includes(value); +} + +/** Keep `failure` for `doctor` and the next pull, and write it to debug.log. */ +export async function recordGitHookFailure(config: LocalConfig, failure: GitHookFailure): Promise { + log.persist(`git hook: ${describeGitHookFailure(failure).message}`); + await writeJson(failureFile(config), failure) + .catch((e) => log.persist(`git hook: could not record the failure: ${(e as Error).message}`)); +} + +export async function readGitHookFailure(config: LocalConfig): Promise { + const failure = await readJson(failureFile(config)).catch(() => null); + return failure && isGitHookEvent(failure.event) ? failure : null; +} + +export async function clearGitHookFailure(config: LocalConfig): Promise { + await remove(failureFile(config)).catch(() => {}); +} + +export function describeGitHookFailure(failure: GitHookFailure): GitHookFailureReport { + const when = `(${failure.at})`; + // git's stderr runs over several lines; the first names the cause, debug.log keeps the rest. + const firstLine = (error: string) => error.trim().split('\n')[0]; + switch (failure.kind) { + case 'fetch-failed': + return { + message: `${failure.event} could not fetch the team repo ${when}: ${firstLine(failure.error)}`, + fix: 'The worktree may lack the team\'s latest resources. Check that you can reach the team repo, ' + + 'then run `teamai pull`.', + }; + case 'fetch-timeout': + return { + message: `${failure.event} stopped the team repo fetch at its ${failure.capMs} ms cap ${when}, ` + + 'and the pull it handed over to has not completed it', + fix: 'Check that you can reach the team repo, then run `teamai pull`.', + }; + case 'lock-held': + return { + message: `${failure.event} skipped its sync: another teamai process held the project's sync lock ` + + `for the ${failure.waitedMs} ms it waits ${when}`, + fix: 'Run `teamai pull` once that process finishes. If a teamai pull is stuck, stop it first.', + }; + case 'hook-error': + return { + message: `${failure.event} failed ${when}: ${firstLine(failure.error)}`, + fix: 'Run `teamai pull` to sync; ~/.teamai/debug.log has the details.', + }; + } +} diff --git a/src/hook-dispatch-cli.ts b/src/hook-dispatch-cli.ts index 26fd3dc0a..c9c2aa026 100644 --- a/src/hook-dispatch-cli.ts +++ b/src/hook-dispatch-cli.ts @@ -26,7 +26,8 @@ import { createDispatcher, type Dispatcher } from './hook-dispatch.js'; import { buildHandlerRegistry, filterHandlersForConfig } from './hook-handlers.js'; import { resolveHookCwd } from './utils/hook-cwd.js'; import { windowsPowerShell } from './utils/powershell.js'; -import { log, setStderrOnly } from './utils/logger.js'; +import { log, setSilent, setStderrOnly } from './utils/logger.js'; +import { clearGitHookRepositoryEnv, GIT_HOOK_TOOL } from './git-hook.js'; import { deriveDispatchSessionId } from './utils/session-id.js'; import { claudeHookRunsInAnotherHost } from './claude-hook-host.js'; @@ -414,7 +415,7 @@ export async function hookDispatchCli( event: string, tool: string, matcher: string, - options: { bgOnly?: boolean; stdinFile?: string } = {}, + options: { bgOnly?: boolean; stdinFile?: string; hookArgs?: string[] } = {}, ): Promise { const { bgOnly = false, stdinFile } = options; setStderrOnly(true); @@ -422,8 +423,19 @@ export async function hookDispatchCli( log.debug('hook-dispatch: skipping claude hooks because Cursor or Copilot CLI has its own teamai hooks'); return; } + // A git hook (see git-hook.ts) prints nothing, and runs with the business + // repo exported in GIT_DIR and friends, which every git child would inherit. + const fromGit = tool === GIT_HOOK_TOOL; + if (fromGit) { + setSilent(true); + clearGitHookRepositoryEnv(); + } try { - const raw = stdinFile ? readStdinFile(stdinFile) : await readStdin(); + // Git sends no payload: its arguments and the checkout it runs in are the + // payload. The detached child gets them back through the STDIN file. + const raw = stdinFile ? readStdinFile(stdinFile) + : fromGit ? JSON.stringify({ cwd: process.cwd(), git_args: options.hookArgs ?? [] }) + : await readStdin(); const stdin = parseStdin(raw, event); // Config gates: a directory without teamai runs no team handlers (#748), and @@ -442,6 +454,14 @@ export async function hookDispatchCli( log.debug(`hook-dispatch: chdir to ${cwd} failed: ${(e as Error).message}`); } } + if (fromGit) { + const { findUnreadableProjectConfig, describeUnreadableConfig } = await import('./config.js'); + const problem = await findUnreadableProjectConfig(cwd); + if (problem !== null) { + log.persist(`git hook: Nothing was synced: ${describeUnreadableConfig(problem)}`); + return; + } + } 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 220dbdcf1..66498c12d 100644 --- a/src/hook-handlers.ts +++ b/src/hook-handlers.ts @@ -140,6 +140,122 @@ const pullHandler: HookHandler = { }, }; +/** + * `post-checkout` from the git hook: a new checkout (a linked worktree) of a + * project-scope repository gets its tool roots and the team's resources. A + * branch switch, user scope (whose resources live in HOME), and checkouts + * teamai itself creates (its knowledge and reports worktrees, under the scope's + * data home or team clone) do nothing. + */ +const newWorktreeHandler: HookHandler = { + name: 'new-worktree', + async execute(stdin, _tool, config) { + const { isNewCheckout } = await import('./git-hook.js'); + const args = Array.isArray(stdin.git_args) ? stdin.git_args.map(String) : []; + if (!config || config.scope !== 'project' || !isNewCheckout(args)) return null; + const cwd = resolveHookCwd(stdin) ?? process.cwd(); + const { getDataHome } = await import('./types.js'); + if (await isWithin(cwd, [getDataHome(config), config.repo.localPath, ...await ownCheckoutsDir(cwd)])) return null; + + await recordingFailure(config, 'post-checkout', async () => { + const { createProjectToolRoots } = await import('./project-agent-root.js'); + await createProjectToolRoots({ cwd }); + const { pull } = await import('./pull.js'); + await pull({ silent: true, inline: true, gitHook: 'post-checkout' }); + }); + // Learnings, reports, sources and the team repo itself refresh after + // `git worktree add` returns; it also retries what failed above. + await spawnDetachedPull(cwd, 'post-checkout'); + return null; + }, +}; + +/** How long `git pull` waits for the post-merge hook's team repo fetch. */ +const POST_MERGE_FETCH_CAP_MS = 5_000; + +/** + * `post-merge` from the git hook (`git pull`): the next session gets what + * changed. With a separate team repo, the team repo is fetched inline within + * POST_MERGE_FETCH_CAP_MS and delivered when its revision moved (the rev fast + * path skips it otherwise); past the cap, and for learnings, reports and + * sources, a detached pull takes over. In single-repo (self) mode the team + * repo is the working tree `git pull` just updated: delivered with no network. + */ +const gitPullHandler: HookHandler = { + name: 'git-pull', + async execute(stdin, _tool, config) { + if (!config || config.scope !== 'project') return null; + const cwd = resolveHookCwd(stdin) ?? process.cwd(); + const { getDataHome, isSelfMode } = await import('./types.js'); + const self = isSelfMode(config); + // teamai's own checkouts; in self mode the team repo is the member's. + const own = [getDataHome(config), ...await ownCheckoutsDir(cwd)]; + if (await isWithin(cwd, self ? own : [...own, config.repo.localPath])) return null; + + const { pull } = await import('./pull.js'); + await recordingFailure(config, 'post-merge', () => pull({ + silent: true, inline: true, gitHook: 'post-merge', fetchTimeoutMs: POST_MERGE_FETCH_CAP_MS, + })); + if (!self) await spawnDetachedPull(cwd, 'post-merge'); + return null; + }, +}; + +/** + * Run the inline pass of a git hook; what it throws is recorded (the hook is + * silent), not raised. + */ +async function recordingFailure( + config: LocalConfig, + event: 'post-checkout' | 'post-merge', + pass: () => Promise, +): Promise { + try { + await pass(); + } catch (e) { + const { recordGitHookFailure } = await import('./git-hook.js'); + await recordGitHookFailure(config, { kind: 'hook-error', event, at: new Date().toISOString(), error: (e as Error).message }); + } +} + +/** + * Start a full `teamai pull --silent` in `cwd` that this process does not wait + * for. TEAMAI_GIT_HOOK makes it record its failure, and clear the record when it + * succeeds. + */ +async function spawnDetachedPull(cwd: string, event: 'post-checkout' | 'post-merge'): Promise { + const { resolveCliEntry } = await import('./builtin-hooks.js'); + const { spawn } = await import('node:child_process'); + spawn(process.execPath, [resolveCliEntry() ?? '', 'pull', '--silent'], { + cwd, detached: true, stdio: 'ignore', windowsHide: true, env: { ...process.env, TEAMAI_GIT_HOOK: event }, + }).on('error', (e) => log.debug(`git hook: detached pull failed to start: ${e.message}`)).unref(); +} + +/** + * The main checkout's `.teamai/`, where teamai creates its knowledge worktree. + * Detection run from inside that worktree resolves the worktree as its own + * project root, so the config alone cannot tell it is teamai's. + */ +async function ownCheckoutsDir(cwd: string): Promise { + // Git refuses to open a directory that no longer exists. + if (!await pathExists(cwd)) return []; + const { resolveAnchors } = await import('./utils/git.js'); + const anchors = await resolveAnchors(cwd); + return anchors ? [path.join(anchors.projectAnchor, '.teamai')] : []; +} + +/** Whether `dir` is one of `parents` or inside one (real paths). */ +async function isWithin(dir: string, parents: string[]): Promise { + const { realpath } = await import('node:fs/promises'); + const real = (p: string) => realpath(p).catch(() => path.resolve(p)); + const target = await real(dir); + for (const parent of parents) { + const rel = path.relative(await real(parent), target); + if (!rel.startsWith('..') && !path.isAbsolute(rel)) return true; + } + return false; +} + const updateHandler: HookHandler = { name: 'update', async execute(_stdin, _tool) { @@ -978,6 +1094,13 @@ export function buildHandlerRegistry(): HandlerRegistration[] { { event: 'prompt-submit', matcher: '*', handler: trackSlashHandler, timeoutMs: FOREGROUND_HOOK_TIMEOUT_MS, requiresConfig: true }, { event: 'prompt-submit', matcher: '*', handler: dashboardReportHandler, timeoutMs: FOREGROUND_HOOK_TIMEOUT_MS, requiresConfig: true }, { event: 'prompt-submit', matcher: '*', handler: localAgentHandler, timeoutMs: FOREGROUND_HOOK_TIMEOUT_MS }, + + // ─── Git (`--tool git`, see git-hook.ts) ────────── + // Inline: the delivery has to land before `git worktree add` returns. Git + // has no hook timeout, so the budget is the detached pull's; post-merge + // caps its own fetch. + { event: 'post-checkout', matcher: '*', handler: newWorktreeHandler, timeoutMs: PULL_TIMEOUT_MS, requiresConfig: true }, + { event: 'post-merge', matcher: '*', handler: gitPullHandler, timeoutMs: PULL_TIMEOUT_MS, requiresConfig: true }, ]; } diff --git a/src/hooks.ts b/src/hooks.ts index 78e924a7c..e4c7c6d73 100644 --- a/src/hooks.ts +++ b/src/hooks.ts @@ -1868,6 +1868,7 @@ export async function reconcileTeamHooksForConfig( // pass would actually reach, which is what hookToolPaths decides below too. const hookToolPaths = scopedToolPaths(teamConfig, { ...localConfig, scope: hookScope }); if (opts.dryRun) { + if (!opts.removeAll) await installProjectGitHook(localConfig, { dryRun: true }); if (!opts.removeAll && 'pi' in hookToolPaths && (!filterAgents || filterAgents.includes('pi'))) { await reportPiSkippedTeamHooks(teamDefs, baseDir, localConfig.scope === 'project' ? (localConfig.projectRoot ?? baseDir) : undefined, builtin); } @@ -1912,11 +1913,37 @@ export async function reconcileTeamHooksForConfig( ); } } + if (!builtinsOnly) await sweepLegacyProjectHooks(teamConfig.toolPaths, localConfig); + // Last, so a repository whose hooks cannot be written still gets the agent + // hooks above; the error then reaches the caller. + if (!opts.removeAll) await installProjectGitHook(localConfig); if (builtinsOnly) return { ok: false, builtins: builtinsOnly }; - await sweepLegacyProjectHooks(teamConfig.toolPaths, localConfig); return { ok: true, defs: teamDefs }; } +/** + * Project scope: install teamai's git hook (git-hook.ts) in the repository, so + * a new worktree gets the team's resources before `git worktree add` returns. + * User scope installs none: its resources live in HOME, which a new worktree + * does not change. Installation errors propagate to the caller. + */ +async function installProjectGitHook(localConfig: LocalConfig, opts: { dryRun?: boolean } = {}): Promise { + if (localConfig.scope !== 'project' || !localConfig.projectRoot) return; + const { installGitHook } = await import('./git-hook.js'); + let result: Awaited>; + try { + result = await installGitHook(localConfig.projectRoot, opts); + } catch (e) { + throw new Error(`Could not install the teamai git hook in ${localConfig.projectRoot}: ${(e as Error).message}. ` + + 'New worktrees and `git pull` get the team\'s resources only at the next session. ' + + 'Fix the cause, then run `teamai pull` to install it.', { cause: e }); + } + if (opts.dryRun && result.installed && result.changed) { + log.info(`Would install or update the teamai git hook (post-checkout, post-merge) in ${localConfig.projectRoot}`); + } + if (!result.installed) log.debug(`git hook: not installed in ${localConfig.projectRoot} (${result.reason})`); +} + /** * The line `init` and bootstrap print when the team hooks did not resolve. The * reason, naming the file, was already reported by the resolution. diff --git a/src/index.ts b/src/index.ts index 4927bb005..8e5fa68af 100644 --- a/src/index.ts +++ b/src/index.ts @@ -997,15 +997,16 @@ program }); program - .command('hook-dispatch ', { hidden: true }) + .command('hook-dispatch [hookArgs...]', { hidden: true }) .description('Unified hook dispatcher — handles all teamai hooks for a given event in one process') .option('--stdin', 'Read hook data from STDIN (accepted for forward compat, always reads STDIN)') .option('--tool ', 'Tool identifier (e.g. codebuddy, workbuddy, claude)') .option('--matcher ', 'Hook matcher for PostToolUse (e.g. Skill, Bash)') .option('--bg-only', 'Internal: run only fire-and-forget background handlers (used by the detached child)') .option('--stdin-file ', 'Internal: read the hook payload from this file instead of STDIN') - .action(async (event: string, cmdOpts: { stdin?: boolean; tool?: string; matcher?: string; bgOnly?: boolean; stdinFile?: string }) => { + .action(async (event: string, hookArgs: string[], cmdOpts: { stdin?: boolean; tool?: string; matcher?: string; bgOnly?: boolean; stdinFile?: string }) => { const bgOnly = cmdOpts.bgOnly ?? false; + const tool = cmdOpts.tool ?? 'claude'; // Hard wall-clock safety net for the FOREGROUND (parent) hook process, which // blocks the host IDE's hook. The host aborts a hook at ~10s regardless of @@ -1018,15 +1019,17 @@ program // The detached `--bg-only` child is unref'd and not awaited by the host, so // it is exempt and keeps its full budget to finish real syncs/downloads. const HOOK_HARD_EXIT_MS = 7_000; + // Git (`--tool git`) has no hook timeout to stay under, and a cut here + // would stop the inline delivery mid-write; its handler bounds itself. let hardExit: NodeJS.Timeout | undefined; - if (!bgOnly) { + if (!bgOnly && tool !== 'git') { hardExit = setTimeout(() => process.exit(0), HOOK_HARD_EXIT_MS); hardExit.unref(); } const { hookDispatchCli } = await import('./hook-dispatch-cli.js'); try { - await hookDispatchCli(event, cmdOpts.tool ?? 'claude', cmdOpts.matcher ?? '*', cmdOpts); + await hookDispatchCli(event, tool, cmdOpts.matcher ?? '*', { ...cmdOpts, hookArgs }); } finally { if (hardExit) clearTimeout(hardExit); // Hook subprocesses must exit promptly: a hung/unreachable backend fetch can diff --git a/src/init.ts b/src/init.ts index 807066afd..784eac3c7 100644 --- a/src/init.ts +++ b/src/init.ts @@ -788,7 +788,15 @@ async function reconcileHooksForInit( localConfig: LocalConfig, filterAgents: string[] | undefined, ): Promise { - const reconciled = await reconcileTeamHooksForConfig(teamConfig, localConfig, { filterAgents }); + let reconciled: Awaited>; + try { + reconciled = await reconcileTeamHooksForConfig(teamConfig, localConfig, { filterAgents }); + } catch (e) { + // The agent hooks are in place; the rest of init (its pull) still runs. + log.error((e as Error).message); + process.exitCode = 1; + return; + } if (!reconciled.ok) log.warn(describeUnappliedTeamHooks(reconciled)); // The hooks install the extensions and plugins that add team instructions // for Pi, OMP and Hermes; name what keeps a tool from getting them (#945). @@ -1020,6 +1028,8 @@ export async function promptForSelfModeAgents(options: { agent?: string | string[]; silent?: boolean; force?: boolean; + /** Project-scope init: asks which tools the member uses here, commits nothing. */ + projectScope?: boolean; }): Promise { const explicit = normalizeAgentList(options.agent); if (explicit.length > 0) return explicit; @@ -1046,8 +1056,13 @@ export async function promptForSelfModeAgents(options: { : 'Auto — none detected (will set up Claude Code)'; console.log(''); - console.log('Which AI tools should teamai set up in this repo?'); - console.log('(creates the skills dir, injects hooks, commits settings to main)'); + if (options.projectScope) { + console.log('Which AI tools do you use in this project?'); + console.log('(creates their tool dirs here and syncs the team\'s resources into them)'); + } else { + console.log('Which AI tools should teamai set up in this repo?'); + console.log('(creates the skills dir, injects hooks, commits settings to main)'); + } console.log(''); console.log(` 1. ${autoLabel}`); tools.forEach((t, i) => { @@ -2011,6 +2026,13 @@ export async function init(options: GlobalOptions & { // Persist --agent into enabledAgents (additive across runs) const requestedAgents = normalizeAgentList(options.agent); + // Interactive project init without --agent asks which tools the member uses. + // Non-interactive runs skip this on purpose: the picker's own non-TTY branch + // mirrors HOME tools, while project init then creates no root. + if (scope === 'project' && requestedAgents.length === 0 + && !options.silent && !options.force && isInteractive()) { + requestedAgents.push(...await promptForSelfModeAgents({ projectScope: true })); + } if (requestedAgents.length > 0) { // As loaded before the clone: that config may have been moved aside since. const prev = carriedConfig?.enabledAgents ?? []; @@ -2063,6 +2085,16 @@ export async function init(options: GlobalOptions & { // Non-critical: state file may not exist yet on first init } + // Step 6.6: project scope creates the roots of the tools the member chose + // (`--agent`), so the stub below and the closing pull have somewhere to + // write. Runs after the config is saved: createProjectToolRoots filters by + // the saved enabledAgents, which now includes this run's choice. Without + // `--agent` no root is invented, as before. + if (scope === 'project' && requestedAgents.length > 0) { + const { createProjectToolRoots } = await import('./project-agent-root.js'); + await createProjectToolRoots({ cwd: projectRoot, tools: requestedAgents }); + } + // Step 7: Inject built-in + team hooks into AI tools const reloadedTeamConfig = await loadTeamConfig(localPath); // Only a stub that actually landed is announced as ready in the IDE. @@ -2079,7 +2111,8 @@ export async function init(options: GlobalOptions & { // installed" (#867). Built-in tools are left untouched here: their root // already existing is exactly what doctor's "is installed" check verifies // (#598), so seeding them outside self mode would silently manufacture a - // directory for software that was never actually installed. + // directory for software that was never actually installed. Only the + // project roots of tools named with `--agent` are created (Step 6.6). try { const { seedSelfModeToolDirs } = await import('./known-agents.js'); const seeded = await seedSelfModeToolDirs(localConfig, reloadedTeamConfig); @@ -2104,6 +2137,14 @@ export async function init(options: GlobalOptions & { } } + // Step 8: deliver the team's resources now. Rules and MCP are read once at + // session start, before the SessionStart hook syncs, so without this the + // first session after init runs without them. Failures are reported by pull + // in its own words; init itself has succeeded. + const { pull } = await import('./pull.js'); + // Inside a project checkout, a bare pull would detect that project instead. + await pull({ verbose: options.verbose, interactive: true, userScopeOnly: localConfig.scope === 'user' }); + log.success('teamai initialized successfully!'); if (stubDeployed > 0) { log.info('The built-in teamai skill is ready in your IDE; it loads its workflows with `teamai skill get`.'); diff --git a/src/project-agent-root.ts b/src/project-agent-root.ts index fc9f2ee76..9392e659d 100644 --- a/src/project-agent-root.ts +++ b/src/project-agent-root.ts @@ -1,9 +1,11 @@ +import { stat } from 'node:fs/promises'; import path from 'node:path'; import { detectProjectConfig, loadTeamConfig } from './config.js'; import { KNOWN_AGENTS } from './known-agents.js'; import { isAgentDisabled, resolveBaseDir, scopedToolPaths, toolInstallRoot } from './types.js'; import { ensureDir } from './utils/fs.js'; +import { resolveAnchors } from './utils/git.js'; import { log } from './utils/logger.js'; /** @@ -33,33 +35,88 @@ function resolveSkillsPath( } /** - * Project-scope SessionStart: create the *current* agent's install root under - * the project (e.g. `/.claude`) so a subsequent `teamai pull` has - * somewhere to write. Bare `teamai pull` / `teamai init` still do not create - * agent roots — only the hook that knows which tool just opened does. + * Project scope: create the install roots of a set of tools under the current + * checkout (e.g. `/.claude`, `/.codex`) so a subsequent + * `teamai pull` has somewhere to write. Bare `teamai pull` does not create + * agent roots; only callers that know which tools the member uses do. * - * No-ops when: not project scope, the tool is disabled / not in enabledAgents, - * the tool is unknown, or the resolved root would escape the project. + * `tools` defaults to `enabledAgents`; when that is empty too, to the tools + * whose root the main checkout already has (a linked worktree then looks like + * the checkout it came from). A tool is skipped when it is disabled, outside a + * non-empty `enabledAgents`, unknown, or its resolved root would escape the + * checkout. No-op outside project scope. */ -export async function seedProjectAgentRoot(tool: string, cwd?: string): Promise { - const id = tool.trim(); - if (!id) return; +export async function createProjectToolRoots(options: { + cwd?: string; + tools?: readonly string[]; +} = {}): Promise { + const requested = options.tools && normalizeIds(options.tools); + if (requested?.length === 0) return; - const projectConfig = await detectProjectConfig(cwd); + const projectConfig = await detectProjectConfig(options.cwd); if (!projectConfig) return; - if (isAgentDisabled(projectConfig, id)) return; - const enabled = projectConfig.enabledAgents; - if (enabled && enabled.length > 0 && !enabled.includes(id)) return; - + const enabled = projectConfig.enabledAgents ?? []; const teamConfig = await loadTeamConfig(projectConfig.repo.localPath); - const skillsPath = resolveSkillsPath(id, teamConfig, projectConfig); - if (!skillsPath) return; + const baseDir = resolveBaseDir(projectConfig); + + const rootOf = (id: string): string | undefined => { + if (isAgentDisabled(projectConfig, id)) return undefined; + if (enabled.length > 0 && !enabled.includes(id)) return undefined; + const skillsPath = resolveSkillsPath(id, teamConfig, projectConfig); + if (!skillsPath) return undefined; + const root = toolInstallRoot(skillsPath); + return isSafeRelativeRoot(root) ? root : undefined; + }; + + const ids = requested + ?? (enabled.length > 0 ? normalizeIds(enabled) : await toolsInMainCheckout(baseDir, teamConfig, rootOf)); + + for (const id of ids) { + const root = rootOf(id); + if (!root) continue; + const dest = path.join(baseDir, root); + await ensureDir(dest); + log.debug(`Seeded project agent root for ${id}: ${dest}`); + } +} + +function normalizeIds(tools: readonly string[]): string[] { + return [...new Set(tools.map((tool) => tool.trim()).filter(Boolean))]; +} - const root = toolInstallRoot(skillsPath); - if (!isSafeRelativeRoot(root)) return; +/** Tools (known or named in teamai.yaml toolPaths) whose root exists in the main checkout of `checkout`. */ +async function toolsInMainCheckout( + checkout: string, + teamConfig: Awaited>, + rootOf: (id: string) => string | undefined, +): Promise { + const mainCheckout = (await resolveAnchors(checkout))?.projectAnchor; + if (!mainCheckout || mainCheckout === checkout) return []; + const candidates = normalizeIds([ + ...KNOWN_AGENTS.map((agent) => agent.id), + ...Object.keys(teamConfig?.toolPaths ?? {}), + ]); + const present: string[] = []; + for (const id of candidates) { + const root = rootOf(id); + if (root && (await isDirectory(path.join(mainCheckout, root)))) present.push(id); + } + return present; +} - const dest = path.join(resolveBaseDir(projectConfig), root); - await ensureDir(dest); - log.debug(`Seeded project agent root for ${id}: ${dest}`); +async function isDirectory(p: string): Promise { + try { + return (await stat(p)).isDirectory(); + } catch { + return false; + } +} + +/** + * Project-scope SessionStart: create the *current* agent's install root, the + * one tool that just opened. See {@link createProjectToolRoots}. + */ +export async function seedProjectAgentRoot(tool: string, cwd?: string): Promise { + await createProjectToolRoots({ cwd, tools: [tool] }); } diff --git a/src/pull.ts b/src/pull.ts index a9db45a31..8aa9e7af0 100644 --- a/src/pull.ts +++ b/src/pull.ts @@ -12,7 +12,7 @@ import { publishQueuedLearnings } from './utils/learnings-publish.js'; import { pendingLearningsDir } from './utils/pending-learnings.js'; import { indexableLearningsRoots } from './utils/learnings-roots.js'; import { log, spinner } from './utils/logger.js'; -import { pathExists, remove, listFiles, listDirs, listFilesRecursive, readFileSafe, dirContentEqual, hasVcsMetadataRecursive } from './utils/fs.js'; +import { pathExists, readJson, writeJson, remove, listFiles, listDirs, listFilesRecursive, readFileSafe, dirContentEqual, hasVcsMetadataRecursive } from './utils/fs.js'; import { reconcilePlacementRecords } from './utils/pending-push.js'; import { applyInstructionPlan, hookLimitProblem, instructionHookChannel, instructionHookChannelInstallable, instructionHookText, planInstructionFiles, @@ -50,6 +50,7 @@ import { SYNC_LOCK_FILENAME, usesBranchWorktree, managedMcpWorkspaceId, + SOURCE_PULL_TTL_MS, } from './types.js'; import type { CultureFrontmatter } from './types.js'; import { deliversEveryNamespace } from './resource-namespaces.js'; @@ -70,6 +71,27 @@ import { runDeclaredPostPull } from './post-pull.js'; let pendingUsageReport: Promise | undefined; const FILE_NOT_FOUND_ERROR_CODE = 'ENOENT'; +/** + * When the team clone was last fetched, beside the clone (as a source cache's + * last-pull.json). A fresh clone has no FETCH_HEAD, and state.lastPull moves + * only on a full sync, so neither can tell. + */ +function teamFetchStamp(localConfig: LocalConfig): string { + return path.join(path.dirname(localConfig.repo.localPath), 'last-fetch.json'); +} + +/** How long an inline pull waits for another pull's partition lock. */ +const INLINE_LOCK_WAIT_MS = 60_000; + +/** `Error.name` of a team repo fetch stopped at `fetchTimeoutMs`. */ +const TEAM_FETCH_TIMEOUT = 'TeamFetchTimeout'; + +async function teamFetchedWithinTtl(localConfig: LocalConfig): Promise { + const stamp = await readJson<{ lastFetch: string }>(teamFetchStamp(localConfig)); + const elapsed = stamp ? Date.now() - new Date(stamp.lastFetch).getTime() : NaN; + return Number.isFinite(elapsed) && elapsed >= 0 && elapsed <= SOURCE_PULL_TTL_MS; +} + /** * Refresh the local team-repo tree, abstracting the two backends. * @@ -93,7 +115,7 @@ const FILE_NOT_FOUND_ERROR_CODE = 'ENOENT'; */ async function refreshTeamRepo( localConfig: LocalConfig, - options: { dryRun?: boolean } = {}, + options: Pick = {}, ): Promise<{ label: string; version: string | null; submodulesFailed: boolean; submodulesChanged: boolean }> { if (localConfig.repo.kind === 'http') { const { resolveApiKey } = await import('./api-key.js'); @@ -142,7 +164,23 @@ async function refreshTeamRepo( // the reconcile/source/report stages — so there is no unlocked window in which // another writer could reset/checkout the tree. We must NOT lock here: the lock // is non-reentrant, so re-acquiring it in the same process would fail. - const result = await pullRepo(localConfig.repo.localPath); + // The new-worktree hook reads a recently fetched clone as it is. + if (options.inline && options.fetchTimeoutMs === undefined && await teamFetchedWithinTtl(localConfig)) { + const version = await getHeadRev(localConfig.repo.localPath).catch(() => null); + return { label: 'fetched within the TTL, not refetched', version, submodulesFailed: false, submodulesChanged: false }; + } + // The post-merge hook caps the fetch: git pull is waiting on it. + const cap = options.fetchTimeoutMs === undefined ? undefined : AbortSignal.timeout(options.fetchTimeoutMs); + const result = await pullRepo(localConfig.repo.localPath, cap).catch((e: unknown) => { + if (cap?.aborted) { + const timeout = new Error(`team repo fetch exceeded ${options.fetchTimeoutMs} ms, left to the detached pull`); + timeout.name = TEAM_FETCH_TIMEOUT; + throw timeout; + } + throw e; + }); + await writeJson(teamFetchStamp(localConfig), { lastFetch: new Date().toISOString() }) + .catch((e) => log.debug(`Could not record the team repo fetch: ${(e as Error).message}`)); let version: string | null = null; try { @@ -174,7 +212,7 @@ async function refreshTeamRepo( // The leading status char is `-` while uninitialized and ` ` (or `+` when // the checkout is behind its pin) afterwards, so a changed status string // means the on-disk tree the deploy step reads is not what was cached. - const git = createGit(localConfig.repo.localPath); + const git = createGit(localConfig.repo.localPath, cap); // Only the status read is guarded here: an unavailable/unsupported status // must degrade to "changed" (see below), NOT be reported as an update // failure — the update itself is still allowed to fail into the outer @@ -905,7 +943,7 @@ async function pullForScope( revisionField?: 'lastPullRev' | 'lastInheritedPullRev'; } = {}, /** Set to `{ completed: true }` on a real (non-dry-run) sync. See pull(). */ - result?: { completed: boolean; docsSyncFailed: boolean; agentModelsHeld: boolean }, + result?: { completed: boolean; docsSyncFailed: boolean; agentModelsHeld: boolean; teamRepoFailed?: boolean; resourceSyncFailed?: boolean }, /** Collects this scope's env resolution for the stages after it (see resolvePullEnv). */ teamEnvs?: Map, instructions?: Map, @@ -935,6 +973,7 @@ async function pullForScope( const refresh = await refreshTeamRepo(localConfig, options); currentRev = refresh.version; submodulesFailed = refresh.submodulesFailed; + if (result && submodulesFailed) result.resourceSyncFailed = true; submodulesChanged = refresh.submodulesChanged; const outcome = `[${scopeLabel}] Team repo: ${refresh.label}`; pullSpin.succeed(outcome); @@ -943,6 +982,14 @@ async function pullForScope( const reason = `[${scopeLabel}] Pull failed: ${(e as Error).message}`; pullSpin.fail(reason); log.persist(reason); + if (result) result.teamRepoFailed = true; + if (options.gitHook && localConfig.scope === 'project') { + const { recordGitHookFailure } = await import('./git-hook.js'); + const at = new Date().toISOString(); + await recordGitHookFailure(localConfig, (e as Error).name === TEAM_FETCH_TIMEOUT + ? { kind: 'fetch-timeout', event: options.gitHook, at, capMs: options.fetchTimeoutMs ?? 0 } + : { kind: 'fetch-failed', event: options.gitHook, at, error: (e as Error).message }); + } return; } @@ -974,7 +1021,8 @@ async function pullForScope( // pull will retry, and that has to hold in every mode. pull() holds the // partition sync lock across this scope and the lock is not reentrant, so // publishing must not try to take it again. Never let it block the pull. - try { + // Inline (new-worktree hook) it is left to the detached pull after it. + if (!options.inline) try { const queue = await publishQueuedLearnings(localConfig, localConfig.username, { holdsSyncLock: true, dryRun: options.dryRun }); if (options.dryRun) { if (queue.remaining > 0) log.info(`[${scopeLabel}] [dry-run] Would publish ${queue.remaining} queued learning(s)`); @@ -999,6 +1047,7 @@ async function pullForScope( // be able to fetch it from the remote instead of skipping forever. const freshConfig = await loadTeamConfig(localConfig.repo.localPath); if (!freshConfig) { + if (result) result.resourceSyncFailed = true; log.warn(`[${scopeLabel}] Team config (teamai.yaml) not found. Skipping.`); return; } @@ -1010,6 +1059,7 @@ async function pullForScope( roleContext = await buildRolePullContext(localConfig); } catch (e) { log.error(`[${scopeLabel}] ${(e as Error).message}`); + if (result) result.resourceSyncFailed = true; return; } @@ -1067,7 +1117,8 @@ async function pullForScope( // and it never publishes, so running it on the fast path cannot flush pending // learnings outside the caller's partition sync lock. const syncLearningsAndRebuildIndex = async (): Promise => { - if (options.dryRun) return; + // Inline (new-worktree hook): the detached pull after it does this. + if (options.dryRun || options.inline) return; try { // Bring the learnings branch up to date before reading it, or a member // only ever sees their own contributions. Read-only: a cold start @@ -1357,7 +1408,10 @@ async function pullForScope( // resolved set is what removes a deactivated namespace's variables. A // file that cannot be used, or a name defined twice, keeps env.sh as is. const variables = deliverableEnvVariables(await resolvePullEnv(localConfig, roleContext, teamEnvs)); - if (!variables) continue; + if (!variables) { + if (result) result.resourceSyncFailed = true; + continue; + } const countLabel = `${variables.length} env variable(s)`; if (options.dryRun) { @@ -1409,6 +1463,7 @@ async function pullForScope( // Only skills stop: nothing is installed or swept for them this run. log.warn(`[${scopeLabel}] ${describeDeliveryConflict(desired)}. Skills were not updated this run; the installed ones are kept.`); skillsHeld = true; + if (result) result.resourceSyncFailed = true; continue; } items = desired.items; @@ -1424,6 +1479,7 @@ async function pullForScope( // and leaves them alone too. log.warn(`[${scopeLabel}] ${describeDeliveryConflict(desired)}. Agents were not updated this run; the installed ones are kept.`); agentsHeld = true; + if (result) result.resourceSyncFailed = true; continue; } items = desired.items; @@ -2128,6 +2184,17 @@ async function reinjectLegacyHooks(localConfig: LocalConfig): Promise { log.debug('Hooks migrated to dispatch format'); } +/** Say the failure the git hook recorded. A pull that then completes clears it (see pull()). */ +async function mentionGitHookFailure(config: LocalConfig, reported: Set): Promise { + const { readGitHookFailure, describeGitHookFailure } = await import('./git-hook.js'); + const failure = await readGitHookFailure(config); + if (!failure) return; + const { message, fix } = describeGitHookFailure(failure); + log.warn(`Last git hook run failed: ${message}`); + log.dim(` → ${fix}`); + reported.add('git-hook-failure'); +} + /** * Main pull entry point. * @@ -2150,12 +2217,19 @@ export async function pull( // Warnings about the team repo are said once per pull, not once per scope or // per resolution, and every pull says them again. resetWarnOnce(); + // A pull a git hook started: inline, or the detached one it hands over to. + const { isGitHookEvent } = await import('./git-hook.js'); + const hookEnv = process.env.TEAMAI_GIT_HOOK; + // Read once; the scripts this pull runs (postPull) must not inherit it. + delete process.env.TEAMAI_GIT_HOOK; + if (!options.gitHook && isGitHookEvent(hookEnv)) options = { ...options, gitHook: hookEnv }; // What the scopes below say in their own words, so the post-pull pass does // not repeat it. Owned here rather than at module scope so nothing survives // into another call. const reported = new Set(); // A later successful scope must not hide an earlier docs failure (or vice versa). - const syncResult = { completed: false, docsSyncFailed: false, agentModelsHeld: false }; + const syncResult = { completed: false, docsSyncFailed: false, agentModelsHeld: false, teamRepoFailed: false, resourceSyncFailed: false }; + const startupErrors: string[] = []; // Each scope's env, resolved once by its env stage (resolvePullEnv). const teamEnvs = new Map(); const instructions = new Map(); @@ -2194,7 +2268,19 @@ export async function pull( // what the real run would have found: a live holder reports this scope as // contended and skips it, exactly as a real pull does. Nothing is recorded // for release, because nothing was taken. - if (await acquireLock(lock, { dryRun: options.dryRun })) { + // Inline (new-worktree hook), a skipped scope is a worktree without the + // team's resources, and the holder is often the detached pull of the + // worktree created just before: wait for it, within the hook's budget. + // Post-merge caps it like its fetch: `git pull` waits on it, and the holder + // may be a detached pull hung on the network. + const lockWaitMs = options.fetchTimeoutMs ?? INLINE_LOCK_WAIT_MS; + const waitUntil = options.inline ? Date.now() + lockWaitMs : 0; + let acquired = await acquireLock(lock, { dryRun: options.dryRun }); + while (!acquired && Date.now() < waitUntil) { + await new Promise((resolve) => setTimeout(resolve, 200)); + acquired = await acquireLock(lock, { dryRun: options.dryRun }); + } + if (acquired) { if (!options.dryRun) heldLocks.set(config, lock); return true; } @@ -2203,6 +2289,15 @@ export async function pull( // once the other process finishes syncs it normally. log.info(`[${config.scope}] sync in progress elsewhere — skipped (another pull/push holds the lock)`); contended.add(config); + startupErrors.push(`[${config.scope}] sync lock is held by another teamai process`); + // A detached hook pull skipping is fine: the holder syncs. An inline one + // leaves the checkout without what the next session reads. + if (options.inline && options.gitHook && config.scope === 'project') { + const { recordGitHookFailure } = await import('./git-hook.js'); + await recordGitHookFailure(config, { + kind: 'lock-held', event: options.gitHook, at: new Date().toISOString(), waitedMs: lockWaitMs, + }); + } return false; }; @@ -2212,14 +2307,16 @@ export async function pull( // processed at all (issue #73: project install isolates from user). let projectConfig: LocalConfig | null = null; const unreadable: string[] = []; - try { - projectConfig = await detectProjectConfig( - undefined, - (configPath, error) => { unreadable.push(`${configPath}: ${error}`); }, - { dryRun: options.dryRun }, - ); - } catch (e) { - log.warn(`Project-scope detection error: ${(e as Error).message}`); + if (!options.userScopeOnly) { + try { + projectConfig = await detectProjectConfig( + undefined, + (configPath, error) => { unreadable.push(`${configPath}: ${error}`); }, + { dryRun: options.dryRun }, + ); + } 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 @@ -2270,17 +2367,21 @@ export async function pull( } } catch (e) { log.warn(`User-scope pull error: ${(e as Error).message}`); + startupErrors.push(`User-scope pull: ${(e as Error).message}`); } } // 3. Project scope. if (projectConfig) { + // The git hook runs silently; what failed in it is said here, once. + if (!options.silent && !options.dryRun) await mentionGitHookFailure(projectConfig, reported); try { if (await lockScope(projectConfig)) { await pullForScope(projectConfig, options, reported, {}, syncResult, teamEnvs, instructions); } } catch (e) { log.warn(`Project-scope pull error: ${(e as Error).message}`); + startupErrors.push(`Project-scope pull: ${(e as Error).message}`); } } @@ -2309,8 +2410,9 @@ export async function pull( if (migrateScope) { try { await reinjectLegacyHooks(migrateScope); - } catch { + } catch (e) { // Non-fatal — pull continues even if hook migration fails. + startupErrors.push(`Hook migration: ${(e as Error).message}`); } } } @@ -2320,11 +2422,11 @@ export async function pull( // what self-heals new built-in hooks and applies hooks.yaml changes on every // session start. In project mode user is null, even when safe resources are // inherited, so executable hook configuration is never composed implicitly. - await reconcileHooksAllScopes(reconcileUser, reconcileProject, options, instructions); + startupErrors.push(...await reconcileHooksAllScopes(reconcileUser, reconcileProject, options, instructions)); // 3.6. Reconcile team MCP servers. Outside pullForScope for the same reason as // hooks. User-scope MCP remains isolated in project mode. - await reconcileMcpAllScopes(reconcileUser, reconcileProject, options, teamEnvs); + startupErrors.push(...await reconcileMcpAllScopes(reconcileUser, reconcileProject, options, teamEnvs)); // 3.6b. What the member should run for a team secret with no value (#875). // Not on the silent session-start pull: its output is discarded, and it runs @@ -2343,7 +2445,7 @@ export async function pull( // truncates only its own file. Dashboard sessions live in one shared file // and are filtered instead: each target gets the sessions its scope // recorded (#785). - if (!options.dryRun && !pendingUsageReport) { + if (!options.dryRun && !options.inline && !pendingUsageReport) { pendingUsageReport = (async () => { try { const { reportUsageToTeam } = await import('./team-push.js'); @@ -2435,6 +2537,26 @@ export async function pull( await pullSources(sourceConfig, options); } catch (e) { log.debug(`Source pull skipped: ${(e as Error).message}`); + startupErrors.push(`Source skills: ${(e as Error).message}`); + } + } + + // Fetching alone is not success: every startup delivery stage must finish + // before a hook retry, or the interactive pull that mentioned the failure, + // may erase it. Preserve the more specific fetch/lock records those stages + // already wrote. + const retriesHookFailure = Boolean(options.gitHook) || reported.has('git-hook-failure'); + if (retriesHookFailure && !options.dryRun && reconcileProject && !syncResult.teamRepoFailed) { + if (syncResult.docsSyncFailed) startupErrors.push('Docs delivery failed'); + if (syncResult.agentModelsHeld) startupErrors.push('Agent models could not be resolved'); + if (syncResult.resourceSyncFailed) startupErrors.push('Resource delivery did not complete'); + const { clearGitHookFailure, recordGitHookFailure } = await import('./git-hook.js'); + if (startupErrors.length > 0) { + if (options.gitHook) await recordGitHookFailure(reconcileProject, { + kind: 'hook-error', event: options.gitHook, at: new Date().toISOString(), error: startupErrors.join('; '), + }); + } else { + await clearGitHookFailure(reconcileProject); } } @@ -2468,7 +2590,7 @@ export async function pull( // usage report (above) may still hold them; its writes go to the reports // worktree, not this clone's tree. Launch shape: post-pull.ts. Nothing // here can fail the pull. - if (!options.dryRun && postPullRepo) { + if (!options.dryRun && !options.inline && postPullRepo) { await runDeclaredPostPull(postPullRepo, { interactive: options.interactive === true }); } } @@ -2575,7 +2697,8 @@ async function reconcileHooksAllScopes( projectConfig: LocalConfig | null, options: GlobalOptions, instructions: Map, -): Promise { +): Promise { + const errors: string[] = []; // A dry run still resolves the entries, so the warnings a maintainer runs // `--dry-run` to see — an unknown id, a deprecated per-entry `roles:`, a // hooks.yaml that does not parse — are reported; only the writes are skipped, @@ -2584,7 +2707,10 @@ async function reconcileHooksAllScopes( for (const localConfig of scopes) { try { const teamConfig = await loadTeamConfig(localConfig.repo.localPath); - if (!teamConfig) continue; + if (!teamConfig) { + errors.push(`[${localConfig.scope}] Hooks: team config could not be loaded`); + continue; + } const { reconcileTeamHooksForConfig } = await import('./hooks.js'); const reconciled = await reconcileTeamHooksForConfig(teamConfig, localConfig, { auto: true, @@ -2592,6 +2718,7 @@ async function reconcileHooksAllScopes( filterAgents: localConfig.enabledAgents, dryRun: options.dryRun, }); + if (!reconciled.ok) errors.push(`[${localConfig.scope}] Team hooks could not be resolved`); if (reconciled.ok && reconciled.defs.length > 0) { // Same preview rule as the user-facing line: a dry run resolved and // reported the entries but wrote nothing, so the debug trail must not @@ -2629,8 +2756,10 @@ async function reconcileHooksAllScopes( } } catch (e) { log.debug(`[${localConfig.scope}] Hook reconcile skipped: ${(e as Error).message}`); + errors.push(`[${localConfig.scope}] Hooks: ${(e as Error).message}`); } } + return errors; } /** @@ -2643,7 +2772,8 @@ async function reconcileMcpAllScopes( projectConfig: LocalConfig | null, options: GlobalOptions, teamEnvs: Map, -): Promise { +): Promise { + const errors: string[] = []; // Same contract as the hooks stage: resolve and report the entry warnings on // a dry run, skip the writes. `reconcileMcpForConfig` already gates every // write on `dryRun` (the `mcp inject --dry-run` path uses it), so this only @@ -2652,11 +2782,15 @@ async function reconcileMcpAllScopes( for (const localConfig of scopes) { try { const teamConfig = await loadTeamConfig(localConfig.repo.localPath); - if (!teamConfig) continue; + if (!teamConfig) { + errors.push(`[${localConfig.scope}] MCP: team config could not be loaded`); + continue; + } const { reconcileMcpForConfig } = await import('./mcp-reconcile.js'); - const { changes } = await reconcileMcpForConfig(teamConfig, localConfig, { + const { changes, unresolved } = await reconcileMcpForConfig(teamConfig, localConfig, { force: options.force, dryRun: options.dryRun, teamEnv: await scopeEnv(localConfig, teamEnvs), }); + if (unresolved) errors.push(`[${localConfig.scope}] Team MCP configuration could not be resolved`); const applied = changes.filter((c) => c.action !== 'skipped'); for (const c of changes) { @@ -2675,8 +2809,10 @@ async function reconcileMcpAllScopes( } } catch (e) { log.debug(`[${localConfig.scope}] MCP reconcile skipped: ${(e as Error).message}`); + errors.push(`[${localConfig.scope}] MCP: ${(e as Error).message}`); } } + return errors; } /** diff --git a/src/source.ts b/src/source.ts index 75ae912e5..294085c51 100644 --- a/src/source.ts +++ b/src/source.ts @@ -198,8 +198,10 @@ async function recordSourcePull(source: SourceConfig): Promise { /** * Clone or pull a source repo. Returns the repo path, or null on failure. */ -async function ensureSourceRepo(source: SourceConfig, force: boolean, dryRun = false): Promise { +async function ensureSourceRepo(source: SourceConfig, force: boolean, dryRun = false, offline = false): Promise { const repoDir = getSourceRepoDir(source); + // The new-worktree hook reads the cached clone, as a dry run does (#929). + if (offline) return await pathExists(repoDir) ? repoDir : null; if (dryRun) { if (!await pathExists(repoDir)) { log.info(`[dry-run] [source:${source.name}] Would clone the repository; no cached skills are available to preview.`); @@ -555,7 +557,7 @@ async function sourceBrowseLocked(name: string, options: GlobalOptions, localCon } // Ensure source repo is cloned - const repoDir = await ensureSourceRepo(source, !!options.force, !!options.dryRun); + const repoDir = await ensureSourceRepo(source, !!options.force, !!options.dryRun, !!options.inline); if (!repoDir) { if (!options.dryRun) log.error(`Could not access source "${name}".`); return; @@ -639,7 +641,7 @@ async function pullSingleSource( options: GlobalOptions, ): Promise { // Ensure source repo is cloned/updated - const repoDir = await ensureSourceRepo(source, !!options.force, !!options.dryRun); + const repoDir = await ensureSourceRepo(source, !!options.force, !!options.dryRun, !!options.inline); if (!repoDir) return; // Load source's teamai.yaml diff --git a/src/types.ts b/src/types.ts index 9dcc42884..4812bc40b 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1072,6 +1072,30 @@ export interface GlobalOptions { * the confirmation prompt (`remove`). */ force?: boolean; + /** + * Internal (the new-worktree git hook, `pull` only): deliver what the next + * session reads, without network when the team clone was fetched within + * SOURCE_PULL_TTL_MS; sources come from their cached clones. Learnings, + * reports, usage reporting and post-pull scripts are left to a full pull. + */ + inline?: boolean; + /** + * Internal (the post-merge git hook, with `inline`): fetch the team repo + * whatever its fetch stamp says, and give up after this many ms; the scope + * is then not delivered and the detached pull after the hook does it. + */ + fetchTimeoutMs?: number; + /** + * Internal (`pull` only): the git hook event that started this pull, inline + * or detached (`TEAMAI_GIT_HOOK` in the environment). Its failures are + * recorded for `doctor` and the next interactive pull; its success clears them. + */ + gitHook?: 'post-checkout' | 'post-merge'; + /** + * Internal (`init --scope user`, `pull` only): pull the user scope even when + * the current directory is a project-scoped checkout. + */ + userScopeOnly?: boolean; /** Push a specific skill by path. */ skill?: string; /** Target role namespace (overrides detected namespace). */ diff --git a/src/uninstall.ts b/src/uninstall.ts index 1f5cb8804..f937fc352 100644 --- a/src/uninstall.ts +++ b/src/uninstall.ts @@ -122,6 +122,8 @@ interface RemovalPlan { docsDir: string | null; /** The .git/info/exclude files holding teamai's MCP config block (#882), each with its patterns and the paths each protects. */ gitExcludes: Map>; + /** teamai's git hook in the project repository: config sections and hook scripts holding its block. */ + gitHook: { repoDir: string; entries: string[] } | null; /** The .teamai home directory path. */ teamaiHome: string; /** Whether teamaiHome exists on disk. */ @@ -739,6 +741,7 @@ async function buildRemovalPlan( shellProfiles: [], docsDir: null, gitExcludes: new Map(), + gitHook: null, teamaiHome, teamaiHomeExists: includeShared && await pathExists(teamaiHome), unpublishedQueues: includeShared ? await listQueuesIn(teamaiHome) : [], @@ -879,6 +882,12 @@ async function buildRemovalPlan( for (const file of Object.keys((await readResolvedMcpFiles(cfg)).files)) dirs.push(path.dirname(file)); } plan.gitExcludes = await findMcpGitExcludes(dirs); + // (h) teamai's git hook, in the config every worktree shares. + if (localConfig.projectRoot) { + const { removeGitHook } = await import('./git-hook.js'); + const entries = await removeGitHook(localConfig.projectRoot, { dryRun: true }).catch(() => []); + if (entries.length > 0) plan.gitHook = { repoDir: localConfig.projectRoot, entries }; + } } } @@ -904,6 +913,7 @@ function isPlanEmpty(plan: RemovalPlan): boolean { plan.shellProfiles.length === 0 && plan.docsDir === null && plan.gitExcludes.size === 0 && + plan.gitHook === null && !plan.teamaiHomeExists ); } @@ -1030,6 +1040,12 @@ function printSummary(plan: RemovalPlan, agentFilter?: string): void { console.log(''); } + if (plan.gitHook) { + console.log(` Git hook in ${plan.gitHook.repoDir} (teamai's entries and blocks):`); + for (const entry of plan.gitHook.entries) console.log(` ${entry}`); + console.log(''); + } + if (plan.teamaiHomeExists) { console.log(' TeamAI home directory:'); console.log(` ${plan.teamaiHome}/`); @@ -1071,6 +1087,18 @@ async function teardownPlugins(): Promise { async function executeRemoval(plan: RemovalPlan): Promise { const pendingOpencode: RemovalPlan['opencodeInstructions'] = []; + if (plan.gitHook) { + const { removeGitHook } = await import('./git-hook.js'); + try { + await removeGitHook(plan.gitHook.repoDir); + log.info(`Removed the teamai git hook from ${plan.gitHook.repoDir}`); + } catch (e) { + log.warn(`Could not remove the teamai git hook from ${plan.gitHook.repoDir}: ${(e as Error).message}. ` + + 'Remove it yourself: `git config --local --remove-section hook.teamai-post-checkout` (and hook.teamai-post-merge), ' + + 'and the `# >>> teamai git hook` block in .git/hooks/post-checkout and post-merge.'); + } + } + // (a) Remove hooks from tool settings (built-in A + team B via the manifest). // Each settings entry carries the manifest for its own location (HOME/user // or a legacy /project copy), so team hooks are stripped at the diff --git a/src/utils/git.ts b/src/utils/git.ts index 5baab5e10..353125960 100644 --- a/src/utils/git.ts +++ b/src/utils/git.ts @@ -88,11 +88,11 @@ function spawnsByPath(candidate: string): boolean { * Authentication is handled by the provider's remote URL or by normal Git * facilities such as credential helpers, SSH config, and SSH agents. */ -export function createGit(basePath?: string): SimpleGit { +export function createGit(basePath?: string, abort?: AbortSignal): SimpleGit { if (basePath) { - return simpleGit({ baseDir: basePath, binary: gitBinary() }); + return simpleGit({ baseDir: basePath, binary: gitBinary(), abort }); } - return simpleGit({ binary: gitBinary() }); + return simpleGit({ binary: gitBinary(), abort }); } /** @@ -346,8 +346,8 @@ export async function commitPaths( * uncommitted changes, so the loss is never silent. A failed fetch is re-thrown * so the caller surfaces the real network/auth cause. */ -export async function pullRepo(localPath: string): Promise { - const git = createGit(localPath); +export async function pullRepo(localPath: string, abort?: AbortSignal): Promise { + const git = createGit(localPath, abort); const branch = (await git.revparse(['--abbrev-ref', 'HEAD'])).trim(); try {