diff --git a/docs/designs/multi-project-management.md b/docs/designs/multi-project-management.md index 308788a7..c893bd46 100644 --- a/docs/designs/multi-project-management.md +++ b/docs/designs/multi-project-management.md @@ -81,7 +81,7 @@ projects: agents: [hai-inference] # optional; agents// scoped to this project ``` -Agent push uses the same role/project namespace resolution as pull and skips ambiguous source destinations. On a role or project change, agent cleanup checks each tool destination independently, including YAML `targets` and legacy format support. Locally edited copies are preserved. +Agent push uses the same role/project namespace resolution as pull and skips ambiguous source destinations. Placement follows it: a new agent pushed with `--role`/`--project` lands under `agents//` (the project's `agents` axis), the same way a new rule resolves from `knowledge` and a new skill from `skills` (issue #649). On a role or project change, agent cleanup checks each tool destination independently, including YAML `targets` and legacy format support. Locally edited copies are preserved. Directory layout reuses the existing namespace convention, adding one learnings layer: diff --git a/docs/usage-guide.md b/docs/usage-guide.md index 0718a285..4eaee530 100644 --- a/docs/usage-guide.md +++ b/docs/usage-guide.md @@ -254,8 +254,11 @@ teamai projects members hai-inference # Who is registered on a project Member registration is a **side-effect of `init`**: running `teamai init --project ` appends `` to your `members/.yaml` roster (append + dedupe across directories), so the team can answer "who is on project X". `teamai push --project ` -pushes skills into that project's skills namespace (resolved from the manifest), -mirroring `teamai push --role`. +pushes each new resource into that project's namespace for its own resource type +(resolved from the manifest): a skill into `resources.skills`, a rule into +`resources.knowledge`, an agent into `resources.agents`. If the project declares +no namespace for a type being pushed, the push stops and names that type rather +than writing to the shared root, where the resource would reach everyone. Example local config: @@ -575,10 +578,10 @@ Exclusion rules take effect after role and tag filtering. When running `teamai p ```bash teamai push # Scan for new/modified resources, create an MR teamai push --all # Skip confirmation, push directly -teamai push --role pm # Push this skill to skills/pm// +teamai push --role pm # Push into the pm namespace (skills/pm/, rules/pm/, agents/pm/) ``` -**Namespace selection (new skills):** When pushing a new skill, the CLI automatically detects available namespaces and offers an interactive choice: +**Namespace selection (new resources):** When pushing a new skill, rule or agent, the CLI automatically detects available namespaces and offers an interactive choice: ``` Which namespace should new skills be pushed to? @@ -588,10 +591,25 @@ Which namespace should new skills be pushed to? Choose namespace [1-3] (default: 1 = common): ``` +- Each resource type resolves from its own axis: skills from the `skills` namespaces, rules from `knowledge`, agents from `agents`. A push that carries several types asks once per axis - If `primaryRole` is set, the list of available namespaces is expanded from the manifest -- If `primaryRole` is not set, the team repo's directory structure is scanned automatically +- If `primaryRole` is not set, the team repo's directory structure is scanned automatically for skills; a new rule or agent stays at the shared root - A single namespace is auto-selected; use `--role ` to choose one explicitly -- Modifying an existing skill automatically keeps its original namespace +- Modifying an existing resource automatically keeps its original namespace +- The chosen destination is printed for each resource, e.g. `[rules] my-rule → rules/pm/my-rule.md` +- A roles manifest that exists but cannot answer — unparseable, or missing the configured role — stops the push instead of falling back to the shared root: fix `manifest/roles.yaml`, run `teamai roles set `, or pass `--role `. A team with no `manifest/roles.yaml` at all keeps the pre-manifest behavior +- `teamai push --dry-run` resolves the same destinations and stops on the same unresolvable namespace, so it never reports a push as viable that the real command refuses +- When several namespaces could take a new resource and there is no terminal to ask on (CI, a hook, `TEAMAI_NONINTERACTIVE`), push stops with exit 2, lists them, and asks for `--role ` +- `--role`/`--project` places new resources only. An edit of a shared-root rule or agent stays at the shared root, and push says so +- A placed resource stays maintainable from the machine that published it. While its PR is open, the open-PR record routes a later edit of the author's own copy back to that PR; once the file is on the default branch, `state.json` records where push put it, so the edit goes back to the same file, and an agent published into a namespace this directory has not activated is still editable rather than skipped as having no active source +- `teamai remove rules ` accepts the bare name the author's copy carries as well as the published `/`; it reports which one it resolved to, and removes both the namespaced team file and the author's copy at the rules root. If the team repo cannot be refreshed first, or this machine's placement records cannot be updated and saved, `remove` stops with exit 1 and removes nothing, because either can resolve the name to the wrong files +- A local agent is an edit of the team agent it was delivered from: one in an active namespace or at the shared root first, then one this machine placed. Only when neither exists does `--role`/`--project` decide, and the agent is new in that namespace; if that namespace already holds an agent of that name, the agent is skipped rather than written over it, as a rule would be. Two active agents of one name stay ambiguous and are skipped, flag or not. The same agent name may exist in several namespaces, so a copy in an inactive one you did not name never blocks publishing yours. A placed agent that changed on the team since this machine last synced it is held until you run `teamai pull`, because agents have no pre-push sync. In single-repo mode, a root copy under `.teamai/` that matches an older version of the file it was placed at is held too: nothing refreshes it, so it is an old copy rather than an edit +- A new resource is never placed on top of one that is already there. If the resolved namespace already holds that name, the push stops and names the file: pull and edit the existing copy, rename yours, or pick another namespace with `--role ` +- An agent whose namespace is not active here stays editable through its placement record, and `pull` delivers it for the same reason, so your copy tracks the team file. An active namespace holding that name wins: that agent is the one deployed here +- A resource awaiting review in an open PR keeps that PR's destination — unless this push names a namespace other than the one recorded (the shared root counts as one), in which case the flag decides, the open PR is left untouched, and the collision is reported +- If the team repo cannot be refreshed at the start of a push, `--project` stops instead of placing by a possibly stale `manifest/projects.yaml`; so does any new resource placed without `--role`, because its destination comes from that clone (`manifest/roles.yaml`, its absence, or the namespaces the repo already has). Fix the pull and retry, or name the namespace with `--role `. `push` also stops, and pushes nothing, when this machine's placement records cannot be updated and saved +- A placement record is written only once the pushed file has landed on the default branch, so a PR closed without merging leaves none behind, whatever became of its branch. It is dropped again when the team deletes that file, or when a shared-root file of the same name appears (your root copy then follows that file, and `pull` warns). `push`, `pull` and `remove` settle this before they read the records. `teamai remove` itself leaves the record alone: its deletion reaches the default branch only when its PR merges, and until then a retried `remove` still resolves the bare name to the namespaced file. If the file reached the default branch with content other than what you pushed (for example a reviewer changed the PR before a squash merge), it is not recorded, and push says so once; run `teamai pull` and edit that file as the team file it now is +- Your own copy of a rule you published into a namespace stays at the rules root. When that namespace is active here, `pull` updates that copy instead of writing a second one under `rules//`; when it is not, `pull` leaves it alone. It is swept only once the team file it was placed at is gone **Updating an open PR instead of duplicating it:** If a resource is already waiting in an unmerged PR, re-running `teamai push` on it updates that existing PR in place (by force-pushing its branch) rather than opening a duplicate. Keep the resource selected to update its PR; deselect it to leave the PR untouched. Unrelated resources selected in the same run go into their own new PR. Once the PR merges (or its branch is removed from the remote), the record is cleared and the next push opens a fresh PR as usual. @@ -1479,7 +1497,7 @@ roles: agents: [common, frontend] # optional; omitted = root-level agents only ``` -`teamai pull` copies these into each Tier-1 tool's `agents/` directory (e.g. `~/.claude/agents/`), flattened by file name, so two active namespaces must not define the same agent name (pull reports the collision and skips the scope). `teamai pull` writes `.toml` for Codex tools, `.json` for Kiro, `.agent.md` for Copilot, and `.md` for every other tool. When a member changes role, agents of the namespaces that stopped being active are removed on the next pull, unless the deployed copy was edited locally, in which case it is kept with a warning. Without a configured role, every agent syncs. `teamai push` resolves the source using the same active role and project namespaces as pull. It writes edits to that source and skips ambiguous destinations with a warning; an agent with only inactive sources is also skipped. Skipped agents do not block other resources in the same push. A new agent lands at the root. Cleanup checks each tool separately, respecting YAML `targets` and legacy format support. An active same-named agent protects a deployed file only when it targets that tool and output file. `teamai remove agents ` records a tombstone. The next pull on every other machine deletes `.agent.md`, `.md`, `.toml` and `.json` from each synced tool's agents directory. That cleanup also runs when the pull finds the team repo unchanged. The CLI's built-in `teamai-recall` profile is deployed alongside team agents but is not uploaded by `teamai push`. +`teamai pull` copies these into each Tier-1 tool's `agents/` directory (e.g. `~/.claude/agents/`), flattened by file name, so two active namespaces must not define the same agent name (pull reports the collision and skips the scope). `teamai pull` writes `.toml` for Codex tools, `.json` for Kiro, `.agent.md` for Copilot, and `.md` for every other tool. When a member changes role, agents of the namespaces that stopped being active are removed on the next pull, unless the deployed copy was edited locally, in which case it is kept with a warning. Without a configured role, every agent syncs. `teamai push` resolves the source using the same active role and project namespaces as pull. It writes edits to that source and skips ambiguous destinations with a warning; an agent with only inactive sources is also skipped. Skipped agents do not block other resources in the same push. A new agent is placed the way a new skill is: `--role ` or `--project ` (that project's `agents` namespace) names the directory, and with neither flag it resolves from the primary role's `agents` namespaces. It only stays at the shared root — where every member receives it — when no namespace resolves, and push warns when that happens (see [Push local resources](#push-local-resources)). Cleanup checks each tool separately, respecting YAML `targets` and legacy format support. An active same-named agent protects a deployed file only when it targets that tool and output file. `teamai remove agents ` records a tombstone. A namespaced agent can be named as `/`; a bare name that only one namespace has resolves to it, and a bare name found in several places is refused, with the qualified names listed, rather than removed from all of them. The next pull on every other machine deletes `.agent.md`, `.md`, `.toml` and `.json` from each synced tool's agents directory. That cleanup also runs when the pull finds the team repo unchanged. Removing a namespaced agent tombstones `/` only, so the same name in another namespace is untouched; a member's flattened `` copy is cleaned, and not pushed again, when it can be that agent's copy (the namespace is active for them, or their machine placed the agent) and their directory does not still receive an agent of that name from another active namespace. A member who never had that namespace keeps their own agent of the same name. The CLI's built-in `teamai-recall` profile is deployed alongside team agents but is not uploaded by `teamai push`. ### GitHub Copilot CLI @@ -1900,7 +1918,7 @@ Shared resources (the env block, docs directory, and `~/.teamai/`) are removed * The exclusion is durable: `uninstall --agent ` drops the tool from `enabledAgents` and records it in `disabledAgents`, so a later `pull` (or another tool's session-start hook) will not resurrect its skills, rules, agents, CLAUDE.md block, or hooks. Running `init --agent ` again clears the exclusion and re-enables sync for that tool. -The same `enabledAgents` whitelist (from `init --agent`) also gates CLI built-in skills/rules/agents and CLAUDE.md-class injects: an already-installed tool outside the list is neither written to nor deleted from, even if its root directory already exists. `teamai remove` respects the same whitelist for agents, rules, and skills, and `teamai pull` / `teamai mcp inject` respect it for MCP servers. Editing `enabledAgents` without `init` still invalidates the last-pull skip cache for newly added tools. +The same `enabledAgents` whitelist (from `init --agent`) also gates CLI built-in skills/rules/agents and CLAUDE.md-class injects: an already-installed tool outside the list is neither written to nor deleted from, even if its root directory already exists. `teamai remove` respects the same whitelist for agents, rules, and skills, `teamai push` reads no rules or agents from a tool outside it, and `teamai pull` / `teamai mcp inject` respect it for MCP servers. Editing `enabledAgents` without `init` still invalidates the last-pull skip cache for newly added tools. To rejoin after uninstalling: diff --git a/docs/usage-guide.zh-CN.md b/docs/usage-guide.zh-CN.md index 5aa56d40..2ecc2f10 100644 --- a/docs/usage-guide.zh-CN.md +++ b/docs/usage-guide.zh-CN.md @@ -238,8 +238,10 @@ teamai projects members hai-inference # 查看某项目下注册了哪些成员 成员登记是 `init` 的**副作用**:执行 `teamai init --project ` 会把 `` 追加进你的 `members/.yaml` 名册(跨目录 append + 去重),于是团队侧可以回答 -「谁在项目 X」。`teamai push --project ` 会把 skill 推送到该项目的 skills -namespace(从 manifest 解析),对标 `teamai push --role`。 +「谁在项目 X」。`teamai push --project ` 会按资源类型各自的维度(从 manifest +解析)把新资源推送到该项目对应的 namespace:skill 走 `resources.skills`,rule 走 +`resources.knowledge`,agent 走 `resources.agents`。若该项目未为本次推送涉及的类型 +声明 namespace,命令会报错并指明该类型,而不会退回共享根目录(那会发给所有人)。 本地配置示例: @@ -553,10 +555,10 @@ excludedSkills: ```bash teamai push # 扫描新增/修改的资源,创建 MR teamai push --all # 跳过确认,直接推送 -teamai push --role pm # 将本次 skill 推送到 skills/pm// +teamai push --role pm # 推送到 pm namespace(skills/pm/、rules/pm/、agents/pm/) ``` -**命名空间选择(新 skill):** 推送新 skill 时,CLI 会自动检测可用的命名空间并提供交互式选择: +**命名空间选择(新资源):** 推送新的 skill、rule 或 agent 时,CLI 会自动检测可用的命名空间并提供交互式选择: ``` Which namespace should new skills be pushed to? @@ -566,10 +568,25 @@ Which namespace should new skills be pushed to? Choose namespace [1-3] (default: 1 = common): ``` +- 每种资源类型按各自维度解析:skill 用 `skills`,rule 用 `knowledge`,agent 用 `agents`。一次推送涉及多种类型时,每个维度各询问一次 - 有 `primaryRole` 时,从 manifest 展开可用 namespace 列表 -- 无 `primaryRole` 时,自动扫描团队仓库目录结构 +- 无 `primaryRole` 时,skill 自动扫描团队仓库目录结构;新的 rule / agent 保留在共享根目录 - 单一命名空间时自动选中;也可用 `--role ` 显式指定 -- 修改已有 skill 时自动保持原 namespace +- 修改已有资源时自动保持原 namespace +- 每个资源的落点都会打印出来,例如 `[rules] my-rule → rules/pm/my-rule.md` +- 若 roles manifest 存在却无法解析(格式错误,或未包含当前配置的角色),命令会报错停止,而不会退回共享根目录:请修复 `manifest/roles.yaml`、执行 `teamai roles set `,或用 `--role ` 显式指定。团队仓库根本没有 `manifest/roles.yaml` 时,保持原有行为 +- `teamai push --dry-run` 会做同样的落点解析,并在同样的无法解析情况下报错,不会把真实命令会拒绝的推送报为可行 +- 当有多个 namespace 可接收新资源、且没有可供询问的终端(CI、hook、`TEAMAI_NONINTERACTIVE`)时,push 会以退出码 2 停止,列出这些 namespace,并要求使用 `--role ` +- `--role`/`--project` 只放置新资源。对共享根目录 rule 或 agent 的修改仍留在共享根目录,push 会给出提示 +- 已落点的资源在发布它的机器上仍可维护:PR 未合并期间,待评审 PR 记录会把作者对自己副本的修改带回该 PR;文件进入默认分支后,`state.json` 会记录 push 的落点,因此修改仍会写回同一个文件;即使 agent 落在本目录未激活的 namespace,也不会被当作“无活跃源”跳过 +- `teamai remove rules ` 同时接受作者副本的简名和发布名 `/`:会打印实际解析到的名字,并同时删除带 namespace 的团队文件和作者在 rules 根目录的副本。若无法先刷新团队仓库,或本机的落点记录无法更新并保存,`remove` 会以退出码 1 停止且不删除任何内容,因为两者都可能把名字解析到错误的文件 +- 本地 agent 被视为其来源团队 agent 的编辑:优先是活跃 namespace 或共享根目录中的 agent,其次是本机放置的 agent。只有两者都不存在时,才由 `--role`/`--project` 决定,此时该 agent 在该 namespace 中是新的;若该 namespace 已有同名 agent,则跳过该 agent 而不是覆盖它,与 rule 的处理一致。两个活跃的同名 agent 无论是否指定参数都视为有歧义并跳过。同名 agent 允许存在于多个 namespace,因此你未指定的非活跃 namespace 中的同名副本不会阻止你发布。本机放置的 agent 若在本机上次同步后被团队修改,会暂缓推送,直到你运行 `teamai pull`,因为 agents 没有推送前同步。单仓库模式下,`.teamai/` 中的根目录副本若与其落点文件的某个旧版本相同,也会暂缓推送:没有任何操作会刷新它,因此它是旧副本而不是编辑 +- 新资源绝不会覆盖已存在的资源:若解析出的 namespace 下已有同名文件,命令会报错并指出该文件:请先 pull 并修改已有副本、重命名自己的资源,或用 `--role ` 换一个 namespace +- 本目录未激活的 namespace 下的 agent 可通过落点记录继续编辑,`pull` 也会基于同一记录下发它,使本地副本与团队文件保持同步;若已激活的 namespace 中已有同名 agent,则以它为准 +- 待评审 PR 中的资源默认沿用该 PR 的落点;但若本次 push 明确指定的 namespace 与记录的落点不同(共享根目录也算一种落点),则以命令行为准,原 PR 保持不动,并提示该冲突 +- push 开始时若无法刷新团队仓库,`--project` 会报错停止,而不会按可能已过期的 `manifest/projects.yaml` 落点;未使用 `--role` 放置的任何新资源同样如此,因为其落点来自该克隆(`manifest/roles.yaml`、它的缺失,或仓库中已有的 namespace)。请先修复 pull 再重试,或用 `--role ` 显式指定 namespace。若本机的落点记录无法更新并保存,`push` 也会停止且不推送任何内容 +- 落点记录只在推送的文件进入默认分支后才写入,因此未合并即关闭的 PR 不会留下记录,无论其分支是否还在。团队删除该文件、或共享根目录出现同名文件时(此时你的根目录副本改为跟随该文件,`pull` 会提示),记录会被清除。`push`、`pull` 和 `remove` 都会在读取记录前先做这一步。`teamai remove` 本身不清除记录:删除要等其 PR 合并才进入默认分支,在此之前重试 `remove` 仍会把简名解析到带 namespace 的团队文件。若该文件进入默认分支时的内容与你推送的不同(例如评审者在 squash 合并前修改了 PR),则不会写入记录,push 会提示一次;此时运行 `teamai pull`,并把该文件当作现在的团队文件来编辑 +- 你自己发布到某个 namespace 的 rule,其本地副本仍留在 rules 根目录。该 namespace 在本目录激活时,`pull` 会直接更新这个副本,而不会在 `rules//` 下再写一份;未激活时 `pull` 不会动它。只有当它对应的团队文件不存在时才会被清理 **更新已存在的 PR 而非重复创建:** 如果某个资源已在一个未合并的 PR 中等待评审,再次对它执行 `teamai push` 会就地更新那个已存在的 PR(通过 force-push 其分支),而不是新开一个重复的 PR。保持该资源被选中即更新其 PR;取消勾选则不动它。同一次运行中选中的其他无关资源会进入各自新开的 PR。一旦该 PR 合并(或其分支从远端删除),记录会被清除,下次 push 照常新开 PR。 @@ -1439,7 +1456,7 @@ roles: agents: [common, frontend] # 可选;省略 = 只同步根目录 agents ``` -`teamai pull` 会将它们按文件名拍平复制到每个 Tier-1 工具的 `agents/` 目录(如 `~/.claude/agents/`),因此两个活跃 namespace 不能定义同名 agent(pull 会报告冲突并跳过该 scope)。`teamai pull` 为 Codex 系工具写入 `.toml`,为 Kiro 写入 `.json`,为 Copilot 写入 `.agent.md`,其余工具写入 `.md`。成员切换角色后,不再活跃的 namespace 中的 agents 会在下一次 pull 时被移除;若本地副本已被手动修改,则保留并给出警告。未配置角色时同步全部 agents。`teamai push` 使用与 pull 相同的活跃角色和项目 namespace 来确定源文件,并将修改写回该源文件;若存在多个候选目标,则跳过并给出警告。若源文件均不活跃,也会跳过。跳过的 agent 不会阻止同一次 push 中的其他资源。新 agent 落在根目录。清理会逐个工具检查 YAML 的 `targets` 和旧格式支持;只有活跃的同名 agent 会写入该工具的同一输出文件时,才保留该文件。`teamai remove agents ` 会记录 tombstone。其他机器下一次 pull 时,会从每个同步中的工具的 agents 目录删除 `.agent.md`、`.md`、`.toml` 和 `.json`。即使该次 pull 发现团队仓库没有变化,也会执行清理。CLI 内置的 `teamai-recall` 配置与团队 agents 并列部署,但不会被 `teamai push` 上传。 +`teamai pull` 会将它们按文件名拍平复制到每个 Tier-1 工具的 `agents/` 目录(如 `~/.claude/agents/`),因此两个活跃 namespace 不能定义同名 agent(pull 会报告冲突并跳过该 scope)。`teamai pull` 为 Codex 系工具写入 `.toml`,为 Kiro 写入 `.json`,为 Copilot 写入 `.agent.md`,其余工具写入 `.md`。成员切换角色后,不再活跃的 namespace 中的 agents 会在下一次 pull 时被移除;若本地副本已被手动修改,则保留并给出警告。未配置角色时同步全部 agents。`teamai push` 使用与 pull 相同的活跃角色和项目 namespace 来确定源文件,并将修改写回该源文件;若存在多个候选目标,则跳过并给出警告。若源文件均不活跃,也会跳过。跳过的 agent 不会阻止同一次 push 中的其他资源。新 agent 与新 skill 一样需要确定落点:`--role ` 或 `--project `(该项目的 `agents` namespace)指定目录;两者都不给时,从主角色的 `agents` namespace 解析。只有在解析不出任何 namespace 时才留在共享根目录(此时全员都会收到),并且 push 会给出警告(见[推送本地资源](#推送本地资源))。清理会逐个工具检查 YAML 的 `targets` 和旧格式支持;只有活跃的同名 agent 会写入该工具的同一输出文件时,才保留该文件。`teamai remove agents ` 会记录 tombstone。带 namespace 的 agent 可写作 `/`;只有一个 namespace 拥有的简名会解析到该 agent;若简名出现在多个位置,命令会列出完整名称并拒绝执行,而不是从所有位置删除。其他机器下一次 pull 时,会从每个同步中的工具的 agents 目录删除 `.agent.md`、`.md`、`.toml` 和 `.json`。即使该次 pull 发现团队仓库没有变化,也会执行清理。删除带 namespace 的 agent 只记录 `/` 的 tombstone,其他 namespace 中的同名 agent 不受影响;当该副本可能属于这个 agent(该 namespace 对成员活跃,或由其本机放置)且成员的目录没有从另一个活跃 namespace 收到同名 agent 时,其拍平后的 `` 副本会被清理,也不会再被推送。从未启用该 namespace 的成员会保留自己的同名 agent。CLI 内置的 `teamai-recall` 配置与团队 agents 并列部署,但不会被 `teamai push` 上传。 ### GitHub Copilot CLI @@ -1848,7 +1865,7 @@ teamai uninstall --agent claude 该排除是持久的:`uninstall --agent ` 会把该工具从 `enabledAgents` 移除并记入 `disabledAgents`,因此之后的 `pull`(或其他工具的 session-start hook)不会再把它的 skills、rules、agents、CLAUDE.md 块或 hooks 重新装回。重新执行 `init --agent ` 会清除该排除、恢复对该工具的同步。 -同一套 `enabledAgents` 白名单(来自 `init --agent`)也约束 CLI 内置 skills/rules/agents 以及 CLAUDE.md 类注入:即使工具根目录已经存在,白名单外的已安装工具也不会被写入或删除。`teamai remove` 对 agents、rules 和 skills 同样遵守该白名单,`teamai pull` / `teamai mcp inject` 对 MCP servers 也遵守该白名单。不经过 `init` 直接把工具加进 `enabledAgents` 时,last-pull 跳过缓存会对新加入的工具失效。 +同一套 `enabledAgents` 白名单(来自 `init --agent`)也约束 CLI 内置 skills/rules/agents 以及 CLAUDE.md 类注入:即使工具根目录已经存在,白名单外的已安装工具也不会被写入或删除。`teamai remove` 对 agents、rules 和 skills 同样遵守该白名单,`teamai push` 也不会从白名单外的工具读取 rules 和 agents,`teamai pull` / `teamai mcp inject` 对 MCP servers 也遵守该白名单。不经过 `init` 直接把工具加进 `enabledAgents` 时,last-pull 跳过缓存会对新加入的工具失效。 卸载后如需重新加入: diff --git a/skills/teamai/references/contribute-member.md b/skills/teamai/references/contribute-member.md index a39daf08..ccf8bd9b 100644 --- a/skills/teamai/references/contribute-member.md +++ b/skills/teamai/references/contribute-member.md @@ -87,6 +87,11 @@ The doc lands in the team's `learnings/` and appears for teammates on their next ``` To publish into a specific role namespace: `teamai push --skill --role `. + `--role ` / `--project ` place every new resource, not only skills: a + new rule and a new agent land in that namespace too (a project resolves each + from its own axis — `knowledge` for rules, `agents` for agents). Without one, + a new resource whose namespace cannot be resolved stays at the shared root and + reaches the whole team. ## After contributing diff --git a/src/__tests__/agents.test.ts b/src/__tests__/agents.test.ts index aecafb5f..d9fe1c3c 100644 --- a/src/__tests__/agents.test.ts +++ b/src/__tests__/agents.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import path from 'node:path'; import os from 'node:os'; import fse from 'fs-extra'; +import { execFileSync } from 'node:child_process'; vi.mock('../utils/logger.js', () => ({ log: { @@ -19,8 +20,16 @@ vi.mock('../utils/logger.js', () => ({ })), })); +const mockGetFileContentAtRev = vi.fn< + (repoPath: string, rev: string, filePath: string) => Promise +>().mockResolvedValue(null); +vi.mock('../utils/git.js', async () => ({ + ...(await vi.importActual('../utils/git.js')), + getFileContentAtRev: (...args: [string, string, string]) => mockGetFileContentAtRev(...args), +})); + import { AgentsHandler } from '../resources/agents.js'; -import type { TeamaiConfig, LocalConfig } from '../types.js'; +import { getDataHome, type TeamaiConfig, type LocalConfig } from '../types.js'; /** * Build a minimal TeamaiConfig with the given toolPaths. @@ -84,6 +93,7 @@ describe('AgentsHandler — Phase 1 push/pull/remove', () => { }); afterEach(async () => { + mockGetFileContentAtRev.mockResolvedValue(null); vi.unstubAllEnvs(); await fse.remove(tmpDir); }); @@ -282,6 +292,451 @@ projects: expect(await fse.readFile(path.join(repoPath, 'agents/aaa/reviewer.md'), 'utf8')).toBe('# original'); }); + /** + * An agent published with `--role`/`--project` lands in a namespace the + * author's own directory need not have activated. Without the record push + * put in state.json, their very next edit is skipped as "no active source" + * and they can never maintain the agent they just created (#649 review). + */ + it('accepts the namespace push recorded for an agent even when it is inactive', async () => { + await fse.outputFile(path.join(repoPath, 'manifest/projects.yaml'), + 'version: 1\nprojects:\n - id: inactive\n resources:\n agents: [fe-agents]\n'); + const sourcePath = path.join(repoPath, 'agents/fe-agents/reviewer.yaml'); + await fse.outputFile(sourcePath, 'name: reviewer\ndescription: Published\ninstructions: Read it.\n'); + await fse.outputFile(path.join(homeDir, '.claude/agents/reviewer.md'), + '---\nname: reviewer\ndescription: Published\n---\n\nEdited locally.\n'); + await fse.outputJson(path.join(getDataHome(localConfig), 'state.json'), { + placedAgents: { reviewer: 'agents/fe-agents/reviewer.yaml' }, + }); + + const items = await handler.scanLocalForPush(teamConfig, localConfig); + + expect(items).toHaveLength(1); + expect(items[0]?.skipReason).toBeUndefined(); + expect(items[0]?.relativePath).toBe('agents/fe-agents/reviewer.yaml'); + }); + + // A projects manifest with none of its projects active here: every namespace + // is inactive, and only the shared root is delivered. + const nothingActive = () => fse.outputFile(path.join(repoPath, 'manifest/projects.yaml'), + 'version: 1\nprojects:\n - id: elsewhere\n resources:\n agents: [zzz]\n'); + + it('does not hold a recorded agent whose local copy already matches the team file', async () => { + // The author's own edit merged after their last pull: the team file moved + // past the baseline, but to exactly what they have, so nothing is pushed. + await nothingActive(); + const current = { name: 'reviewer', type: 'agents' as const, + sourcePath: path.join(repoPath, 'agents/fe-agents/reviewer.yaml'), relativePath: 'agents/fe-agents/reviewer.yaml' }; + await fse.outputFile(current.sourcePath, 'name: reviewer\ndescription: Published\ninstructions: My merged edit.\n'); + await fse.outputJson(path.join(getDataHome(localConfig), 'state.json'), { + placedAgents: { reviewer: 'agents/fe-agents/reviewer.yaml' }, lastPullRev: 'abc1234', + }); + await handler.pullItem(current, teamConfig, localConfig); + mockGetFileContentAtRev.mockResolvedValue(Buffer.from('name: reviewer\ndescription: Published\ninstructions: Read it.\n')); + + expect(await handler.scanLocalForPush(teamConfig, localConfig)).toEqual([]); + }); + + it('holds a recorded agent that changed on the team since this machine last synced it', async () => { + // Agents have no pre-push sync: a teammate's edit made before the author's + // next pull would be overwritten by the stale local copy (#649 review). + await nothingActive(); + await fse.outputFile(path.join(repoPath, 'agents/fe-agents/reviewer.yaml'), + 'name: reviewer\ndescription: Published\ninstructions: A teammate rewrote this.\n'); + await fse.outputFile(path.join(homeDir, '.claude/agents/reviewer.md'), + '---\nname: reviewer\ndescription: Published\n---\n\nRead it.\n'); + await fse.outputJson(path.join(getDataHome(localConfig), 'state.json'), { + placedAgents: { reviewer: 'agents/fe-agents/reviewer.yaml' }, lastPullRev: 'abc1234', + }); + mockGetFileContentAtRev.mockResolvedValue(Buffer.from('name: reviewer\ndescription: Published\ninstructions: Read it.\n')); + + const items = await handler.scanLocalForPush(teamConfig, localConfig); + + expect(items).toHaveLength(1); + expect(items[0]?.skipReason).toContain('changed on the team since this machine last synced it'); + expect(mockGetFileContentAtRev).toHaveBeenCalledWith(repoPath, 'abc1234', './agents/fe-agents/reviewer.yaml'); + }); + + /** + * The layout allows the same stem in several namespaces. An explicit + * --role/--project names the destination, so a copy in some OTHER namespace + * is a different agent and must not block publishing this one — which is + * what filtering on activity alone did (#649 review). + */ + /** + * `remove agents vr` matched the bare stem and deleted every `vr` in every + * namespace, other people's agents included, and left the namespaced record + * behind (#649 review). + */ + it('resolves the bare agent name to the namespace push recorded for it', async () => { + await fse.outputFile(path.join(repoPath, 'agents/fe/vr.yaml'), + 'name: vr\ndescription: Mine\ninstructions: Read it.\n'); + await fse.outputJson(path.join(getDataHome(localConfig), 'state.json'), { + placedAgents: { vr: 'agents/fe/vr.yaml' }, + }); + + expect(await handler.publishedNameFor('vr', localConfig)).toBe('fe/vr'); + }); + + it('does not resolve an agent record whose team file is gone', async () => { + await fse.outputJson(path.join(getDataHome(localConfig), 'state.json'), { + placedAgents: { vr: 'agents/fe/vr.yaml' }, + }); + + expect(await handler.publishedNameFor('vr', localConfig)).toBeNull(); + }); + + it('removes only the named namespace, leaving the same stem elsewhere', async () => { + await fse.outputFile(path.join(repoPath, 'agents/fe/vr.yaml'), 'name: vr\ndescription: Mine\ninstructions: A.\n'); + await fse.outputFile(path.join(repoPath, 'agents/other/vr.yaml'), 'name: vr\ndescription: Theirs\ninstructions: B.\n'); + await fse.outputFile(path.join(homeDir, '.claude/agents/vr.md'), '# the author copy'); + // Push recorded where it put this agent; that record is what makes the + // flattened local copy this agent's rather than another namespace's. + await fse.outputJson(path.join(getDataHome(localConfig), 'state.json'), { + placedAgents: { vr: 'agents/fe/vr.yaml' }, + }); + + await handler.removeItem('fe/vr', teamConfig, localConfig); + + expect(await fse.pathExists(path.join(repoPath, 'agents/fe/vr.yaml'))).toBe(false); + // Somebody else's agent of the same name is not ours to delete. + expect(await fse.readFile(path.join(repoPath, 'agents/other/vr.yaml'), 'utf-8')).toContain('Theirs'); + // The author's own copy is at the agents root under the bare stem. + expect(await fse.pathExists(path.join(homeDir, '.claude/agents/vr.md'))).toBe(false); + const tombstones = await fse.readFile(path.join(repoPath, 'agents', '.removed'), 'utf-8'); + expect(tombstones.split('\n')).toContain('fe/vr'); + // Agents deploy flattened, so a bare `vr` tombstone would suppress and + // delete be/vr the moment that namespace became active. + expect(tombstones.split('\n')).not.toContain('vr'); + }); + + it('keeps the flattened local copy when no record proves it is this agent\'s', async () => { + // Agents deploy flattened, so ~/.claude/agents/vr.md could be be/vr's + // deployment. Without a record, removing fe/vr must not take it. + await fse.outputFile(path.join(repoPath, 'agents/fe/vr.yaml'), 'name: vr\ndescription: A\ninstructions: A.\n'); + await fse.outputFile(path.join(homeDir, '.claude/agents/vr.md'), '# some other namespace copy'); + + await handler.removeItem('fe/vr', teamConfig, localConfig); + + expect(await fse.pathExists(path.join(repoPath, 'agents/fe/vr.yaml'))).toBe(false); + expect(await fse.readFile(path.join(homeDir, '.claude/agents/vr.md'), 'utf-8')) + .toBe('# some other namespace copy'); + }); + + /** + * Single-repo mode: the canonical source is the repo's own .teamai/agents/, + * picked up directly rather than reverse-parsed. Both the placement record + * and removal have to reach it (#649 review). + */ + describe('single-repo mode canonical sources', () => { + function selfConfig(): LocalConfig { + return { ...localConfig, repo: { ...localConfig.repo, kind: 'self' }, projectRoot: tmpDir }; + } + + it('follows the record for a root canonical source placed in a namespace', async () => { + const self = selfConfig(); + await fse.outputFile(path.join(repoPath, 'agents/fe/vr.yaml'), + 'name: vr\ndescription: Published\ninstructions: Read it.\n'); + await fse.outputFile(path.join(tmpDir, '.teamai/agents/vr.yaml'), + 'name: vr\ndescription: Published\ninstructions: Edited locally.\n'); + await fse.outputJson(path.join(getDataHome(self), 'state.json'), { + placedAgents: { vr: 'agents/fe/vr.yaml' }, + }); + + const items = await handler.scanLocalForPush(teamConfig, self); + + // Without the record this reads as new, and the collision check then + // refuses the very agent this machine published. + expect(items).toHaveLength(1); + expect(items[0]?.status).toBe('modified'); + expect(items[0]?.relativePath).toBe('agents/fe/vr.yaml'); + }); + + it('holds a root canonical source that is an older version of the placed file', async () => { + // Nothing refreshes .teamai/agents/vr.yaml after placement: a teammate's + // later edit leaves it an old copy nobody edited, and pushing it would + // revert that edit (#649 review). + const self = selfConfig(); + const run = (args: string[]) => execFileSync('git', args, { cwd: repoPath, encoding: 'utf8', env: { + ...process.env, GIT_AUTHOR_NAME: 'T', GIT_AUTHOR_EMAIL: 't@t', GIT_COMMITTER_NAME: 'T', GIT_COMMITTER_EMAIL: 't@t', + } }); + const placedFile = path.join(repoPath, 'agents/fe/vr.yaml'); + const asPlaced = 'name: vr\ndescription: Published\ninstructions: As placed.\n'; + run(['init', '-q', '-b', 'main']); + await fse.outputFile(placedFile, asPlaced); + run(['add', '-A']); run(['commit', '-q', '-m', 'placement merged']); + await fse.outputFile(placedFile, 'name: vr\ndescription: Published\ninstructions: A teammate improved this.\n'); + run(['add', '-A']); run(['commit', '-q', '-m', 'teammate edit']); + await fse.outputFile(path.join(tmpDir, '.teamai/agents/vr.yaml'), asPlaced); + await fse.outputJson(path.join(getDataHome(self), 'state.json'), { placedAgents: { vr: 'agents/fe/vr.yaml' } }); + + const items = await handler.scanLocalForPush(teamConfig, self); + + expect(items).toHaveLength(1); + expect(items[0]?.skipReason).toContain('is an older version of agents/fe/vr.yaml'); + }); + + it('follows a renamed canonical source to its new extension and retires the recorded file', async () => { + const self = selfConfig(); + // Recorded and published as legacy .md; the author has since rewritten it as .yaml. + await fse.outputFile(path.join(repoPath, 'agents/fe/vr.md'), '# vr\nLegacy body.\n'); + await fse.outputFile(path.join(tmpDir, '.teamai/agents/vr.yaml'), + 'name: vr\ndescription: Rewritten\ninstructions: Read it.\n'); + await fse.outputJson(path.join(getDataHome(self), 'state.json'), { + placedAgents: { vr: 'agents/fe/vr.md' }, + }); + + const items = await handler.scanLocalForPush(teamConfig, self); + + // Keeping the recorded .md as relativePath made pushGroup stage a path + // pushItem never wrote — the .yaml went unstaged and the .md stayed. + expect(items).toHaveLength(1); + expect(items[0]?.status).toBe('modified'); + expect(items[0]?.relativePath).toBe('agents/fe/vr.yaml'); + expect(items[0]).toMatchObject({ supersedes: 'agents/fe/vr.md' }); + + await handler.pushItem(items[0]!, teamConfig, self); + + expect(await fse.pathExists(path.join(repoPath, 'agents/fe/vr.yaml'))).toBe(true); + expect(await fse.pathExists(path.join(repoPath, 'agents/fe/vr.md'))).toBe(false); + }); + + it('removes the canonical source so the agent cannot republish itself', async () => { + const self = selfConfig(); + await fse.outputFile(path.join(repoPath, 'agents/fe/vr.yaml'), 'name: vr\ndescription: A\ninstructions: A.\n'); + await fse.outputFile(path.join(tmpDir, '.teamai/agents/vr.yaml'), 'name: vr\ndescription: A\ninstructions: A.\n'); + await fse.outputJson(path.join(getDataHome(self), 'state.json'), { + placedAgents: { vr: 'agents/fe/vr.yaml' }, + }); + + await handler.removeItem('fe/vr', teamConfig, self); + + // A bare-stem tombstone cannot cover this without suppressing the same + // stem in every other namespace, because agents deploy flattened. + expect(await fse.pathExists(path.join(tmpDir, '.teamai/agents/vr.yaml'))).toBe(false); + }); + }); + + it('still removes a bare stem from every namespace', async () => { + await fse.outputFile(path.join(repoPath, 'agents/fe/vr.yaml'), 'name: vr\ndescription: A\ninstructions: A.\n'); + await fse.outputFile(path.join(repoPath, 'agents/other/vr.yaml'), 'name: vr\ndescription: B\ninstructions: B.\n'); + + await handler.removeItem('vr', teamConfig, localConfig); + + expect(await fse.pathExists(path.join(repoPath, 'agents/fe/vr.yaml'))).toBe(false); + expect(await fse.pathExists(path.join(repoPath, 'agents/other/vr.yaml'))).toBe(false); + }); + + it('publishes into the requested namespace despite a stem in an inactive one', async () => { + await nothingActive(); + await fse.outputFile(path.join(repoPath, 'agents/other-ns/reviewer.yaml'), + 'name: reviewer\ndescription: Somebody else\'s\ninstructions: Read other-ns.\n'); + await fse.outputFile(path.join(homeDir, '.claude/agents/reviewer.md'), + '---\nname: reviewer\ndescription: Mine\n---\n\nYou review the front end.\n'); + + const items = await handler.scanLocalForPush(teamConfig, localConfig, { namespace: 'fe-agents' }); + + expect(items).toHaveLength(1); + expect(items[0]?.skipReason).toBeUndefined(); + // New at the shared root; placement then writes it to the requested namespace. + expect(items[0]?.status).toBe('new'); + }); + + it('refuses to place onto a requested namespace\'s agent that was never delivered here', async () => { + // Neither active nor recorded, so this local file is not a copy of + // fe-agents/reviewer: it is new, and pushing it there would overwrite + // somebody else's agent — which rules already refuse (#649 review). + await nothingActive(); + await fse.outputFile(path.join(repoPath, 'agents/other-ns/reviewer.yaml'), + 'name: reviewer\ndescription: Somebody else\'s\ninstructions: Read other-ns.\n'); + const requested = path.join(repoPath, 'agents/fe-agents/reviewer.yaml'); + const theirs = 'name: reviewer\ndescription: Theirs\ninstructions: Read it.\n'; + await fse.outputFile(requested, theirs); + await fse.outputFile(path.join(homeDir, '.claude/agents/reviewer.md'), + '---\nname: reviewer\ndescription: Mine\n---\n\nMy own agent.\n'); + + const items = await handler.scanLocalForPush(teamConfig, localConfig, { namespace: 'fe-agents' }); + + expect(items).toHaveLength(1); + expect(items[0]?.skipReason).toContain('agents/fe-agents/reviewer.yaml already exists'); + for (const item of items) await handler.pushItem(item, teamConfig, localConfig); + expect(await fse.readFile(requested, 'utf8')).toBe(theirs); + }); + + it('keeps two active same-stem agents ambiguous even when a flag names one of them', async () => { + // pull reports the collision and leaves `vr` as common's copy; under + // --role fe that copy must not be read as an edit of fe/vr (#649 review). + await fse.outputFile(path.join(repoPath, 'manifest/projects.yaml'), + 'version: 1\nprojects:\n - id: base\n resources:\n agents: [common, fe]\n'); + localConfig.projects = ['base']; + const common = { name: 'vr', type: 'agents' as const, sourcePath: path.join(repoPath, 'agents/common/vr.yaml'), relativePath: 'agents/common/vr.yaml' }; + await fse.outputFile(common.sourcePath, 'name: vr\ndescription: Common\ninstructions: Read common.\n'); + await fse.outputFile(path.join(repoPath, 'agents/fe/vr.yaml'), 'name: vr\ndescription: Front\ninstructions: Read fe.\n'); + await handler.pullItem(common, teamConfig, localConfig); + + const items = await handler.scanLocalForPush(teamConfig, localConfig, { namespace: 'fe' }); + + expect(items).toHaveLength(1); + expect(items[0]?.skipReason).toContain('Ambiguous agent "vr"'); + }); + + it('sends an agent awaiting review as a placement back to its PR, despite a same stem elsewhere', async () => { + // Without a flag, a stem existing only in an inactive namespace is "no + // active source" — but this one is this machine's, placed and under review. + await nothingActive(); + await fse.outputFile(path.join(repoPath, 'agents/other-ns/vr.yaml'), 'name: vr\ndescription: Other\ninstructions: x\n'); + await fse.outputFile(path.join(homeDir, '.claude/agents/vr.md'), '---\nname: vr\ndescription: Mine\n---\n\nEdited.\n'); + await fse.outputJson(path.join(getDataHome(localConfig), 'state.json'), { + pendingPushes: [{ branch: 'teamai/push/me/1', prUrl: null, createdAt: '2026-01-01T00:00:00.000Z', + items: [{ type: 'agents', name: 'vr', relativePath: 'agents/fe/vr.yaml', namespace: 'fe', placed: true, blob: 'b10b' }] }], + }); + + const items = await handler.scanLocalForPush(teamConfig, localConfig); + + expect(items).toHaveLength(1); + expect(items[0]?.skipReason).toBeUndefined(); + expect(items[0]?.status).toBe('new'); + }); + + it('edits the shared-root agent it was deployed from, never a namespaced second copy', async () => { + // The root copy reaches every member, so the local file is its copy; a + // namespaced second one would leave two active agents of that name. + await fse.outputFile(path.join(repoPath, 'agents/reviewer.yaml'), + 'name: reviewer\ndescription: Shared\ninstructions: Read it.\n'); + await fse.outputFile(path.join(homeDir, '.claude/agents/reviewer.md'), + '---\nname: reviewer\ndescription: Shared\n---\n\nEdited locally.\n'); + + const items = await handler.scanLocalForPush(teamConfig, localConfig, { namespace: 'fe-agents' }); + + expect(items).toHaveLength(1); + expect(items[0]?.skipReason).toBeUndefined(); + expect(items[0]?.status).toBe('modified'); + expect(items[0]?.relativePath).toBe('agents/reviewer.yaml'); + }); + + it('compares a deployed agent with its active source, not with the requested namespace\'s file', async () => { + // common is active and delivered `vr`; fe/vr is another agent, inactive + // here. `push --role fe` must not read the untouched common copy as an + // edit of fe/vr and write it over that file (#649 review). + await fse.outputFile(path.join(repoPath, 'manifest/projects.yaml'), + 'version: 1\nprojects:\n - id: base\n resources:\n agents: [common]\n'); + localConfig.projects = ['base']; + const common = { name: 'vr', type: 'agents' as const, sourcePath: path.join(repoPath, 'agents/common/vr.yaml'), relativePath: 'agents/common/vr.yaml' }; + await fse.outputFile(common.sourcePath, 'name: vr\ndescription: Common\ninstructions: Read common.\n'); + await fse.outputFile(path.join(repoPath, 'agents/fe/vr.yaml'), 'name: vr\ndescription: Front\ninstructions: Read fe.\n'); + await handler.pullItem(common, teamConfig, localConfig); + + expect(await handler.scanLocalForPush(teamConfig, localConfig, { namespace: 'fe' })).toEqual([]); + }); + + /** + * The record lets the author edit an agent in a namespace this directory + * never activates — which also means `pull` never refreshed a copy of it, and + * the pre-push sync covers rules and skills but not agents. Pushing a stale + * rendering over a teammate's newer canonical file is the risk (#649 review). + */ + /** + * Delivery and revocation are two halves of the same decision. When only + * delivery knew about the placement record, `pull` wrote the agent and the + * revocation pass deleted it again in the same run — so the record-based + * delivery was inert and the file churned on every pull. + */ + it('does not revoke an agent this machine published into an inactive namespace', async () => { + const sourcePath = path.join(repoPath, 'agents/fe-agents/reviewer.yaml'); + await fse.outputFile(sourcePath, 'name: reviewer\ndescription: Mine\ninstructions: Read it.\n'); + await fse.outputJson(path.join(getDataHome(localConfig), 'state.json'), { + placedAgents: { reviewer: 'agents/fe-agents/reviewer.yaml' }, + }); + // Deploy it the way pull would. + await handler.pullItem( + { name: 'reviewer', type: 'agents', sourcePath, relativePath: 'agents/fe-agents/reviewer.yaml', namespace: 'fe-agents' }, + teamConfig, localConfig, + ); + const deployed = path.join(homeDir, '.claude/agents/reviewer.md'); + expect(await fse.pathExists(deployed)).toBe(true); + + // `fe-agents` is not active; only the record keeps this agent here. + await handler.cleanupInactiveNamespaces(teamConfig, localConfig, ['common']); + + expect(await fse.pathExists(deployed)).toBe(true); + }); + + it('still revokes an agent whose namespace went inactive with no record', async () => { + const sourcePath = path.join(repoPath, 'agents/fe-agents/reviewer.yaml'); + await fse.outputFile(sourcePath, 'name: reviewer\ndescription: Theirs\ninstructions: Read it.\n'); + await handler.pullItem( + { name: 'reviewer', type: 'agents', sourcePath, relativePath: 'agents/fe-agents/reviewer.yaml', namespace: 'fe-agents' }, + teamConfig, localConfig, + ); + const deployed = path.join(homeDir, '.claude/agents/reviewer.md'); + expect(await fse.pathExists(deployed)).toBe(true); + + await handler.cleanupInactiveNamespaces(teamConfig, localConfig, ['common']); + + expect(await fse.pathExists(deployed)).toBe(false); + }); + + it('prefers an active source over the placement record', async () => { + // The record is a FALLBACK. When an active namespace holds this stem, that + // is the agent deployed here — and the one pull delivers. + await fse.outputFile(path.join(repoPath, 'manifest/projects.yaml'), + 'version: 1\nprojects:\n - id: active\n resources:\n agents: [common]\n'); + localConfig.projects = ['active']; + await fse.outputFile(path.join(repoPath, 'agents/common/reviewer.yaml'), + 'name: reviewer\ndescription: Active\ninstructions: Read common.\n'); + await fse.outputFile(path.join(repoPath, 'agents/fe-agents/reviewer.yaml'), + 'name: reviewer\ndescription: Recorded\ninstructions: Read fe.\n'); + await fse.outputFile(path.join(homeDir, '.claude/agents/reviewer.md'), + '---\nname: reviewer\ndescription: Active\n---\n\nEdited locally.\n'); + await fse.outputJson(path.join(getDataHome(localConfig), 'state.json'), { + placedAgents: { reviewer: 'agents/fe-agents/reviewer.yaml' }, + }); + + const items = await handler.scanLocalForPush(teamConfig, localConfig); + + expect(items).toHaveLength(1); + expect(items[0]?.skipReason).toBeUndefined(); + expect(items[0]?.relativePath).toBe('agents/common/reviewer.yaml'); + }); + + it('pushes a recorded agent whose canonical file has not moved', async () => { + await fse.outputFile(path.join(repoPath, 'manifest/projects.yaml'), + 'version: 1\nprojects:\n - id: inactive\n resources:\n agents: [fe-agents]\n'); + const canonical = 'name: reviewer\ndescription: Mine\ninstructions: Read it.\n'; + await fse.outputFile(path.join(repoPath, 'agents/fe-agents/reviewer.yaml'), canonical); + await fse.outputFile(path.join(homeDir, '.claude/agents/reviewer.md'), + '---\nname: reviewer\ndescription: Mine\n---\n\nEdited locally.\n'); + await fse.outputJson(path.join(getDataHome(localConfig), 'state.json'), { + lastPullRev: 'abc1234', + placedAgents: { reviewer: 'agents/fe-agents/reviewer.yaml' }, + }); + mockGetFileContentAtRev.mockResolvedValue(Buffer.from(canonical)); + + const items = await handler.scanLocalForPush(teamConfig, localConfig); + + expect(items).toHaveLength(1); + expect(items[0]?.skipReason).toBeUndefined(); + expect(items[0]?.relativePath).toBe('agents/fe-agents/reviewer.yaml'); + }); + + it('still skips an inactive agent this machine never published', async () => { + await fse.outputFile(path.join(repoPath, 'manifest/projects.yaml'), + 'version: 1\nprojects:\n - id: inactive\n resources:\n agents: [fe-agents]\n'); + await fse.outputFile(path.join(repoPath, 'agents/fe-agents/reviewer.yaml'), + 'name: reviewer\ndescription: Somebody else\'s\ninstructions: Read it.\n'); + await fse.outputFile(path.join(homeDir, '.claude/agents/reviewer.md'), + '---\nname: reviewer\ndescription: Somebody else\'s\n---\n\nEdited locally.\n'); + // A record for a DIFFERENT agent must not widen this one. + await fse.outputJson(path.join(getDataHome(localConfig), 'state.json'), { + placedAgents: { other: 'agents/fe-agents/other.yaml' }, + }); + + const items = await handler.scanLocalForPush(teamConfig, localConfig); + + expect(items).toHaveLength(1); + expect(items[0]?.skipReason).toContain('no active source'); + }); + it.each(['zzz', ''])('rejects ambiguous push destinations including root: %s', async (namespace) => { await fse.outputFile(path.join(repoPath, 'agents/aaa/reviewer.md'), '# aaa'); await fse.outputFile(path.join(repoPath, 'agents', namespace, 'reviewer.md'), '# zzz'); @@ -470,4 +925,81 @@ projects: const items = await handler.scanLocalForPush(teamConfig, localConfig); expect(items.find((i) => i.name === 'ghost')).toBeUndefined(); }); + + it('scanLocalForPush reads a namespaced tombstone as the flattened stem once no namespace has it', async () => { + // Every member holds `fe/vr` as `/vr`. After the removal the only + // tombstone is `fe/vr`, and without reading it as `vr` the copy is new. + await fse.writeFile(path.join(repoPath, 'agents', '.removed'), 'fe/vr\n'); + await fse.writeFile(path.join(homeDir, '.claude/agents', 'vr.md'), '---\nname: vr\ndescription: d\n---\n\nold\n'); + + const items = await handler.scanLocalForPush(teamConfig, localConfig); + + expect(items.find((i) => i.name === 'vr')).toBeUndefined(); + expect(await handler.removedStems(teamConfig, localConfig)).toEqual(new Set(['fe/vr', 'vr'])); + }); + + it('keeps the flattened stem live while this directory still receives an agent of that stem', async () => { + await fse.writeFile(path.join(repoPath, 'agents', '.removed'), 'fe/vr\n'); + await fse.ensureDir(path.join(repoPath, 'agents', 'be')); + await fse.writeFile(path.join(repoPath, 'agents', 'be', 'vr.yaml'), 'name: vr\ndescription: be\ninstructions: x\n'); + + // No roles or projects here, so be/vr is delivered, and `vr` is its copy: + // suppressing it would block editing be/vr. + expect(await handler.removedStems(teamConfig, localConfig)).toEqual(new Set(['fe/vr'])); + }); + + it('leaves a same-named agent alone on a member who never had the removed agent\'s namespace', async () => { + // An ops member's own `vr` was never fe/vr's copy: removing fe/vr must not + // delete it on pull or stop them pushing it (#649 review). + await fse.outputFile(path.join(repoPath, 'manifest/projects.yaml'), + 'version: 1\nprojects:\n - id: ops\n resources:\n agents: [ops]\n - id: front\n resources:\n agents: [fe]\n'); + localConfig.projects = ['ops']; + await fse.writeFile(path.join(repoPath, 'agents', '.removed'), 'fe/vr\n'); + await fse.writeFile(path.join(homeDir, '.claude/agents', 'vr.md'), '---\nname: vr\ndescription: mine\n---\n\nMine.\n'); + + expect(await handler.removedStems(teamConfig, localConfig)).toEqual(new Set(['fe/vr'])); + expect((await handler.scanLocalForPush(teamConfig, localConfig)).map((i) => i.name)).toContain('vr'); + }); + + it('retires the flattened stem of an agent this machine placed, after its record was dropped', async () => { + // The author's fe was never active; the record said their `vr` was fe/vr. + // Once the removal merged, reconcile dropped the record and retired it. + await fse.outputFile(path.join(repoPath, 'manifest/projects.yaml'), + 'version: 1\nprojects:\n - id: front\n resources:\n agents: [fe]\n'); + await fse.writeFile(path.join(repoPath, 'agents', '.removed'), 'fe/vr\n'); + await fse.outputJson(path.join(getDataHome(localConfig), 'state.json'), { + retiredPlacedAgents: { vr: 'agents/fe/vr.yaml' }, + }); + + expect(await handler.removedStems(teamConfig, localConfig)).toEqual(new Set(['fe/vr', 'vr'])); + }); + + it('retires the flattened stem when the surviving same-stem agent is not active here', async () => { + // An fe member: fe/vr is removed, be/vr survives but is not theirs, so the + // `vr` they hold is the removed fe/vr, not a copy of be/vr (#649 review). + await fse.outputFile(path.join(repoPath, 'manifest/projects.yaml'), + 'version: 1\nprojects:\n - id: front\n resources:\n agents: [fe]\n - id: back\n resources:\n agents: [be]\n'); + localConfig.projects = ['front']; + await fse.writeFile(path.join(repoPath, 'agents', '.removed'), 'fe/vr\n'); + await fse.outputFile(path.join(repoPath, 'agents/be/vr.yaml'), 'name: vr\ndescription: be\ninstructions: x\n'); + await fse.writeFile(path.join(homeDir, '.claude/agents', 'vr.md'), '---\nname: vr\ndescription: d\n---\n\nold fe\n'); + + expect(await handler.removedStems(teamConfig, localConfig)).toEqual(new Set(['fe/vr', 'vr'])); + expect((await handler.scanLocalForPush(teamConfig, localConfig, { namespace: 'fe' })).find((i) => i.name === 'vr')) + .toBeUndefined(); + }); + + it('scanLocalForPush does not publish the copy an excluded tool still holds', async () => { + // `removeItem` leaves an excluded tool's copy alone, and a namespaced + // removal tombstones only `/`: were this copy read, the next push + // would republish the agent the author just removed (#649 review). + await fse.writeFile( + path.join(homeDir, '.codebuddy/agents', 'vr.md'), + '---\nname: vr\ndescription: reviews code\n---\n\nYou review.\n', + ); + + const items = await handler.scanLocalForPush(teamConfig, { ...localConfig, enabledAgents: ['claude'] }); + + expect(items.find((i) => i.name === 'vr')).toBeUndefined(); + }); }); diff --git a/src/__tests__/doctor-agents-delivery.test.ts b/src/__tests__/doctor-agents-delivery.test.ts index 354cb9fb..9f337e93 100644 --- a/src/__tests__/doctor-agents-delivery.test.ts +++ b/src/__tests__/doctor-agents-delivery.test.ts @@ -8,6 +8,8 @@ vi.mock('../config.js', async (importOriginal) => ({ detectProjectConfig: vi.fn().mockResolvedValue(null), loadLocalConfig: vi.fn(), loadTeamConfig: vi.fn(), + // resolveDesiredAgents reads placement records to mirror what pull delivers. + loadStateForScope: vi.fn().mockResolvedValue({}), })); vi.mock('../utils/logger.js', () => ({ diff --git a/src/__tests__/doctor-rules-delivery.test.ts b/src/__tests__/doctor-rules-delivery.test.ts index 72bf26af..411f5b75 100644 --- a/src/__tests__/doctor-rules-delivery.test.ts +++ b/src/__tests__/doctor-rules-delivery.test.ts @@ -8,6 +8,8 @@ vi.mock('../config.js', async (importOriginal) => ({ detectProjectConfig: vi.fn().mockResolvedValue(null), loadLocalConfig: vi.fn(), loadTeamConfig: vi.fn(), + // resolveDesiredAgents reads placement records to mirror what pull delivers. + loadStateForScope: vi.fn().mockResolvedValue({}), })); vi.mock('../utils/logger.js', () => ({ diff --git a/src/__tests__/doctor.test.ts b/src/__tests__/doctor.test.ts index f68ae101..7168ff61 100644 --- a/src/__tests__/doctor.test.ts +++ b/src/__tests__/doctor.test.ts @@ -8,6 +8,8 @@ vi.mock('../config.js', async (importOriginal) => ({ loadLocalConfig: vi.fn(), loadTeamConfig: vi.fn(), detectProjectConfig: vi.fn().mockResolvedValue(null), + // resolveDesiredAgents reads placement records to mirror what pull delivers. + loadStateForScope: vi.fn().mockResolvedValue({}), })); vi.mock('../utils/fs.js', () => ({ diff --git a/src/__tests__/pkg-team-distribution.test.ts b/src/__tests__/pkg-team-distribution.test.ts index 8d037569..f8f24ca5 100644 --- a/src/__tests__/pkg-team-distribution.test.ts +++ b/src/__tests__/pkg-team-distribution.test.ts @@ -91,6 +91,7 @@ vi.mock('../utils/pre-push-sync.js', () => ({ })); vi.mock('../utils/prompt.js', () => ({ + isInteractive: vi.fn(() => true), askQuestion: vi.fn(async () => ''), askConfirmation: vi.fn(async () => true), askSelection: vi.fn(async (_prompt: string, count: number) => diff --git a/src/__tests__/placement-records.test.ts b/src/__tests__/placement-records.test.ts new file mode 100644 index 00000000..13f2d58b --- /dev/null +++ b/src/__tests__/placement-records.test.ts @@ -0,0 +1,371 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import path from 'node:path'; +import os from 'node:os'; +import fse from 'fs-extra'; + +const mockWarn = vi.fn(); +vi.mock('../utils/logger.js', () => ({ + log: { debug: vi.fn(), info: vi.fn(), warn: (...args: unknown[]) => mockWarn(...args), error: vi.fn(), success: vi.fn() }, +})); +const mockRemoteBranchExists = vi.fn(); +vi.mock('../utils/git.js', async () => ({ + ...(await vi.importActual('../utils/git.js')), + remoteBranchExists: (...args: unknown[]) => mockRemoteBranchExists(...args), +})); +import { execFileSync } from 'node:child_process'; + +import { reconcilePlacementRecords, isPlacement } from '../utils/pending-push.js'; +import type { PendingPush, ResourceItem } from '../types.js'; + +/** + * A placement record says "the author's root copy of IS the team file + * at //". Push marks the placement on the pending PR entry; + * only once that file is on the default branch does it become a record — so a + * PR closed unmerged, branch kept or not, never leaves one behind, and no + * provider has to be asked whether a PR is open. A record is withdrawn again + * when its file is gone, or when a shared-root file of the same name appears + * and takes over the root path in every tool directory (#649 review). + */ +describe('reconcilePlacementRecords', () => { + let repoPath: string; + const pending = (items: PendingPush['items'], branch = 'teamai/push/me/1'): PendingPush => ({ + branch, prUrl: null, createdAt: '2026-01-01T00:00:00.000Z', items, + }); + // A factory: recording consumes the mark on the item, so tests must not share one. + const placedRule = () => ({ type: 'rules', name: 'my-rule', relativePath: 'rules/fe/my-rule.md', namespace: 'fe', placed: true }); + + beforeEach(async () => { + repoPath = await fse.mkdtemp(path.join(os.tmpdir(), 'teamai-placed-')); + mockRemoteBranchExists.mockReset(); + mockWarn.mockReset(); + }); + afterEach(async () => { await fse.remove(repoPath); }); + + /** Commit `content` at `rel` on the default branch, as a merge would; returns its blob. */ + const land = async (rel: string, content: string): Promise => { + const run = (args: string[]) => execFileSync('git', args, { cwd: repoPath, encoding: 'utf8', env: { + ...process.env, GIT_AUTHOR_NAME: 'T', GIT_AUTHOR_EMAIL: 't@t', GIT_COMMITTER_NAME: 'T', GIT_COMMITTER_EMAIL: 't@t', + } }).trim(); + if (!await fse.pathExists(path.join(repoPath, '.git'))) run(['init', '-q', '-b', 'main']); + await fse.outputFile(path.join(repoPath, rel), content); + run(['add', '-A']); run(['commit', '-q', '-m', `land ${rel}`]); + return run(['hash-object', rel]); + }; + + it('records a placement once the blob it pushed is on the default branch', async () => { + const blob = await land('rules/fe/my-rule.md', 'x'); + const state = { placedRules: {}, placedAgents: {}, pendingPushes: [pending([{ ...placedRule(), blob }])] }; + + expect(await reconcilePlacementRecords(repoPath, state)).toBe(true); + expect(state.placedRules).toEqual({ 'my-rule': 'rules/fe/my-rule.md' }); + }); + + it('spends a placement with no blob instead of recording it because the path exists', async () => { + // Nothing tells its landing from another member creating the path after + // this PR closed unmerged (#649 review). + await fse.outputFile(path.join(repoPath, 'rules/fe/my-rule.md'), 'somebody else\'s'); + const entry = pending([placedRule()]); + const state = { placedRules: {}, placedAgents: {}, pendingPushes: [entry] }; + + expect(await reconcilePlacementRecords(repoPath, state)).toBe(true); + expect(state.placedRules).toEqual({}); + expect(entry.items[0]?.placed).toBe(false); + }); + + it('records nothing while the placement is not on the default branch, whatever its branch is doing', async () => { + // Open PR, or closed unmerged with the branch kept: the same from here, + // and neither may leave a record. + const state = { placedRules: {}, placedAgents: {}, pendingPushes: [pending([{ ...placedRule(), blob: 'deadbeef' }])] }; + + expect(await reconcilePlacementRecords(repoPath, state)).toBe(false); + expect(state.placedRules).toEqual({}); + expect(mockRemoteBranchExists).not.toHaveBeenCalled(); + }); + + it('records a placement only when the blob it pushed is in the default branch history for that path', async () => { + const git = (args: string[]) => execFileSync('git', args, { cwd: repoPath, encoding: 'utf8', env: { + ...process.env, GIT_AUTHOR_NAME: 'T', GIT_AUTHOR_EMAIL: 't@t', GIT_COMMITTER_NAME: 'T', GIT_COMMITTER_EMAIL: 't@t', + } }).trim(); + git(['init', '-q', '-b', 'main']); + await fse.outputFile(path.join(repoPath, 'rules/fe/my-rule.md'), 'ours, as pushed\n'); + git(['add', '-A']); git(['commit', '-q', '-m', 'merge ours']); + const ours = git(['hash-object', 'rules/fe/my-rule.md']); + // A teammate edits it afterwards: the path still exists, the blob differs now. + await fse.outputFile(path.join(repoPath, 'rules/fe/my-rule.md'), 'edited after the merge\n'); + git(['add', '-A']); git(['commit', '-q', '-m', 'teammate edit']); + await fse.outputFile(path.join(repoPath, 'never-committed.md'), 'never pushed anywhere\n'); + const theirs = git(['hash-object', 'never-committed.md']); + + const landed = { placedRules: {}, placedAgents: {}, pendingPushes: [pending([{ ...placedRule(), blob: ours }])] }; + expect(await reconcilePlacementRecords(repoPath, landed)).toBe(true); + expect(landed.placedRules).toEqual({ 'my-rule': 'rules/fe/my-rule.md' }); + + // Same path, but what is there was never what we pushed: somebody else's file. + const shadow = { placedRules: {}, placedAgents: {}, pendingPushes: [pending([{ ...placedRule(), blob: theirs }])] }; + expect(await reconcilePlacementRecords(repoPath, shadow)).toBe(false); + expect(shadow.placedRules).toEqual({}); + }); + + it('proves landing only by commits after the revision the push branch was built on', async () => { + const git = (args: string[]) => execFileSync('git', args, { cwd: repoPath, encoding: 'utf8', env: { + ...process.env, GIT_AUTHOR_NAME: 'T', GIT_AUTHOR_EMAIL: 't@t', GIT_COMMITTER_NAME: 'T', GIT_COMMITTER_EMAIL: 't@t', + } }).trim(); + const file = path.join(repoPath, 'rules/fe/my-rule.md'); + git(['init', '-q', '-b', 'main']); + // The same content sat at this path once, long before the push. + await fse.outputFile(file, 'ours, as pushed\n'); + git(['add', '-A']); git(['commit', '-q', '-m', 'an old rule']); + const ours = git(['hash-object', 'rules/fe/my-rule.md']); + git(['rm', '-q', 'rules/fe/my-rule.md']); git(['commit', '-q', '-m', 'retired']); + const base = git(['rev-parse', '--short', 'HEAD']); + // The placement PR is closed unmerged; a teammate then creates the path. + await fse.outputFile(file, 'somebody else\'s\n'); + git(['add', '-A']); git(['commit', '-q', '-m', 'teammate rule']); + + const entry = { ...pending([{ ...placedRule(), blob: ours }]), base }; + const closed = { placedRules: {}, placedAgents: {}, pendingPushes: [entry] }; + await reconcilePlacementRecords(repoPath, closed); + expect(closed.placedRules).toEqual({}); + // The path arrived without what was pushed: indistinguishable from a + // reviewer's edit before a squash merge, so the author is told, once. + expect(entry.items[0]?.placed).toBe(false); + expect(mockWarn.mock.calls.flat().join(' ')).toContain('not with the content you pushed'); + }); + + it('drops a record whose file was deleted and recreated between two checks', async () => { + const git = (args: string[]) => execFileSync('git', args, { cwd: repoPath, encoding: 'utf8', env: { + ...process.env, GIT_AUTHOR_NAME: 'T', GIT_AUTHOR_EMAIL: 't@t', GIT_COMMITTER_NAME: 'T', GIT_COMMITTER_EMAIL: 't@t', + } }).trim(); + const file = path.join(repoPath, 'agents/fe/vr.yaml'); + git(['init', '-q', '-b', 'main']); + await fse.outputFile(file, 'name: vr\n# the author\'s\n'); + git(['add', '-A']); git(['commit', '-q', '-m', 'placement merged']); + const state = { placedRules: {}, placedAgents: { vr: 'agents/fe/vr.yaml' }, pendingPushes: [] } as { + placedRules: Record; placedAgents: Record; + pendingPushes: PendingPush[]; placementsCheckedAt?: string; + }; + expect(await reconcilePlacementRecords(repoPath, state)).toBe(true); + expect(state.placementsCheckedAt).toBe(git(['rev-parse', 'HEAD'])); + + // The author's removal merges, and another member publishes their own vr + // at the same path before the author runs anything. + git(['rm', '-q', 'agents/fe/vr.yaml']); git(['commit', '-q', '-m', 'removal merged']); + await fse.outputFile(file, 'name: vr\n# somebody else\'s\n'); + git(['add', '-A']); git(['commit', '-q', '-m', 'teammate vr']); + + expect(await reconcilePlacementRecords(repoPath, state)).toBe(true); + expect(state.placedAgents).toEqual({}); + // Retired just like a record whose file is simply gone: the author's + // flattened copy was the removed agent's, whatever sits there now. + expect((state as { retiredPlacedAgents?: Record }).retiredPlacedAgents) + .toEqual({ vr: 'agents/fe/vr.yaml' }); + }); + + describe('the checkpoint and the ref it is read from', () => { + const git = (args: string[]) => execFileSync('git', args, { cwd: repoPath, encoding: 'utf8', env: { + ...process.env, GIT_AUTHOR_NAME: 'T', GIT_AUTHOR_EMAIL: 't@t', GIT_COMMITTER_NAME: 'T', GIT_COMMITTER_EMAIL: 't@t', + } }).trim(); + type ReconcileState = Parameters[1]; + const commitFile = async (rel: string, content: string, message: string) => { + await fse.outputFile(path.join(repoPath, rel), content); + git(['add', '-A']); git(['commit', '-q', '-m', message]); + return git(['hash-object', rel]); + }; + + it('clears the checkpoint with the last record, so a later re-placement at that path is recorded', async () => { + git(['init', '-q', '-b', 'main']); + await commitFile('rules/fe/my-rule.md', 'first\n', 'placement merged'); + const state: ReconcileState = { placedRules: { 'my-rule': 'rules/fe/my-rule.md' }, placedAgents: {}, pendingPushes: [] }; + await reconcilePlacementRecords(repoPath, state); + git(['rm', '-q', 'rules/fe/my-rule.md']); git(['commit', '-q', '-m', 'removal merged']); + await reconcilePlacementRecords(repoPath, state); + expect(state.placedRules).toEqual({}); + expect(state.placementsCheckedAt).toBeUndefined(); + + // The author places the rule again, and that PR merges. + const base = git(['rev-parse', 'HEAD']); + const blob = await commitFile('rules/fe/my-rule.md', 'second\n', 're-placement merged'); + state.pendingPushes = [{ ...pending([{ ...placedRule(), blob }]), base }]; + await reconcilePlacementRecords(repoPath, state); + + expect(state.placedRules).toEqual({ 'my-rule': 'rules/fe/my-rule.md' }); + }); + + it('keeps a record made in this run although the checkpoint predates an earlier deletion of its path', async () => { + git(['init', '-q', '-b', 'main']); + await commitFile('rules/fe/other.md', 'other\n', 'another placement'); + const state: ReconcileState = { placedRules: { other: 'rules/fe/other.md' }, placedAgents: {}, pendingPushes: [] }; + await reconcilePlacementRecords(repoPath, state); + // Before the author's next check: my-rule is created and deleted by + // somebody, then the author's own placement of it lands. + await commitFile('rules/fe/my-rule.md', 'somebody\'s\n', 'teammate rule'); + git(['rm', '-q', 'rules/fe/my-rule.md']); git(['commit', '-q', '-m', 'teammate removal']); + const base = git(['rev-parse', 'HEAD']); + const blob = await commitFile('rules/fe/my-rule.md', 'ours\n', 'our placement merged'); + state.pendingPushes = [{ ...pending([{ ...placedRule(), blob }]), base }]; + + await reconcilePlacementRecords(repoPath, state); + + expect(state.placedRules).toEqual({ other: 'rules/fe/other.md', 'my-rule': 'rules/fe/my-rule.md' }); + }); + + it('reads the default branch through a ref when the checkout is somewhere else', async () => { + // A single-repo member on a feature branch cut before the placement merged. + git(['init', '-q', '-b', 'main']); + await commitFile('README.md', 'seed\n', 'seed'); + git(['checkout', '-q', '-b', 'feature']); + git(['checkout', '-q', 'main']); + const base = git(['rev-parse', 'HEAD']); + const blob = await commitFile('rules/fe/my-rule.md', 'ours\n', 'placement merged'); + git(['checkout', '-q', 'feature']); + const state: ReconcileState = { + placedRules: {}, placedAgents: {}, pendingPushes: [{ ...pending([{ ...placedRule(), blob }]), base }], + }; + + expect(await reconcilePlacementRecords(repoPath, state, 'main')).toBe(true); + expect(state.placedRules).toEqual({ 'my-rule': 'rules/fe/my-rule.md' }); + expect(state.placementsCheckedAt).toBe(git(['rev-parse', 'main'])); + // Reconciled against the checkout itself, the same record is dropped. + const againstCheckout: ReconcileState = { ...state, placedRules: { ...state.placedRules } }; + await reconcilePlacementRecords(repoPath, againstCheckout); + expect(againstCheckout.placedRules).toEqual({}); + }); + + it('changes nothing when the ref cannot be resolved', async () => { + git(['init', '-q', '-b', 'main']); + await commitFile('README.md', 'seed\n', 'seed'); + const state: ReconcileState = { placedRules: { 'my-rule': 'rules/fe/my-rule.md' }, placedAgents: {}, pendingPushes: [] }; + + expect(await reconcilePlacementRecords(repoPath, state, 'origin/main')).toBe(false); + expect(state.placedRules).toEqual({ 'my-rule': 'rules/fe/my-rule.md' }); + }); + }); + + it('spends a placement unrecorded when its path was deleted and recreated before the first check', async () => { + const git = (args: string[]) => execFileSync('git', args, { cwd: repoPath, encoding: 'utf8', env: { + ...process.env, GIT_AUTHOR_NAME: 'T', GIT_AUTHOR_EMAIL: 't@t', GIT_COMMITTER_NAME: 'T', GIT_COMMITTER_EMAIL: 't@t', + } }).trim(); + const file = path.join(repoPath, 'rules/fe/my-rule.md'); + git(['init', '-q', '-b', 'main']); + await fse.outputFile(path.join(repoPath, 'README.md'), 'seed\n'); + git(['add', '-A']); git(['commit', '-q', '-m', 'seed']); + const base = git(['rev-parse', 'HEAD']); + await fse.outputFile(file, 'ours, as pushed\n'); + const ours = git(['hash-object', 'rules/fe/my-rule.md']); + git(['add', '-A']); git(['commit', '-q', '-m', 'placement merged']); + git(['rm', '-q', 'rules/fe/my-rule.md']); git(['commit', '-q', '-m', 'team deleted it']); + await fse.outputFile(file, 'somebody else\'s\n'); + git(['add', '-A']); git(['commit', '-q', '-m', 'teammate rule']); + + const entry = { ...pending([{ ...placedRule(), blob: ours }]), base }; + const state = { placedRules: {}, placedAgents: {}, pendingPushes: [entry] }; + expect(await reconcilePlacementRecords(repoPath, state)).toBe(true); + expect(state.placedRules).toEqual({}); + expect(entry.items[0]?.placed).toBe(false); + }); + + it('records a placement once: not again after the team deleted the file and someone recreated the path', async () => { + const blob = await land('rules/fe/my-rule.md', 'ours'); + const entry = pending([{ ...placedRule(), blob }]); + const state = { placedRules: {}, placedAgents: {}, pendingPushes: [entry] }; + expect(await reconcilePlacementRecords(repoPath, state)).toBe(true); + expect(state.placedRules).toEqual({ 'my-rule': 'rules/fe/my-rule.md' }); + expect(entry.items[0]?.placed).toBe(false); + + // The team deletes it: the record goes. + execFileSync('git', ['rm', '-q', 'rules/fe/my-rule.md'], { cwd: repoPath }); + execFileSync('git', ['-c', 'user.name=T', '-c', 'user.email=t@t', 'commit', '-q', '-m', 'deleted'], { cwd: repoPath }); + expect(await reconcilePlacementRecords(repoPath, state)).toBe(true); + expect(state.placedRules).toEqual({}); + + // Another member recreates the path. The blob we pushed is still in the + // history, so only the consumed mark keeps this from becoming ours again. + await land('rules/fe/my-rule.md', 'somebody else\'s'); + expect(await reconcilePlacementRecords(repoPath, state)).toBe(false); + expect(state.placedRules).toEqual({}); + }); + + it('does not record a pending item that was not a placement', async () => { + await fse.outputFile(path.join(repoPath, 'rules/fe/my-rule.md'), 'x'); + const state = { placedRules: {}, placedAgents: {}, pendingPushes: [pending([{ ...placedRule(), placed: undefined }])] }; + + expect(await reconcilePlacementRecords(repoPath, state)).toBe(false); + expect(state.placedRules).toEqual({}); + }); + + it('keeps a record whose file is on the default branch', async () => { + await fse.outputFile(path.join(repoPath, 'rules/fe/my-rule.md'), 'x'); + const state = { placedRules: { 'my-rule': 'rules/fe/my-rule.md' }, placedAgents: {}, pendingPushes: [] }; + + expect(await reconcilePlacementRecords(repoPath, state)).toBe(false); + expect(state.placedRules).toEqual({ 'my-rule': 'rules/fe/my-rule.md' }); + }); + + it('drops every record whose file the team has deleted, not just the last one', async () => { + // Deleting by destructuring from the ORIGINAL map put back what an earlier + // iteration had removed, so only the last stale record actually went. + await fse.outputFile(path.join(repoPath, 'rules/fe/kept.md'), 'x'); + const state = { + placedRules: { gone1: 'rules/fe/gone1.md', kept: 'rules/fe/kept.md', gone2: 'rules/fe/gone2.md' }, + placedAgents: { vr: 'agents/fe/vr.yaml', qa: 'agents/fe/qa.yaml' }, + pendingPushes: [], + }; + + expect(await reconcilePlacementRecords(repoPath, state)).toBe(true); + expect(state.placedRules).toEqual({ kept: 'rules/fe/kept.md' }); + expect(state.placedAgents).toEqual({}); + // The author's flattened agent copies stood for these; removedStems needs that. + expect((state as { retiredPlacedAgents?: Record }).retiredPlacedAgents) + .toEqual({ vr: 'agents/fe/vr.yaml', qa: 'agents/fe/qa.yaml' }); + }); + + it('withdraws a record once a shared-root file of the same name exists, and says so', async () => { + await fse.outputFile(path.join(repoPath, 'rules/fe/my-rule.md'), 'the author\'s'); + await fse.outputFile(path.join(repoPath, 'rules/my-rule.md'), 'somebody else\'s, for everyone'); + const state = { placedRules: { 'my-rule': 'rules/fe/my-rule.md' }, placedAgents: {}, pendingPushes: [] }; + + expect(await reconcilePlacementRecords(repoPath, state)).toBe(true); + expect(state.placedRules).toEqual({}); + expect(mockWarn.mock.calls.flat().join(' ')).toContain('rules/my-rule.md now exists at the shared root'); + }); + + it('withdraws an agent record shadowed by a legacy shared-root .md of the same stem', async () => { + await fse.outputFile(path.join(repoPath, 'agents/fe/vr.yaml'), 'name: vr\n'); + await fse.outputFile(path.join(repoPath, 'agents/vr.md'), '# vr\n'); + const state = { placedRules: {}, placedAgents: { vr: 'agents/fe/vr.yaml' }, pendingPushes: [] }; + + expect(await reconcilePlacementRecords(repoPath, state)).toBe(true); + expect(state.placedAgents).toEqual({}); + }); + + it('does not record a landed placement that a shared-root file already shadows', async () => { + const blob = await land('rules/fe/my-rule.md', 'x'); + await land('rules/my-rule.md', 'y'); + const state = { placedRules: {}, placedAgents: {}, pendingPushes: [pending([{ ...placedRule(), blob }])] }; + + await reconcilePlacementRecords(repoPath, state); + + expect(state.placedRules).toEqual({}); + }); +}); + +describe('isPlacement', () => { + const base = { sourcePath: '/tmp/x', status: 'new' as const }; + it('is a new root rule or agent that ended up namespaced', () => { + expect(isPlacement({ ...base, type: 'rules', name: 'my-rule', relativePath: 'rules/fe/my-rule.md', namespace: 'fe' })).toBe(true); + expect(isPlacement({ ...base, type: 'agents', name: 'vr', relativePath: 'agents/fe/vr.yaml', namespace: 'fe' })).toBe(true); + }); + it('is not a rule the scanner found in a subdirectory, a modified item, a skill, or an unplaced one', () => { + expect(isPlacement({ ...base, type: 'rules', name: 'fe/my-rule', relativePath: 'rules/fe/my-rule.md', namespace: 'fe' })).toBe(false); + expect(isPlacement({ ...base, type: 'agents', name: 'vr', relativePath: 'agents/fe/vr.yaml', namespace: 'fe', status: 'modified' })).toBe(false); + expect(isPlacement({ ...base, type: 'skills', name: 's', relativePath: 'skills/fe/s', namespace: 'fe' })).toBe(false); + expect(isPlacement({ ...base, type: 'rules', name: 'my-rule', relativePath: 'rules/my-rule.md' })).toBe(false); + }); + it('is an agent rewritten under another extension through its record', () => { + const item: ResourceItem & { supersedes: string } = { + ...base, type: 'agents', name: 'vr', relativePath: 'agents/fe/vr.yaml', namespace: 'fe', + status: 'modified', supersedes: 'agents/fe/vr.md', + }; + expect(isPlacement(item)).toBe(true); + }); +}); diff --git a/src/__tests__/pre-push-sync.test.ts b/src/__tests__/pre-push-sync.test.ts index 4bde47e0..80d257b6 100644 --- a/src/__tests__/pre-push-sync.test.ts +++ b/src/__tests__/pre-push-sync.test.ts @@ -16,8 +16,11 @@ vi.mock('../utils/logger.js', () => ({ // Mock getFileContentAtRev since test dirs are not real git repos const mockGetFileContentAtRev = vi.fn<(repoPath: string, rev: string, filePath: string) => Promise>(); +const mockGetFileContentWhenAdded = vi.fn<(repoPath: string, filePath: string) => Promise>() + .mockResolvedValue(null); vi.mock('../utils/git.js', () => ({ getFileContentAtRev: (...args: [string, string, string]) => mockGetFileContentAtRev(...args), + getFileContentWhenAdded: (...args: [string, string]) => mockGetFileContentWhenAdded(...args), createGit: vi.fn(), pullRepo: vi.fn(), pushRepoBranch: vi.fn(), @@ -97,6 +100,86 @@ describe('syncTeamUpdatesToLocal — rules', () => { expect(content).toBe('v2 content'); }); + it('syncs a root-authored rule through its recorded rules// destination', async () => { + // push placed this rule under rules/fe-know/; the author's copy stayed at + // the tool's rules root, so there is no rules/my-rule.md to compare against. + await fse.ensureDir(path.join(repoPath, 'rules', 'fe-know')); + await fse.writeFile(path.join(repoPath, 'rules/fe-know', 'my-rule.md'), 'teammate v2'); + await fse.writeFile(path.join(homeDir, '.claude/rules', 'my-rule.md'), 'v1 content'); + mockGetFileContentAtRev.mockResolvedValue(Buffer.from('v1 content')); + + await syncTeamUpdatesToLocal(teamConfig, localConfig, 'abc1234', { + 'my-rule': 'rules/fe-know/my-rule.md', + }); + + // Without the redirect the stale root copy reads as a local modification and + // the next push sends it over the teammate's update. + const content = await fse.readFile(path.join(homeDir, '.claude/rules', 'my-rule.md'), 'utf-8'); + expect(content).toBe('teammate v2'); + expect(mockGetFileContentAtRev).toHaveBeenCalledWith(repoPath, 'abc1234', 'rules/fe-know/my-rule.md'); + }); + + it('syncs a placement that landed after the last pull from the version it was added with', async () => { + // Placed, merged, recorded — and a teammate edits it before the author's + // next pull. At lastPullRev the file did not exist yet (#649 review). + await fse.outputFile(path.join(repoPath, 'rules/fe-know', 'my-rule.md'), 'teammate v2'); + await fse.writeFile(path.join(homeDir, '.claude/rules', 'my-rule.md'), 'as placed'); + mockGetFileContentAtRev.mockResolvedValue(null); + mockGetFileContentWhenAdded.mockResolvedValueOnce(Buffer.from('as placed')); + + await syncTeamUpdatesToLocal(teamConfig, localConfig, 'abc1234', { + 'my-rule': 'rules/fe-know/my-rule.md', + }); + + expect(await fse.readFile(path.join(homeDir, '.claude/rules', 'my-rule.md'), 'utf-8')).toBe('teammate v2'); + expect(mockGetFileContentWhenAdded).toHaveBeenCalledWith(repoPath, 'rules/fe-know/my-rule.md'); + }); + + it('keeps following the record when a shared-root rule with the same name exists', async () => { + // Both sides must resolve the same file. If the sync compared against + // rules/my-rule.md while the scanner followed the record, the scan would + // read the local copy as modified and push it over the placed rule. + await fse.ensureDir(path.join(repoPath, 'rules', 'fe-know')); + await fse.writeFile(path.join(repoPath, 'rules/fe-know', 'my-rule.md'), 'teammate v2'); + await fse.writeFile(path.join(repoPath, 'rules', 'my-rule.md'), 'somebody else\'s shared rule'); + await fse.writeFile(path.join(homeDir, '.claude/rules', 'my-rule.md'), 'v1 content'); + mockGetFileContentAtRev.mockResolvedValue(Buffer.from('v1 content')); + + await syncTeamUpdatesToLocal(teamConfig, localConfig, 'abc1234', { + 'my-rule': 'rules/fe-know/my-rule.md', + }); + + expect(await fse.readFile(path.join(homeDir, '.claude/rules', 'my-rule.md'), 'utf-8')) + .toBe('teammate v2'); + expect(mockGetFileContentAtRev).toHaveBeenCalledWith(repoPath, 'abc1234', 'rules/fe-know/my-rule.md'); + }); + + it('leaves a root rule alone when no record maps it to a namespaced team rule', async () => { + await fse.ensureDir(path.join(repoPath, 'rules', 'fe-know')); + await fse.writeFile(path.join(repoPath, 'rules/fe-know', 'my-rule.md'), 'someone else v2'); + await fse.writeFile(path.join(homeDir, '.claude/rules', 'my-rule.md'), 'my own rule'); + mockGetFileContentAtRev.mockResolvedValue(Buffer.from('my own rule')); + + await syncTeamUpdatesToLocal(teamConfig, localConfig, 'abc1234', {}); + + // A shared basename is not evidence: this machine never pushed that rule. + const content = await fse.readFile(path.join(homeDir, '.claude/rules', 'my-rule.md'), 'utf-8'); + expect(content).toBe('my own rule'); + }); + + it('ignores a record whose team file is gone', async () => { + await fse.writeFile(path.join(homeDir, '.claude/rules', 'my-rule.md'), 'v1 content'); + mockGetFileContentAtRev.mockResolvedValue(Buffer.from('v1 content')); + + await syncTeamUpdatesToLocal(teamConfig, localConfig, 'abc1234', { + 'my-rule': 'rules/fe-know/my-rule.md', + }); + + const content = await fse.readFile(path.join(homeDir, '.claude/rules', 'my-rule.md'), 'utf-8'); + expect(content).toBe('v1 content'); + expect(mockGetFileContentAtRev).not.toHaveBeenCalled(); + }); + it('should NOT sync local rule when user edited it', async () => { // Team repo has v2 await fse.writeFile(path.join(repoPath, 'rules', 'my-rule.md'), 'v2 content'); diff --git a/src/__tests__/pull-agents-role-filter.test.ts b/src/__tests__/pull-agents-role-filter.test.ts index 0f74a121..2c30a032 100644 --- a/src/__tests__/pull-agents-role-filter.test.ts +++ b/src/__tests__/pull-agents-role-filter.test.ts @@ -86,4 +86,41 @@ describe('filterAgentsByNamespaces', () => { expect(() => filterAgentsByNamespaces(agents, null)).toThrow(/Duplicate agent "reviewer"/); }); + + /** + * An agent published with --role/--project lives in a namespace this + * directory need not activate, and push lets the author keep editing it + * through the placement record. Pull has to deliver it for the same reason: + * otherwise the local copy never tracks the team file and the next push + * writes a stale rendering over whoever changed it (#649 review). + */ + it('delivers an agent this machine published into an inactive namespace', () => { + const agents = [makeAgent('vr', 'fe-agents'), makeAgent('other', 'devops')]; + + const result = filterAgentsByNamespaces(agents, ['common'], { + vr: 'agents/fe-agents/vr.yaml', + }); + + expect(result.map((a) => a.name)).toEqual(['vr']); + }); + + it('leaves the record alone when an active namespace claims that stem', () => { + // Agents deploy flattened, so two of one stem would collide on the same + // filename — and the active one is the agent deployed here. + const agents = [makeAgent('vr', 'common'), makeAgent('vr', 'fe-agents')]; + + const result = filterAgentsByNamespaces(agents, ['common'], { + vr: 'agents/fe-agents/vr.yaml', + }); + + expect(result).toHaveLength(1); + expect(result[0]?.namespace).toBe('common'); + }); + + it('ignores a record that does not match the agent it names', () => { + const agents = [makeAgent('vr', 'fe-agents')]; + + expect(filterAgentsByNamespaces(agents, ['common'], { vr: 'agents/other/vr.yaml' })) + .toEqual([]); + }); }); diff --git a/src/__tests__/pull-placement-reconcile.test.ts b/src/__tests__/pull-placement-reconcile.test.ts new file mode 100644 index 00000000..052c323b --- /dev/null +++ b/src/__tests__/pull-placement-reconcile.test.ts @@ -0,0 +1,147 @@ +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import os from 'node:os'; +import path from 'node:path'; +import fse from 'fs-extra'; + +const testRoot = await fse.mkdtemp(path.join(os.tmpdir(), 'teamai-pull-reconcile-')); +const originalHome = process.env.HOME; +process.env.HOME = path.join(testRoot, 'home'); + +vi.mock('../config.js', () => ({ + requireInit: vi.fn(), + loadState: vi.fn().mockResolvedValue({ lastPull: null, lastPullRev: null }), + saveState: vi.fn(), + loadLocalConfigForScope: vi.fn(), + loadTeamConfig: vi.fn(), + detectProjectConfig: vi.fn().mockResolvedValue(null), + loadStateForScope: vi.fn().mockResolvedValue({ lastPull: null, lastPullRev: null }), + saveStateForScope: vi.fn(), +})); + +vi.mock('../utils/git.js', () => ({ + pullRepo: vi.fn().mockResolvedValue('already up to date'), + getHeadRev: vi.fn().mockResolvedValue('abc1234'), + createGit: vi.fn(), + getDefaultBranch: vi.fn().mockResolvedValue('main'), +})); + +vi.mock('../utils/pending-push.js', () => ({ + reconcilePlacementRecords: vi.fn().mockResolvedValue(false), +})); + +vi.mock('../utils/logger.js', () => ({ + log: { info: vi.fn(), success: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn(), dim: vi.fn() }, + spinner: vi.fn(() => ({ + start: vi.fn().mockReturnThis(), + succeed: vi.fn().mockReturnThis(), + fail: vi.fn().mockReturnThis(), + warn: vi.fn().mockReturnThis(), + info: vi.fn().mockReturnThis(), + stop: vi.fn().mockReturnThis(), + })), +})); + +vi.mock('../utils/learnings-publish.js', () => ({ + publishQueuedLearnings: vi.fn().mockResolvedValue({ published: [], remaining: 0 }), +})); +vi.mock('../source.js', () => ({ pullSources: vi.fn().mockResolvedValue(undefined) })); +vi.mock('../hooks.js', () => ({ + injectHooksToAllTools: vi.fn().mockResolvedValue(undefined), + reconcileTeamHooksForConfig: vi.fn().mockResolvedValue([]), +})); +vi.mock('../mcp-reconcile.js', () => ({ + reconcileMcpForConfig: vi.fn().mockResolvedValue({ changes: [], wrote: false }), +})); +vi.mock('../team-push.js', () => ({ reportUsageToTeam: vi.fn().mockResolvedValue(undefined) })); +vi.mock('../usage-tracker.js', () => ({ + readUsageEvents: vi.fn().mockResolvedValue([]), + truncateUsageAfterReport: vi.fn().mockResolvedValue(undefined), +})); +vi.mock('../roles.js', () => ({ + loadRolesManifest: vi.fn().mockResolvedValue({ version: 1, roles: [], defaults: { shareTarget: 'primary-role' } }), + resolveRoleResourceNamespaces: vi.fn(() => ({ knowledge: [], skills: [], learnings: [] })), +})); +vi.mock('../update.js', () => ({ + acquireLock: vi.fn().mockResolvedValue(true), + releaseLock: vi.fn().mockResolvedValue(undefined), +})); + +const { pull } = await import('../pull.js'); +const { loadLocalConfigForScope, loadTeamConfig } = await import('../config.js'); +const { reconcilePlacementRecords } = await import('../utils/pending-push.js'); +import type { LocalConfig, TeamaiConfig } from '../types.js'; + +const business = path.join(testRoot, 'product'); +const teamConfig: TeamaiConfig = { + team: 'test', + description: '', + repo: 'https://example.test/team/repo.git', + provider: 'git', + reviewers: [], + sharing: { + skills: {}, + rules: { enforced: [] }, + docs: { localDir: '' }, + env: { injectShellProfile: false }, + }, + toolPaths: {}, +}; + +function config(kind: 'git' | 'self'): LocalConfig { + return kind === 'self' + ? { + repo: { + localPath: path.join(business, '.teamai'), + remote: 'https://example.test/team/repo.git', + kind: 'self', + businessRepoRoot: business, + }, + username: 'alice', + scope: 'user', + additionalRoles: [], + } + : { + repo: { localPath: path.join(testRoot, 'team-repo'), remote: 'https://example.test/team/repo.git', kind: 'git' }, + username: 'alice', + scope: 'user', + additionalRoles: [], + }; +} + +/** + * Placement records are settled against the default branch. An independent + * clone is that branch once pulled; a single-repo member's own checkout is + * whatever they have out — a feature branch, a main not pulled yet — and a + * record dropped against it never comes back, while skipping the pass there + * left a merged placement unrecorded until the next push (#649 review). So a + * single-repo pull reads the default branch as the ref origin/. + */ +describe('teamai pull settles placement records against the default branch', () => { + beforeEach(async () => { + vi.clearAllMocks(); + vi.mocked(loadTeamConfig).mockResolvedValue(teamConfig); + await fse.outputFile(path.join(business, '.teamai', 'teamai.yaml'), 'team: test\n'); + await fse.outputFile(path.join(testRoot, 'team-repo', 'teamai.yaml'), 'team: test\n'); + }); + + afterAll(async () => { + process.env.HOME = originalHome; + await fse.remove(testRoot); + }); + + it('in an independent clone', async () => { + vi.mocked(loadLocalConfigForScope).mockResolvedValue(config('git')); + + await pull({ silent: true, force: true }); + + expect(reconcilePlacementRecords).toHaveBeenCalledWith(path.join(testRoot, 'team-repo'), expect.anything(), undefined); + }); + + it('in single-repo mode, through origin/ rather than the member\'s checkout', async () => { + vi.mocked(loadLocalConfigForScope).mockResolvedValue(config('self')); + + await pull({ silent: true, force: true }); + + expect(reconcilePlacementRecords).toHaveBeenCalledWith(path.join(business, '.teamai'), expect.anything(), 'origin/main'); + }); +}); diff --git a/src/__tests__/push-namespace-e2e.test.ts b/src/__tests__/push-namespace-e2e.test.ts new file mode 100644 index 00000000..787608b8 --- /dev/null +++ b/src/__tests__/push-namespace-e2e.test.ts @@ -0,0 +1,973 @@ +/** + * Issue #649 end to end, against the built CLI and real git remotes. + * + * `teamai push --role/--project` used to place new SKILLS only. A new rule was + * written to `rules/.md` and a new agent to `agents/.yaml`, neither + * of which carries a namespace segment, so `pull` delivered both to every member + * of the team. These tests drive `dist/index.js` and assert on the content of + * the branch that reached the remote, not on CLI output alone. + */ +import { afterEach, describe, expect, it } from 'vitest'; +import { execFileSync, spawn } from 'node:child_process'; +import fs from 'node:fs'; +import http from 'node:http'; +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 PUSH_AGENTS = ['claude', 'codex', 'codebuddy', 'opencode'] as const; +type PushAgent = (typeof PUSH_AGENTS)[number]; + +const GIT_ENV = { + GIT_AUTHOR_NAME: 'TeamAI CI', + GIT_AUTHOR_EMAIL: 'ci@teamai.test', + GIT_COMMITTER_NAME: 'TeamAI CI', + GIT_COMMITTER_EMAIL: 'ci@teamai.test', +}; + +interface RunResult { + code: number | null; + output: string; +} + +function git(args: string[], cwd: string): string { + return execFileSync('git', args, { + cwd, + encoding: 'utf8', + env: { ...process.env, ...GIT_ENV }, + }).trim(); +} + +function runCLI( + args: string[], + cwd: string, + home: string, + envOverrides: Record = {}, +): Promise { + return new Promise((resolve) => { + const child = spawn('node', [CLI, ...args], { + cwd, + env: { ...process.env, ...GIT_ENV, HOME: home, FORCE_COLOR: '0', ...envOverrides }, + 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 })); + }); +} + +/** + * A new agent authored in the tool's OWN format, because push reverse-parses + * the local file before it can place the canonical `.yaml`. Codex reads TOML; + * the others read `.md` with YAML frontmatter. + */ +function localAgentFile(agent: PushAgent): { name: string; content: string } { + if (agent === 'codex') { + return { + name: 'vr.toml', + content: 'name = "vr"\ndescription = "reviews code"\ndeveloper_instructions = "You review."\n', + }; + } + return { + name: 'vr.md', + content: '---\nname: vr\ndescription: reviews code\n---\n\nYou review.\n', + }; +} + +const PROJECTS_MANIFEST = [ + 'version: 1', + 'projects:', + ' - id: front-app', + ' name: Front App', + ' description: Front end', + ' resources:', + ' knowledge: [fe-know]', + ' skills: [fe-skills]', + ' learnings: []', + ' agents: [fe-agents]', +].join('\n'); + +const ROLES_MANIFEST = [ + 'version: 1', + 'roles:', + ' - id: backend', + ' description: Backend', + ' resources:', + ' knowledge: [be-know]', + ' skills: [be-skills]', + ' agents: [be-agents]', +].join('\n'); + +interface Fixture { + sandbox: string; + home: string; + projectRoot: string; + remote: string; + teamRepo: string; + agent: PushAgent; + username: string; +} + +/** Seeded team repo + one member directory, with nothing pushed yet. */ +function makeFixture(options: { + agent: PushAgent; + provider: 'git' | 'github' | 'gitlab'; + repoUrl?: string; + username?: string; + rolesManifest?: string; +}): Fixture { + const { agent, provider } = options; + const username = options.username ?? `author-${provider}`; + const sandbox = fs.mkdtempSync(path.join(os.tmpdir(), `teamai-ns-649-${provider}-${agent}-`)); + const home = path.join(sandbox, 'home'); + const projectRoot = path.join(sandbox, 'project'); + const seed = path.join(sandbox, 'seed'); + const remote = path.join(sandbox, 'team.git'); + const teamRepo = path.join(projectRoot, '.teamai', 'team-repo'); + + fs.mkdirSync(home, { recursive: true }); + for (const dir of ['skills', 'rules', 'agents']) { + fs.mkdirSync(path.join(projectRoot, `.${agent}`, dir), { recursive: true }); + fs.mkdirSync(path.join(seed, dir), { recursive: true }); + fs.writeFileSync(path.join(seed, dir, '.gitkeep'), ''); + } + fs.mkdirSync(path.join(seed, 'manifest'), { recursive: true }); + fs.writeFileSync(path.join(seed, 'manifest', 'projects.yaml'), PROJECTS_MANIFEST); + fs.writeFileSync(path.join(seed, 'manifest', 'roles.yaml'), options.rolesManifest ?? ROLES_MANIFEST); + fs.writeFileSync( + path.join(seed, 'teamai.yaml'), + [ + 'team: issue-649', + `repo: ${options.repoUrl ?? 'https://git.example.test/team/issue-649.git'}`, + `provider: ${provider}`, + 'reviewers: []', + 'toolPaths:', + ` ${agent}:`, + ` skills: .${agent}/skills`, + ` rules: .${agent}/rules`, + ` agents: .${agent}/agents`, + ].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); + git(['clone', '-q', remote, teamRepo], projectRoot); + + fs.writeFileSync( + path.join(projectRoot, '.teamai', 'config.yaml'), + [ + 'repo:', + ` localPath: ${teamRepo}`, + ` remote: ${remote}`, + `username: ${username}`, + 'updatePolicy: auto', + 'primaryRole: backend', + 'additionalRoles: []', + 'scope: project', + `projectRoot: ${projectRoot}`, + ].join('\n'), + ); + + return { sandbox, home, projectRoot, remote, teamRepo, agent, username }; +} + +/** Author a new rule, skill and agent at each tool directory's root. */ +function writeLocalResources(fixture: Fixture, ruleBody = '# Rule v1\n'): void { + const { projectRoot, agent } = fixture; + fs.writeFileSync(path.join(projectRoot, `.${agent}/rules`, 'my-rule.md'), ruleBody); + fs.mkdirSync(path.join(projectRoot, `.${agent}/skills`, 'my-skill'), { recursive: true }); + fs.writeFileSync( + path.join(projectRoot, `.${agent}/skills/my-skill`, 'SKILL.md'), + '---\nname: my-skill\ndescription: a skill\n---\n\n# Skill\n', + ); + const agentFile = localAgentFile(agent); + fs.writeFileSync(path.join(projectRoot, `.${agent}/agents`, agentFile.name), agentFile.content); +} + +/** Resolve once the wall clock has moved to the next second. */ +function nextSecond(): Promise { + const started = Math.floor(Date.now() / 1000); + return new Promise((resolve) => { + const tick = () => (Math.floor(Date.now() / 1000) > started ? resolve() : setTimeout(tick, 50)); + tick(); + }); +} + +/** Files on the single push branch this fixture's remote received. */ +function branchFiles(fixture: Fixture): { branch: string; files: string[] } { + const branch = git( + ['for-each-ref', '--format=%(refname:short)', `refs/heads/teamai/push/${fixture.username}/`], + fixture.remote, + ).split('\n').filter(Boolean).at(-1) ?? ''; + const files = branch + ? git(['ls-tree', '-r', '--name-only', branch], fixture.remote).split('\n').filter(Boolean) + : []; + return { branch, files }; +} + +function readState(fixture: Fixture): Record { + return JSON.parse(fs.readFileSync(path.join(fixture.projectRoot, '.teamai', 'state.json'), 'utf8')); +} + +/** Land a branch on the remote's default branch and drop it, as a merged PR does. */ +function mergeBranch(fixture: Fixture, branch: string): void { + const clone = path.join(fixture.sandbox, `merge-${Date.now()}`); + git(['clone', '-q', fixture.remote, clone], fixture.sandbox); + git(['merge', '--no-edit', '-q', `origin/${branch}`], clone); + git(['push', '-q', 'origin', 'main'], clone); + git(['push', '-q', 'origin', '--delete', branch], clone); + fs.rmSync(clone, { recursive: true, force: true }); + // The member's own clone keeps the branch it pushed. Once the PR is merged + // and the remote branch is gone, that local ref is stale — and `push`/`remove` + // generate branch names at one-second resolution, so a run in the same second + // would collide with it. + git(['checkout', '-q', 'main'], fixture.teamRepo); + git(['branch', '-q', '-D', branch], fixture.teamRepo); +} + +/** Commit a file straight onto the remote's default branch, as a teammate would. */ +function commitOnMain(fixture: Fixture, relPath: string, content: string): void { + const clone = path.join(fixture.sandbox, `mate-${Date.now()}`); + git(['clone', '-q', fixture.remote, clone], fixture.sandbox); + fs.mkdirSync(path.dirname(path.join(clone, relPath)), { recursive: true }); + fs.writeFileSync(path.join(clone, relPath), content); + git(['add', '-A'], clone); + git(['commit', '-q', '-m', `teammate: ${relPath}`], clone); + git(['push', '-q', 'origin', 'main'], clone); + fs.rmSync(clone, { recursive: true, force: true }); +} + +/** Delete a file straight off the remote's default branch, as a teammate would. */ +function deleteOnMain(fixture: Fixture, relPath: string): void { + const clone = path.join(fixture.sandbox, `mate-rm-${Date.now()}`); + git(['clone', '-q', fixture.remote, clone], fixture.sandbox); + git(['rm', '-q', relPath], clone); + git(['commit', '-q', '-m', `teammate: delete ${relPath}`], clone); + git(['push', '-q', 'origin', 'main'], clone); + fs.rmSync(clone, { recursive: true, force: true }); +} + +const cleanups: string[] = []; +afterEach(() => { + while (cleanups.length) { + fs.rmSync(cleanups.pop()!, { recursive: true, force: true }); + } +}); + +function track(fixture: Fixture): Fixture { + if (!fs.existsSync(CLI)) throw new Error(`CLI binary not found at ${CLI}. Run "npm run build" first.`); + cleanups.push(fixture.sandbox); + return fixture; +} + +describe('push places new rules and agents in a namespace (issue #649)', () => { + it.each(PUSH_AGENTS)('--project resolves each type from its own axis for %s', async (agent) => { + const fixture = track(makeFixture({ agent, provider: 'git' })); + writeLocalResources(fixture); + + const result = await runCLI( + ['push', '--project', 'front-app', '--all'], + fixture.projectRoot, + fixture.home, + ); + + // The generic git provider cannot open a PR; the branch is still pushed. + expect(result.output).toContain('[rules] my-rule → rules/fe-know/my-rule.md'); + expect(result.output).toContain('[agents] vr → agents/fe-agents/vr.yaml'); + + const { branch, files } = branchFiles(fixture); + expect(branch, result.output).not.toBe(''); + expect(files).toContain('rules/fe-know/my-rule.md'); + expect(files).toContain('skills/fe-skills/my-skill/SKILL.md'); + expect(files).toContain('agents/fe-agents/vr.yaml'); + // The shared root is what shipped the rule to the whole team before #649. + expect(files).not.toContain('rules/my-rule.md'); + expect(files).not.toContain('agents/vr.yaml'); + + // The author's copy stays at the tool's rules root, so push marks where it + // put it on the pending PR entry; the record itself is written once the + // PR merges (see the next case), so a PR closed unmerged leaves none. + const state = readState(fixture) as { placedRules?: unknown; pendingPushes: Array<{ items: Array> }> }; + expect(state.placedRules ?? {}).toEqual({}); + expect(state.pendingPushes.at(-1)?.items).toEqual(expect.arrayContaining([ + expect.objectContaining({ type: 'rules', name: 'my-rule', relativePath: 'rules/fe-know/my-rule.md', placed: true }), + expect.objectContaining({ type: 'agents', name: 'vr', relativePath: 'agents/fe-agents/vr.yaml', placed: true }), + ])); + }, 60_000); + + it('sends an edit of the root copy back to the same namespace after the PR merges', async () => { + const fixture = track(makeFixture({ agent: 'claude', provider: 'git' })); + writeLocalResources(fixture); + await runCLI(['push', '--project', 'front-app', '--all'], fixture.projectRoot, fixture.home); + mergeBranch(fixture, branchFiles(fixture).branch); + + const unchanged = await runCLI( + ['push', '--project', 'front-app', '--all'], + fixture.projectRoot, + fixture.home, + ); + expect(unchanged.output).toContain('No new or modified resources to push'); + // That run found the file on the default branch, so the placement is a record now. + expect(readState(fixture).placedRules).toEqual({ 'my-rule': 'rules/fe-know/my-rule.md' }); + + fs.writeFileSync(path.join(fixture.projectRoot, '.claude/rules', 'my-rule.md'), '# Rule v2\n'); + const edited = await runCLI( + ['push', '--project', 'front-app', '--all'], + fixture.projectRoot, + fixture.home, + ); + + expect(edited.output).toContain('[rules] my-rule (modified)'); + const { branch, files } = branchFiles(fixture); + expect(files).toContain('rules/fe-know/my-rule.md'); + expect(files).not.toContain('rules/my-rule.md'); + expect(git(['show', `${branch}:rules/fe-know/my-rule.md`], fixture.remote)).toContain('Rule v2'); + }, 60_000); + + it('never matches another member\'s root rule to a namespaced team rule by basename', async () => { + const fixture = track(makeFixture({ agent: 'claude', provider: 'git', username: 'member-b' })); + // The team already has the namespaced rule, but THIS machine never pushed it. + commitOnMain(fixture, 'rules/fe-know/my-rule.md', '# Author version\n'); + fs.writeFileSync(path.join(fixture.projectRoot, '.claude/rules', 'my-rule.md'), '# Unrelated\n'); + + const result = await runCLI(['push', '--all'], fixture.projectRoot, fixture.home); + + expect(result.output).toContain('[rules] my-rule (new)'); + const { branch, files } = branchFiles(fixture); + expect(files).toContain('rules/be-know/my-rule.md'); + // The author's rule is untouched: a shared basename is not evidence. + expect(git(['show', `${branch}:rules/fe-know/my-rule.md`], fixture.remote)) + .toContain('# Author version'); + }, 60_000); + + it('syncs a teammate\'s newer namespaced rule instead of pushing the stale root copy over it', async () => { + const fixture = track(makeFixture({ agent: 'claude', provider: 'git' })); + writeLocalResources(fixture); + await runCLI(['push', '--project', 'front-app', '--all'], fixture.projectRoot, fixture.home); + mergeBranch(fixture, branchFiles(fixture).branch); + // A pull is what records lastPullRev, which the three-way check needs. + const pulled = await runCLI(['pull'], fixture.projectRoot, fixture.home); + expect(pulled.code, pulled.output).toBe(0); + + commitOnMain(fixture, 'rules/fe-know/my-rule.md', '# Teammate v2\n'); + + const result = await runCLI( + ['push', '--project', 'front-app', '--all'], + fixture.projectRoot, + fixture.home, + ); + + // The root copy is the author's own, placed under rules/fe-know/. Without + // the placedRules redirect in the pre-push sync it stayed at v1, read as a + // local modification, and reverted the teammate's update. (The pull above + // also installs the CLI's built-in `teamai` skill, which this run does + // push — the assertion is about the rule.) + expect(result.output).not.toContain('[rules] my-rule'); + expect(fs.readFileSync(path.join(fixture.projectRoot, '.claude/rules', 'my-rule.md'), 'utf8')) + .toContain('Teammate v2'); + const { branch } = branchFiles(fixture); + expect(git(['show', `${branch}:rules/fe-know/my-rule.md`], fixture.remote)).toContain('Teammate v2'); + expect(git(['show', 'main:rules/fe-know/my-rule.md'], fixture.remote)).toContain('Teammate v2'); + }, 60_000); + + it('lets the author keep editing an agent they published into an inactive namespace', async () => { + const fixture = track(makeFixture({ agent: 'claude', provider: 'git' })); + writeLocalResources(fixture); + await runCLI(['push', '--project', 'front-app', '--all'], fixture.projectRoot, fixture.home); + mergeBranch(fixture, branchFiles(fixture).branch); + + // `front-app` is never activated in this directory, so `fe-agents` is not + // an active namespace: without the placedAgents record the edit below is + // skipped as "no active source" and the agent cannot be maintained. + fs.writeFileSync( + path.join(fixture.projectRoot, '.claude/agents', 'vr.md'), + '---\nname: vr\ndescription: reviews code\n---\n\nYou review twice.\n', + ); + const result = await runCLI( + ['push', '--project', 'front-app', '--all'], + fixture.projectRoot, + fixture.home, + ); + + expect(result.output).not.toContain('no active source'); + expect(result.output).toContain('[agents] vr (modified)'); + const { branch } = branchFiles(fixture); + expect(git(['show', `${branch}:agents/fe-agents/vr.yaml`], fixture.remote)) + .toContain('You review twice.'); + }, 60_000); + + it('removes the published rule even when the author\'s root copy has local edits', async () => { + const fixture = track(makeFixture({ agent: 'claude', provider: 'git' })); + writeLocalResources(fixture); + await runCLI(['push', '--project', 'front-app', '--all'], fixture.projectRoot, fixture.home); + mergeBranch(fixture, branchFiles(fixture).branch); + + // With edits, the LOCAL scan contributes the bare name too. Taking that + // match deletes the local copy, reports success, and leaves the namespaced + // team file published — the author believes the rule is gone (#649 review). + fs.writeFileSync(path.join(fixture.projectRoot, '.claude/rules', 'my-rule.md'), '# Edited\n'); + const result = await runCLI( + ['remove', 'rules', 'my-rule', '--force'], + fixture.projectRoot, + fixture.home, + ); + + expect(result.output).toContain('my-rule was published as fe-know/my-rule'); + const { branch, files } = branchFiles(fixture); + expect(branch, result.output).not.toBe(''); + expect(files).not.toContain('rules/fe-know/my-rule.md'); + expect(fs.existsSync(path.join(fixture.projectRoot, '.claude/rules', 'my-rule.md'))).toBe(false); + }, 60_000); + + it('publishes an agent into the requested namespace despite the same stem elsewhere', async () => { + const fixture = track(makeFixture({ agent: 'claude', provider: 'git' })); + // A team agent of the same name in a namespace this directory never + // activates. Without the requested destination taking part in candidate + // selection, it blocks the push entirely with "no active source". + commitOnMain(fixture, 'agents/other-ns/vr.yaml', + 'name: vr\ndescription: somebody else\'s reviewer\ninstructions: Read other-ns.\n'); + fs.writeFileSync( + path.join(fixture.projectRoot, '.claude/agents', 'vr.md'), + '---\nname: vr\ndescription: reviews code\n---\n\nYou review the front end.\n', + ); + + const result = await runCLI( + ['push', '--project', 'front-app', '--all'], + fixture.projectRoot, + fixture.home, + ); + + expect(result.output).not.toContain('no active source'); + expect(result.output).toContain('[agents] vr → agents/fe-agents/vr.yaml'); + const { branch, files } = branchFiles(fixture); + expect(files).toContain('agents/fe-agents/vr.yaml'); + // The other namespace's agent is a different agent, and stays untouched. + expect(git(['show', `${branch}:agents/other-ns/vr.yaml`], fixture.remote)) + .toContain('Read other-ns.'); + }, 60_000); + + it('refuses to place a new rule onto a team rule that already holds the name', async () => { + const fixture = track(makeFixture({ agent: 'claude', provider: 'git' })); + // The destination the author's role resolves to is already taken by + // somebody else's rule. Theirs is NEW here — no record maps it to anything + // — so placing it there would replace that file in a run nobody reviewed. + commitOnMain(fixture, 'rules/be-know/foo.md', '# The team rule\n'); + fs.writeFileSync(path.join(fixture.projectRoot, '.claude/rules', 'foo.md'), '# My own foo\n'); + + const result = await runCLI(['push', '--all'], fixture.projectRoot, fixture.home); + + expect(result.code, result.output).toBe(2); + expect(result.output).toContain('rules/be-know/foo.md already exists'); + expect(branchFiles(fixture).branch).toBe(''); + expect(git(['show', 'main:rules/be-know/foo.md'], fixture.remote)).toContain('The team rule'); + }, 60_000); + + it('pull delivers a published agent from an inactive namespace and keeps it', async () => { + const fixture = track(makeFixture({ agent: 'claude', provider: 'git' })); + writeLocalResources(fixture); + await runCLI(['push', '--project', 'front-app', '--all'], fixture.projectRoot, fixture.home); + mergeBranch(fixture, branchFiles(fixture).branch); + + // `front-app` is never activated here: only the placement record keeps this + // agent. Delivery and revocation run in the SAME pull, so if only one of + // them knows about the record, the file is written and deleted again. + const pulled = await runCLI(['pull', '--force'], fixture.projectRoot, fixture.home); + expect(pulled.code, pulled.output).toBe(0); + + const deployed = path.join(fixture.projectRoot, '.claude/agents', 'vr.md'); + expect(fs.existsSync(deployed), pulled.output).toBe(true); + + // And it survives a second pull, which is when a revoke would show up. + const again = await runCLI(['pull', '--force'], fixture.projectRoot, fixture.home); + expect(again.code, again.output).toBe(0); + expect(fs.existsSync(deployed), again.output).toBe(true); + }, 60_000); + + it('pull updates the author\'s root copy of a placed rule instead of writing a second one', async () => { + const fixture = track(makeFixture({ agent: 'claude', provider: 'git' })); + writeLocalResources(fixture); + // be-know is the backend role's knowledge namespace, so it IS active here + // and pull delivers the rule — which used to land at rules/be-know/ beside + // the author's own copy at the root, the same rule twice for a tool that + // loads rules recursively. + await runCLI(['push', '--role', 'be-know', '--all'], fixture.projectRoot, fixture.home); + mergeBranch(fixture, branchFiles(fixture).branch); + commitOnMain(fixture, 'rules/be-know/my-rule.md', '# Rule v2, edited by a teammate\n'); + + const pulled = await runCLI(['pull', '--force'], fixture.projectRoot, fixture.home); + expect(pulled.code, pulled.output).toBe(0); + + const rulesDir = path.join(fixture.projectRoot, '.claude/rules'); + expect(fs.readFileSync(path.join(rulesDir, 'my-rule.md'), 'utf8')).toContain('Rule v2'); + expect(fs.existsSync(path.join(rulesDir, 'be-know', 'my-rule.md')), pulled.output).toBe(false); + }, 60_000); + + it('leaves a shared-root PR untouched when the next push names a namespace', async () => { + // No knowledge namespace on the role, so the first push goes to the shared root. + const fixture = track(makeFixture({ + agent: 'claude', + provider: 'git', + rolesManifest: ROLES_MANIFEST.replace('knowledge: [be-know]', 'knowledge: []'), + })); + fs.writeFileSync(path.join(fixture.projectRoot, '.claude/rules', 'my-rule.md'), '# Rule v1\n'); + const first = await runCLI(['push', '--all'], fixture.projectRoot, fixture.home); + const { branch: sharedBranch, files: sharedFiles } = branchFiles(fixture); + expect(sharedFiles, first.output).toContain('rules/my-rule.md'); + + // The PR is still open. Naming a namespace now must not rebuild it. Branch + // names carry a one-second timestamp, so a second push inside the same + // second would be given the pending branch's name and land on it for that + // reason alone; wait the second out so the test sees the conflict check. + await nextSecond(); + fs.writeFileSync(path.join(fixture.projectRoot, '.claude/rules', 'my-rule.md'), '# Rule v2\n'); + const second = await runCLI(['push', '--role', 'fe-know', '--all'], fixture.projectRoot, fixture.home); + + expect(second.output).toContain('awaiting review at rules/my-rule.md'); + expect(second.output).toContain('separate PR'); + const branches = git( + ['for-each-ref', '--format=%(refname:short)', `refs/heads/teamai/push/${fixture.username}/`], + fixture.remote, + ).split('\n').filter(Boolean); + expect(branches, second.output).toHaveLength(2); + const other = branches.find((name) => name !== sharedBranch) ?? ''; + expect(git(['ls-tree', '-r', '--name-only', other], fixture.remote)).toContain('rules/fe-know/my-rule.md'); + // The review at the shared root still holds exactly what it did. + expect(git(['ls-tree', '-r', '--name-only', sharedBranch], fixture.remote)).toContain('rules/my-rule.md'); + expect(git(['show', `${sharedBranch}:rules/my-rule.md`], fixture.remote)).toContain('Rule v1'); + }, 60_000); + + it('places by the projects manifest the pull just fetched, not the one from the last pull', async () => { + const fixture = track(makeFixture({ agent: 'claude', provider: 'git' })); + writeLocalResources(fixture); + // The remote renames the project's knowledge namespace after this clone + // was made. Read before the pull, the manifest still says fe-know. + commitOnMain(fixture, 'manifest/projects.yaml', PROJECTS_MANIFEST.replace('knowledge: [fe-know]', 'knowledge: [fe-know-v2]')); + + const result = await runCLI(['push', '--project', 'front-app', '--all'], fixture.projectRoot, fixture.home); + + expect(result.output).toContain('[rules] my-rule → rules/fe-know-v2/my-rule.md'); + const { files } = branchFiles(fixture); + expect(files, result.output).toContain('rules/fe-know-v2/my-rule.md'); + expect(files).not.toContain('rules/fe-know/my-rule.md'); + }, 60_000); + + it('refuses a --project push when the team clone cannot be refreshed', async () => { + const fixture = track(makeFixture({ agent: 'claude', provider: 'git' })); + writeLocalResources(fixture); + // The clone's manifest is whatever the last pull left. Point origin + // somewhere unreachable so this run cannot refresh it. + git(['remote', 'set-url', 'origin', path.join(fixture.sandbox, 'nowhere.git')], fixture.teamRepo); + + const result = await runCLI(['push', '--project', 'front-app', '--all'], fixture.projectRoot, fixture.home); + + expect(result.code, result.output).toBe(1); + expect(result.output).toContain('could not be refreshed'); + expect(branchFiles(fixture).branch).toBe(''); + }, 60_000); + + it('refuses to remove when the team clone cannot be refreshed', async () => { + const fixture = track(makeFixture({ agent: 'claude', provider: 'git' })); + commitOnMain(fixture, 'agents/other-ns/vr.yaml', + 'name: vr\ndescription: somebody else\'s\ninstructions: Read other-ns.\n'); + writeLocalResources(fixture); + await runCLI(['push', '--project', 'front-app', '--all'], fixture.projectRoot, fixture.home); + // The placement merges, but this clone never sees it: fetching fails while + // pushing still works, so a removal resolved from the stale tree would land. + mergeBranch(fixture, branchFiles(fixture).branch); + git(['remote', 'set-url', 'origin', path.join(fixture.sandbox, 'nowhere.git')], fixture.teamRepo); + git(['remote', 'set-url', '--push', 'origin', fixture.remote], fixture.teamRepo); + + const result = await runCLI(['remove', 'agents', 'vr', '--force'], fixture.projectRoot, fixture.home); + + // Stale, `vr` resolves to no record and falls back to the bare stem, which + // would remove other-ns/vr as well. + expect(result.code, result.output).toBe(1); + expect(result.output).toContain('could not be refreshed'); + expect(result.output).toContain('Nothing was removed'); + expect(branchFiles(fixture).branch).toBe(''); + }, 60_000); + + it('pull drops the placement record of a rule the team has since deleted', async () => { + const fixture = track(makeFixture({ agent: 'claude', provider: 'git' })); + writeLocalResources(fixture); + await runCLI(['push', '--role', 'be-know', '--all'], fixture.projectRoot, fixture.home); + mergeBranch(fixture, branchFiles(fixture).branch); + const landed = await runCLI(['pull', '--force'], fixture.projectRoot, fixture.home); + expect(readState(fixture).placedRules, landed.output).toEqual({ 'my-rule': 'rules/be-know/my-rule.md' }); + deleteOnMain(fixture, 'rules/be-know/my-rule.md'); + + const pulled = await runCLI(['pull', '--force'], fixture.projectRoot, fixture.home); + expect(pulled.code, pulled.output).toBe(0); + + // Left in place, the record would claim the next `rules/be-know/my-rule.md` + // anybody creates as this author's, and their root copy would push over it. + expect(readState(fixture).placedRules ?? {}).toEqual({}); + + // And when somebody does create it, the placement — already recorded once, + // and still on the pending entry — must not come back as a record. + commitOnMain(fixture, 'rules/be-know/my-rule.md', '# Somebody else\'s rule\n'); + const again = await runCLI(['pull', '--force'], fixture.projectRoot, fixture.home); + expect(again.code, again.output).toBe(0); + expect(readState(fixture).placedRules ?? {}).toEqual({}); + expect(fs.readFileSync(path.join(fixture.projectRoot, '.claude/rules/be-know', 'my-rule.md'), 'utf8')) + .toContain("Somebody else's rule"); + }, 60_000); + + it('never records a placement whose PR was closed without merging, even with the branch kept', async () => { + const fixture = track(makeFixture({ agent: 'claude', provider: 'git' })); + writeLocalResources(fixture); + await runCLI(['push', '--role', 'be-know', '--all'], fixture.projectRoot, fixture.home); + expect(branchFiles(fixture).files).toContain('rules/be-know/my-rule.md'); + + // Nothing merges. The branch stays on the remote, as a closed PR's often does. + const pulled = await runCLI(['pull', '--force'], fixture.projectRoot, fixture.home); + expect(pulled.code, pulled.output).toBe(0); + expect(readState(fixture).placedRules ?? {}).toEqual({}); + + // A teammate later publishes an unrelated rule at that very path. + commitOnMain(fixture, 'rules/be-know/my-rule.md', '# Somebody else\'s rule\n'); + const again = await runCLI(['pull', '--force'], fixture.projectRoot, fixture.home); + expect(again.code, again.output).toBe(0); + // Delivered to its namespace directory; the author's root copy is untouched. + expect(fs.readFileSync(path.join(fixture.projectRoot, '.claude/rules', 'my-rule.md'), 'utf8')).toBe('# Rule v1\n'); + expect(fs.readFileSync(path.join(fixture.projectRoot, '.claude/rules/be-know', 'my-rule.md'), 'utf8')) + .toContain("Somebody else's rule"); + }, 60_000); + + it('withdraws a placement record when a shared-root rule takes the name', async () => { + const fixture = track(makeFixture({ agent: 'claude', provider: 'git' })); + writeLocalResources(fixture); + await runCLI(['push', '--role', 'be-know', '--all'], fixture.projectRoot, fixture.home); + mergeBranch(fixture, branchFiles(fixture).branch); + await runCLI(['pull', '--force'], fixture.projectRoot, fixture.home); + expect(readState(fixture).placedRules).toEqual({ 'my-rule': 'rules/be-know/my-rule.md' }); + + commitOnMain(fixture, 'rules/my-rule.md', '# Shared rule for everyone\n'); + const pulled = await runCLI(['pull', '--force'], fixture.projectRoot, fixture.home); + expect(pulled.code, pulled.output).toBe(0); + + expect(pulled.output).toContain('rules/my-rule.md now exists at the shared root'); + expect(readState(fixture).placedRules ?? {}).toEqual({}); + const rulesDir = path.join(fixture.projectRoot, '.claude/rules'); + expect(fs.readFileSync(path.join(rulesDir, 'my-rule.md'), 'utf8')).toContain('Shared rule'); + expect(fs.readFileSync(path.join(rulesDir, 'be-know', 'my-rule.md'), 'utf8')).toContain('Rule v1'); + // And the next push does not follow the withdrawn record onto the namespaced file. + fs.writeFileSync(path.join(rulesDir, 'my-rule.md'), '# Shared rule, edited here\n'); + await runCLI(['push', '--all'], fixture.projectRoot, fixture.home); + const { branch, files } = branchFiles(fixture); + expect(files, branch).toContain('rules/my-rule.md'); + expect(git(['show', `${branch}:rules/be-know/my-rule.md`], fixture.remote)).toContain('Rule v1'); + }, 60_000); + + it('removes only the published agent, through the real remove command', async () => { + const fixture = track(makeFixture({ agent: 'claude', provider: 'git' })); + // A second agent of the same name in another namespace, and the author's + // own, published through --project. + commitOnMain(fixture, 'agents/other-ns/vr.yaml', + 'name: vr\ndescription: somebody else\'s\ninstructions: Read other-ns.\n'); + writeLocalResources(fixture); + await runCLI(['push', '--project', 'front-app', '--all'], fixture.projectRoot, fixture.home); + mergeBranch(fixture, branchFiles(fixture).branch); + + const result = await runCLI( + ['remove', 'agents', 'vr', '--force'], + fixture.projectRoot, + fixture.home, + ); + + // The bare stem is what the author knows; it has to resolve to what push + // published, or removal falls back to the stem and takes every namespace. + expect(result.output).toContain('vr was published as fe-agents/vr'); + const { branch, files } = branchFiles(fixture); + expect(branch, result.output).not.toBe(''); + expect(files).not.toContain('agents/fe-agents/vr.yaml'); + // Somebody else's agent of the same name survives. + expect(git(['show', `${branch}:agents/other-ns/vr.yaml`], fixture.remote)) + .toContain('Read other-ns.'); + // And the tombstone names only the published agent: a bare `vr` would + // suppress other-ns/vr for anyone who activates that namespace. + const tombstones = git(['show', `${branch}:agents/.removed`], fixture.remote).split('\n'); + expect(tombstones).toContain('fe-agents/vr'); + expect(tombstones).not.toContain('vr'); + }, 60_000); + + it('refuses a roles manifest namespace that is not a single path segment', async () => { + const fixture = track(makeFixture({ + agent: 'claude', + provider: 'git', + rolesManifest: ROLES_MANIFEST.replace('knowledge: [be-know]', 'knowledge: [foo/bar]'), + })); + fs.writeFileSync(path.join(fixture.projectRoot, '.claude/rules', 'my-rule.md'), '# Rule\n'); + + const result = await runCLI(['push', '--all'], fixture.projectRoot, fixture.home); + + // Two levels is a depth pull never looks at for agents, and reads back as + // the wrong namespace for a rule. + expect(result.code, result.output).toBe(2); + expect(result.output).toContain('foo/bar'); + expect(branchFiles(fixture).branch).toBe(''); + }, 60_000); + + it('removes a rule by the bare name it was published under a namespace with', async () => { + const fixture = track(makeFixture({ agent: 'claude', provider: 'git' })); + writeLocalResources(fixture); + await runCLI(['push', '--project', 'front-app', '--all'], fixture.projectRoot, fixture.home); + mergeBranch(fixture, branchFiles(fixture).branch); + + // The author's copy is at the rules root, so `my-rule` is the name they know. + const result = await runCLI( + ['remove', 'rules', 'my-rule', '--force'], + fixture.projectRoot, + fixture.home, + ); + + expect(result.output).toContain('my-rule was published as fe-know/my-rule'); + const { branch, files } = branchFiles(fixture); + expect(branch, result.output).not.toBe(''); + // The namespaced team file is gone from the branch, not just the local copy. + expect(files).not.toContain('rules/fe-know/my-rule.md'); + // And the author's own copy went with it, or the next push re-publishes it. + expect(fs.existsSync(path.join(fixture.projectRoot, '.claude/rules', 'my-rule.md'))).toBe(false); + // The removal is only on its branch, so the record still resolves a retry; + // it goes once the default branch no longer has the file. + expect(readState(fixture).placedRules).toEqual({ 'my-rule': 'rules/fe-know/my-rule.md' }); + mergeBranch(fixture, branch); + const pulled = await runCLI(['pull', '--force'], fixture.projectRoot, fixture.home); + expect(pulled.code, pulled.output).toBe(0); + expect(readState(fixture).placedRules ?? {}).toEqual({}); + }, 60_000); + + it('does not republish a removed agent from the copy an excluded tool kept', async () => { + const fixture = track(makeFixture({ agent: 'claude', provider: 'git' })); + // Codex is configured for the team but excluded on this machine, and holds + // its own copy of the agent the author publishes and then removes. + commitOnMain(fixture, 'teamai.yaml', [ + fs.readFileSync(path.join(fixture.teamRepo, 'teamai.yaml'), 'utf8'), + ' codex:', + ' agents: .codex/agents', + ].join('\n')); + fs.appendFileSync(path.join(fixture.projectRoot, '.teamai', 'config.yaml'), '\nenabledAgents:\n - claude\n'); + writeLocalResources(fixture); + fs.mkdirSync(path.join(fixture.projectRoot, '.codex/agents'), { recursive: true }); + fs.writeFileSync(path.join(fixture.projectRoot, '.codex/agents', 'vr.toml'), localAgentFile('codex').content); + + await runCLI(['push', '--project', 'front-app', '--all'], fixture.projectRoot, fixture.home); + mergeBranch(fixture, branchFiles(fixture).branch); + await nextSecond(); + await runCLI(['remove', 'agents', 'vr', '--force'], fixture.projectRoot, fixture.home); + mergeBranch(fixture, branchFiles(fixture).branch); + await runCLI(['pull', '--force'], fixture.projectRoot, fixture.home); + + // `remove` leaves an excluded tool's copy alone, by design. + expect(fs.existsSync(path.join(fixture.projectRoot, '.codex/agents', 'vr.toml'))).toBe(true); + await nextSecond(); + const again = await runCLI(['push', '--all'], fixture.projectRoot, fixture.home); + + expect(again.output).toContain('Scanning local resources'); + expect(again.output).not.toContain('[agents] vr'); + // Both merged branches are gone from the remote, so any branch is this push's. + expect(branchFiles(fixture).files.filter((f) => /^agents\/(.+\/)?vr\./.test(f))).toEqual([]); + }, 60_000); + + it('cleans the flattened copy of a removed namespaced agent on pull, and never republishes it', async () => { + const fixture = track(makeFixture({ agent: 'claude', provider: 'git' })); + writeLocalResources(fixture); + await runCLI(['push', '--project', 'front-app', '--all'], fixture.projectRoot, fixture.home); + mergeBranch(fixture, branchFiles(fixture).branch); + await nextSecond(); + await runCLI(['remove', 'agents', 'vr', '--force'], fixture.projectRoot, fixture.home); + const removal = branchFiles(fixture).branch; + const agentCopy = path.join(fixture.projectRoot, '.claude/agents', 'vr.md'); + expect(fs.existsSync(agentCopy)).toBe(false); + + // While the removal is under review the agent is still on main, and the + // record still delivers it — as a pending removal of any agent would. + await runCLI(['pull', '--force'], fixture.projectRoot, fixture.home); + expect(fs.existsSync(agentCopy)).toBe(true); + + // Merged: the only tombstone is `fe-agents/vr`, but the copy is `vr.md`. + mergeBranch(fixture, removal); + const pulled = await runCLI(['pull', '--force'], fixture.projectRoot, fixture.home); + expect(pulled.code, pulled.output).toBe(0); + expect(fs.existsSync(agentCopy)).toBe(false); + + // And a copy that comes back anyway is not a new agent to publish. + fs.writeFileSync(agentCopy, localAgentFile('claude').content); + await nextSecond(); + const again = await runCLI(['push', '--all'], fixture.projectRoot, fixture.home); + expect(again.output).toContain('Scanning local resources'); + expect(again.output).not.toContain('[agents] vr'); + expect(branchFiles(fixture).files.filter((f) => /^agents\/(.+\/)?vr\./.test(f))).toEqual([]); + }, 60_000); + + it('resolves a retried agent removal through the record while the first removal is unmerged', async () => { + const fixture = track(makeFixture({ agent: 'claude', provider: 'git' })); + commitOnMain(fixture, 'agents/other-ns/vr.yaml', + 'name: vr\ndescription: somebody else\'s\ninstructions: Read other-ns.\n'); + writeLocalResources(fixture); + await runCLI(['push', '--project', 'front-app', '--all'], fixture.projectRoot, fixture.home); + mergeBranch(fixture, branchFiles(fixture).branch); + await runCLI(['remove', 'agents', 'vr', '--force'], fixture.projectRoot, fixture.home); + const first = branchFiles(fixture).branch; + + // The first removal PR is still open. Dropping the record there sent the + // retry to the bare stem, which removes `vr` from every namespace. + await nextSecond(); + const retry = await runCLI(['remove', 'agents', 'vr', '--force'], fixture.projectRoot, fixture.home); + + expect(retry.output).toContain('vr was published as fe-agents/vr'); + const { branch, files } = branchFiles(fixture); + expect(branch, retry.output).not.toBe(first); + expect(files).not.toContain('agents/fe-agents/vr.yaml'); + expect(files).toContain('agents/other-ns/vr.yaml'); + + mergeBranch(fixture, branch); + const pulled = await runCLI(['pull', '--force'], fixture.projectRoot, fixture.home); + expect(pulled.code, pulled.output).toBe(0); + expect(readState(fixture).placedAgents ?? {}).toEqual({}); + }, 60_000); + + it('stops the push when the roles manifest exists but cannot be parsed', async () => { + const fixture = track(makeFixture({ + agent: 'claude', + provider: 'git', + rolesManifest: 'version: 1\nroles:\n - id: backend\n bad indentation: [\n', + })); + fs.writeFileSync(path.join(fixture.projectRoot, '.claude/rules', 'my-rule.md'), '# Rule\n'); + + const result = await runCLI(['push', '--all'], fixture.projectRoot, fixture.home); + + // Falling back here would publish the rule to the whole team. + expect(result.code, result.output).toBe(2); + expect(result.output).toContain('Cannot resolve where new rules should go'); + expect(branchFiles(fixture).branch).toBe(''); + }, 60_000); + + it('--dry-run reports the destinations and pushes nothing', async () => { + const fixture = track(makeFixture({ agent: 'claude', provider: 'git' })); + writeLocalResources(fixture); + + const result = await runCLI( + ['push', '--project', 'front-app', '--dry-run'], + fixture.projectRoot, + fixture.home, + ); + + expect(result.output).toContain('[rules] my-rule → rules/fe-know/my-rule.md'); + expect(result.output).toContain('[agents] vr → agents/fe-agents/vr.yaml'); + expect(result.output).toContain('Dry run'); + expect(branchFiles(fixture).branch).toBe(''); + }, 60_000); + + it('--dry-run fails on a project axis the real push would refuse', async () => { + const fixture = track(makeFixture({ agent: 'claude', provider: 'git' })); + fs.writeFileSync( + path.join(fixture.teamRepo, 'manifest', 'projects.yaml'), + PROJECTS_MANIFEST.replace('knowledge: [fe-know]', 'knowledge: []'), + ); + git(['commit', '-q', '-am', 'drop the knowledge axis'], fixture.teamRepo); + git(['push', '-q', 'origin', 'main'], fixture.teamRepo); + fs.writeFileSync(path.join(fixture.projectRoot, '.claude/rules', 'my-rule.md'), '# Rule\n'); + + const result = await runCLI( + ['push', '--project', 'front-app', '--dry-run'], + fixture.projectRoot, + fixture.home, + ); + + expect(result.code, result.output).toBe(2); + expect(result.output).toContain('declares no knowledge namespace'); + }, 60_000); +}); + +describe('push namespace placement reaches the PR providers (issue #649)', () => { + it.each(PUSH_AGENTS)('creates a GitHub PR for the namespaced branch for %s', async (agent) => { + const fixture = track(makeFixture({ + agent, + provider: 'github', + repoUrl: 'https://github.com/team/issue-649.git', + })); + const binDir = path.join(fixture.sandbox, 'bin'); + const ghLog = path.join(fixture.sandbox, 'gh.log'); + fs.mkdirSync(binDir); + fs.writeFileSync( + path.join(binDir, 'gh'), + '#!/bin/sh\nprintf "%s\\n" "$*" > "$TEAMAI_FAKE_GH_LOG"\n' + + 'printf "%s\\n" "https://github.com/team/issue-649/pull/649"\n', + { mode: 0o755 }, + ); + writeLocalResources(fixture); + + const result = await runCLI( + ['push', '--project', 'front-app', '--all'], + fixture.projectRoot, + fixture.home, + { PATH: `${binDir}:${process.env.PATH ?? ''}`, TEAMAI_FAKE_GH_LOG: ghLog }, + ); + + expect(result.code, result.output).toBe(0); + expect(result.output).toContain('Pull Request created: https://github.com/team/issue-649/pull/649'); + expect(fs.readFileSync(ghLog, 'utf8')).toContain('pr create -R team/issue-649'); + const { files } = branchFiles(fixture); + expect(files).toContain('rules/fe-know/my-rule.md'); + expect(files).toContain('agents/fe-agents/vr.yaml'); + expect(files).not.toContain('rules/my-rule.md'); + }, 60_000); + + it.each(PUSH_AGENTS)('creates a GitLab MR for the namespaced branch for %s', async (agent) => { + const requestPaths: string[] = []; + const server = http.createServer((request, response) => { + requestPaths.push(request.url ?? ''); + request.on('data', () => {}); + request.on('end', () => { + response.writeHead(201, { 'content-type': 'application/json' }); + response.end(JSON.stringify({ + iid: 649, + web_url: 'https://gitlab.example.test/team/issue-649/-/merge_requests/649', + })); + }); + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + const address = server.address(); + if (!address || typeof address === 'string') { + server.close(); + throw new Error('Failed to start the fake GitLab API server.'); + } + const gitlabUrl = `http://127.0.0.1:${address.port}`; + const fixture = track(makeFixture({ + agent, + provider: 'gitlab', + repoUrl: `${gitlabUrl}/team/issue-649.git`, + })); + writeLocalResources(fixture); + + try { + const result = await runCLI( + ['push', '--project', 'front-app', '--all'], + fixture.projectRoot, + fixture.home, + { GITLAB_URL: gitlabUrl, GITLAB_TOKEN: 'test-token' }, + ); + + expect(result.code, result.output).toBe(0); + expect(result.output).toContain( + 'Pull Request created: https://gitlab.example.test/team/issue-649/-/merge_requests/649', + ); + expect(requestPaths).toEqual(['/api/v4/projects/team%2Fissue-649/merge_requests']); + const { files } = branchFiles(fixture); + expect(files).toContain('rules/fe-know/my-rule.md'); + expect(files).toContain('agents/fe-agents/vr.yaml'); + expect(files).not.toContain('rules/my-rule.md'); + } finally { + await new Promise((resolve, reject) => { + server.close((error) => error ? reject(error) : resolve()); + }); + } + }, 60_000); +}); diff --git a/src/__tests__/push-namespaces.test.ts b/src/__tests__/push-namespaces.test.ts new file mode 100644 index 00000000..5d887875 --- /dev/null +++ b/src/__tests__/push-namespaces.test.ts @@ -0,0 +1,183 @@ +import { describe, expect, it } from 'vitest'; +import { + isAtSharedRoot, isPlaceableType, placedResourcePath, resolveProjectNamespace, withNamespace, +} from '../push-namespaces.js'; +import type { ProjectsManifest } from '../projects.js'; +import type { ResourceItem } from '../types.js'; + +function item(type: ResourceItem['type'], name: string, relativePath: string): ResourceItem { + return { name, type, sourcePath: `/local/${name}`, relativePath }; +} + +describe('isAtSharedRoot', () => { + it('is true for a resource that carries no namespace directory', () => { + expect(isAtSharedRoot(item('rules', 'my-rule', 'rules/my-rule.md'))).toBe(true); + expect(isAtSharedRoot(item('agents', 'vr', 'agents/vr.yaml'))).toBe(true); + expect(isAtSharedRoot(item('skills', 'my-skill', 'skills/my-skill'))).toBe(true); + }); + + it('is false once a namespace directory is present', () => { + expect(isAtSharedRoot(item('rules', 'my-rule', 'rules/frontend/my-rule.md'))).toBe(false); + expect(isAtSharedRoot(item('agents', 'vr', 'agents/frontend/vr.yaml'))).toBe(false); + expect(isAtSharedRoot(item('skills', 'my-skill', 'skills/frontend/my-skill'))).toBe(false); + }); + + it('is false for a rule authored inside a subdirectory of the tool rules dir', () => { + // `rules` names include their subdirectory (src/resources/rules.ts), so a + // deeper path is already namespaced and must not be placed again. + expect(isAtSharedRoot(item('rules', 'a/b/c', 'rules/a/b/c.md'))).toBe(false); + }); +}); + +describe('isPlaceableType', () => { + it('accepts the namespaced types and rejects the rest', () => { + expect(isPlaceableType('skills')).toBe(true); + expect(isPlaceableType('rules')).toBe(true); + expect(isPlaceableType('agents')).toBe(true); + // env is pushable but never namespaced; docs and hooks are not placed either. + expect(isPlaceableType('env')).toBe(false); + expect(isPlaceableType('docs')).toBe(false); + expect(isPlaceableType('hooks')).toBe(false); + }); +}); + +describe('withNamespace', () => { + it('inserts the namespace after the resource root, keeping the basename', () => { + expect(withNamespace('rules/my-rule.md', 'knowledge-ns')).toBe('rules/knowledge-ns/my-rule.md'); + expect(withNamespace('skills/my-skill', 'skills-ns')).toBe('skills/skills-ns/my-skill'); + }); + + it('keeps an agent extension the caller does not know about', () => { + // Agents push as .yaml, or .md on the legacy fallback path. + expect(withNamespace('agents/vr.yaml', 'ns')).toBe('agents/ns/vr.yaml'); + expect(withNamespace('agents/vr.md', 'ns')).toBe('agents/ns/vr.md'); + }); +}); + +const manifest: ProjectsManifest = { + version: 1, + projects: [ + { + id: 'front-app', + name: 'Front App', + description: '', + // Deliberately all different: the axis a type resolves from is the point. + resources: { knowledge: ['fe-know'], skills: ['fe-skills'], learnings: [], agents: ['fe-agents'] }, + }, + { + id: 'bare', + name: 'Bare', + description: '', + resources: { knowledge: [], skills: ['bare-skills'], learnings: [], agents: [] }, + }, + { + id: 'multi', + name: 'Multi', + description: '', + resources: { knowledge: ['k1', 'k2'], skills: [], learnings: [], agents: [] }, + }, + { + id: 'unsafe', + name: 'Unsafe', + description: '', + // The manifest schema only requires a non-empty string. + resources: { knowledge: ['../../evil'], skills: [], learnings: [], agents: [] }, + }, + ], +}; + +describe('resolveProjectNamespace', () => { + it('resolves each type from its own axis', () => { + expect(resolveProjectNamespace(manifest, 'front-app', 'rules')).toEqual({ ok: true, namespace: 'fe-know' }); + expect(resolveProjectNamespace(manifest, 'front-app', 'skills')).toEqual({ ok: true, namespace: 'fe-skills' }); + expect(resolveProjectNamespace(manifest, 'front-app', 'agents')).toEqual({ ok: true, namespace: 'fe-agents' }); + }); + + it('fails naming the type and the axis when the project declares none', () => { + const result = resolveProjectNamespace(manifest, 'bare', 'rules'); + expect(result.ok).toBe(false); + expect(result.ok === false && result.message).toContain('rules'); + expect(result.ok === false && result.message).toContain('knowledge'); + expect(result.ok === false && result.message).toContain('bare'); + }); + + it('fails rather than guessing when the axis is multi-valued', () => { + const result = resolveProjectNamespace(manifest, 'multi', 'rules'); + expect(result.ok).toBe(false); + expect(result.ok === false && result.message).toContain('k1, k2'); + expect(result.ok === false && result.message).toContain('--role'); + }); + + it('rejects a namespace that is not a single safe path segment', () => { + const result = resolveProjectNamespace(manifest, 'unsafe', 'rules'); + expect(result.ok).toBe(false); + expect(result.ok === false && result.message).toContain('../../evil'); + }); + + it('fails on an unknown project instead of throwing', () => { + const result = resolveProjectNamespace(manifest, 'nope', 'rules'); + expect(result.ok).toBe(false); + expect(result.ok === false && result.message).toMatch(/unknown project/i); + }); +}); + +/** + * `state.json` is a file on disk, and both the push scanner and the pre-push + * sync resolve a placement record through this one function. When only one of + * them followed the record, the sync skipped a teammate's newer version and + * the scan pushed the stale local copy over it. + */ +describe('placedResourcePath', () => { + const rules = { 'my-rule': 'rules/fe-know/my-rule.md' }; + + it('returns the recorded destination', () => { + expect(placedResourcePath(rules, 'rules', 'my-rule')).toBe('rules/fe-know/my-rule.md'); + }); + + it('returns null with no record at all', () => { + expect(placedResourcePath(undefined, 'rules', 'my-rule')).toBeNull(); + expect(placedResourcePath({}, 'rules', 'my-rule')).toBeNull(); + }); + + it('ignores a name that already carries its namespace', () => { + // It matches its team file by full path and never needs a record. + expect(placedResourcePath({ 'fe-know/my-rule': 'rules/fe-know/my-rule.md' }, 'rules', 'fe-know/my-rule')) + .toBeNull(); + }); + + it('rejects a record that escapes the resource root', () => { + expect(placedResourcePath({ x: 'rules/../../etc/passwd' }, 'rules', 'x')).toBeNull(); + expect(placedResourcePath({ x: '../rules/ns/x.md' }, 'rules', 'x')).toBeNull(); + expect(placedResourcePath({ x: 'skills/ns/x.md' }, 'rules', 'x')).toBeNull(); + }); + + it('rejects a namespace segment that is not a safe directory name', () => { + // On Windows `path.join` reads the backslashes as separators, so this + // escapes the resource root although it has three `/` segments (#649 review). + expect(placedResourcePath({ foo: 'rules/..\\..\\victim/foo.md' }, 'rules', 'foo')).toBeNull(); + expect(placedResourcePath({ vr: 'agents/fe\\..\\..\\x/vr.yaml' }, 'agents', 'vr')).toBeNull(); + expect(placedResourcePath({ foo: 'rules/fe:ads/foo.md' }, 'rules', 'foo')).toBeNull(); + }); + + it('rejects a record that is not namespaced or not named after the resource', () => { + expect(placedResourcePath({ x: 'rules/x.md' }, 'rules', 'x')).toBeNull(); + expect(placedResourcePath({ x: 'rules/ns/sub/x.md' }, 'rules', 'x')).toBeNull(); + expect(placedResourcePath({ x: 'rules/ns/other.md' }, 'rules', 'x')).toBeNull(); + }); + + it('requires the exact filename, not merely the resource name as a prefix', () => { + // `x.backup.md` is somebody else's file. Trusting it would point scanning, + // the pre-push sync and removal at an unrelated resource (#649 review). + expect(placedResourcePath({ x: 'rules/ns/x.backup.md' }, 'rules', 'x')).toBeNull(); + expect(placedResourcePath({ x: 'rules/ns/x.yaml' }, 'rules', 'x')).toBeNull(); + expect(placedResourcePath({ vr: 'agents/ns/vr.old.yaml' }, 'agents', 'vr')).toBeNull(); + // A legacy `.md` agent is still the agent itself. + expect(placedResourcePath({ vr: 'agents/ns/vr.md' }, 'agents', 'vr')).toBe('agents/ns/vr.md'); + }); + + it('resolves an agent record, whose file keeps the canonical .yaml', () => { + expect(placedResourcePath({ vr: 'agents/fe-agents/vr.yaml' }, 'agents', 'vr')) + .toBe('agents/fe-agents/vr.yaml'); + expect(placedResourcePath({ vr: 'agents/fe-agents/vr.yaml' }, 'rules', 'vr')).toBeNull(); + }); +}); diff --git a/src/__tests__/push-pending-pr.test.ts b/src/__tests__/push-pending-pr.test.ts index d49efc36..94fd9175 100644 --- a/src/__tests__/push-pending-pr.test.ts +++ b/src/__tests__/push-pending-pr.test.ts @@ -30,6 +30,7 @@ const mockCreatePullRequest = vi.fn(); const mockAskSelection = vi.fn(); vi.mock('../utils/prompt.js', () => ({ + isInteractive: vi.fn(() => true), askQuestion: vi.fn(() => Promise.resolve('1')), askConfirmation: vi.fn(() => Promise.resolve(true)), askSelection: (...args: unknown[]) => mockAskSelection(...args), @@ -63,6 +64,7 @@ vi.mock('../utils/git.js', () => ({ isDedicatedRepoRoot: vi.fn().mockResolvedValue(true), getDefaultBranch: vi.fn().mockResolvedValue('main'), getFileContentAtRev: vi.fn().mockResolvedValue(null), + getHeadCommit: vi.fn().mockResolvedValue('base000'), })); vi.mock('../resources/index.js', () => ({ diff --git a/src/__tests__/push-role.test.ts b/src/__tests__/push-role.test.ts index 87fe1562..2b004f88 100644 --- a/src/__tests__/push-role.test.ts +++ b/src/__tests__/push-role.test.ts @@ -1,5 +1,9 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; import { push } from '../push.js'; +import { RolesManifestNotFoundError } from '../roles.js'; const mockAutoDetectInit = vi.fn(); const mockPullRepo = vi.fn(); @@ -13,6 +17,7 @@ const mockGetHandler = vi.fn(); let readlineAnswer = '1'; vi.mock('../utils/prompt.js', () => ({ + isInteractive: vi.fn(() => true), askQuestion: vi.fn((_prompt: string, defaultValue?: string) => { return Promise.resolve(readlineAnswer || defaultValue || ''); }), @@ -62,10 +67,25 @@ vi.mock('../utils/git.js', () => ({ generateBranchName: (...args: unknown[]) => mockGenerateBranchName(...args), resetToCleanMaster: (...args: unknown[]) => mockResetToCleanMaster(...args), isDedicatedRepoRoot: vi.fn().mockResolvedValue(true), + // Without these two the PR step throws inside its own catch, which quietly + // leaves process.exitCode at 1 and makes exit-code assertions meaningless. getDefaultBranch: vi.fn().mockResolvedValue('main'), + remoteBranchExists: vi.fn().mockResolvedValue(true), getFileContentAtRev: vi.fn().mockResolvedValue(null), + hashObject: vi.fn().mockResolvedValue('b10b'), + blobInHistory: vi.fn().mockResolvedValue(true), + getHeadCommit: vi.fn().mockResolvedValue('base000'), })); +const mockLoadProjectsManifest = vi.fn().mockResolvedValue(null); +vi.mock('../projects.js', async () => { + const actual = await vi.importActual('../projects.js'); + return { + ...actual, + loadProjectsManifest: (...args: unknown[]) => mockLoadProjectsManifest(...args), + }; +}); + vi.mock('../roles.js', async () => { const actual = await vi.importActual('../roles.js'); return { @@ -223,6 +243,28 @@ describe('push namespace routing', () => { expect(pushedItems[0].relativePath).toBe('skills/common/skill-a'); }); + it('names the choice and --role instead of prompting when there is no terminal', async () => { + const pushedItems: Array> = []; + mockAutoDetectInit.mockResolvedValue({ + localConfig: makeLocalConfig(), // primaryRole=hai → skills: [common, hai] + teamConfig: makeTeamConfig(), + }); + mockSkillHandler(pushedItems); + const { isInteractive, askQuestion } = await import('../utils/prompt.js'); + vi.mocked(isInteractive).mockReturnValueOnce(false); + vi.mocked(askQuestion).mockClear(); + + await push({ all: true }); + + expect(askQuestion).not.toHaveBeenCalled(); + expect(pushedItems).toHaveLength(0); + expect(process.exitCode).toBe(2); + const { log } = await import('../utils/logger.js'); + const said = vi.mocked(log.error).mock.calls.flat().join(' '); + expect(said).toContain('common, hai'); + expect(said).toContain('--role '); + }); + it('allows selecting a non-default namespace', async () => { const pushedItems: Array> = []; mockAutoDetectInit.mockResolvedValue({ @@ -773,3 +815,1247 @@ describe('push completion signal through pushGroup (#702 follow-up, finding 5)', expect(outcome.completed).toBe(false); }); }); + +/** + * Issue #649: `--role`/`--project` used to place new skills only, so a new rule + * or agent landed at the shared root and `pull` shipped it to the whole team. + */ +describe('push namespace routing for rules and agents', () => { + /** Scans one item per type, and records what reached each handler's pushItem. */ + function mockHandlers( + scanned: Partial>>>, + pushedItems: Array>, + ) { + mockGetHandler.mockImplementation((type: string) => ({ + scanLocalForPush: vi.fn().mockResolvedValue(scanned[type as keyof typeof scanned] ?? []), + pushItem: vi.fn().mockImplementation(async (item: Record) => { + pushedItems.push(item); + }), + })); + } + + const newRule = { + name: 'my-rule', type: 'rules', sourcePath: '/tmp/my-rule.md', + relativePath: 'rules/my-rule.md', status: 'new', + }; + const newAgent = { + name: 'vr', type: 'agents', sourcePath: '/tmp/vr.md', + relativePath: 'agents/vr.yaml', status: 'new', + }; + + beforeEach(() => { + vi.clearAllMocks(); + mockPullRepo.mockResolvedValue('Already up to date.'); + mockPushRepoBranch.mockResolvedValue(true); + mockCheckoutMaster.mockResolvedValue(undefined); + mockGenerateBranchName.mockReturnValue('teamai/push/test/20260403-120000'); + mockLoadStateForScope.mockResolvedValue({ + lastPush: null, lastPull: null, pushedRules: [], pushedSkills: [], + pushedEnvVars: [], lastUpdateCheck: null, availableUpdate: null, + }); + mockSaveStateForScope.mockResolvedValue(undefined); + mockLoadRolesManifest.mockResolvedValue({ + version: 1, + roles: [ + { id: 'hai', description: 'HyperAI', resources: { knowledge: ['common', 'hai'], skills: ['common', 'hai'], agents: [] } }, + ], + }); + readlineAnswer = '1'; + mockScanTeamRepoNamespaces.mockResolvedValue([]); + process.exitCode = undefined; + }); + + it('--role places a new rule and a new agent in that namespace', async () => { + const pushedItems: Array> = []; + mockAutoDetectInit.mockResolvedValue({ + localConfig: makeLocalConfig(), + teamConfig: makeTeamConfig(), + }); + mockHandlers({ rules: [{ ...newRule }], agents: [{ ...newAgent }] }, pushedItems); + + await push({ all: true, role: 'pm' }); + + const rule = pushedItems.find((i) => i.type === 'rules'); + const agent = pushedItems.find((i) => i.type === 'agents'); + expect(rule?.relativePath).toBe('rules/pm/my-rule.md'); + expect(rule?.namespace).toBe('pm'); + // The agent keeps the extension its handler chose. + expect(agent?.relativePath).toBe('agents/pm/vr.yaml'); + expect(agent?.namespace).toBe('pm'); + }); + + it('rejects a path-traversal --role before placing a rule', async () => { + const pushedItems: Array> = []; + mockAutoDetectInit.mockResolvedValue({ + localConfig: makeLocalConfig(), + teamConfig: makeTeamConfig(), + }); + mockHandlers({ rules: [{ ...newRule }] }, pushedItems); + + await push({ all: true, role: '../outside' }); + + expect(process.exitCode).toBe(2); + expect(pushedItems).toHaveLength(0); + // The flag is the problem, and this push carries no skill at all. + const { log } = await import('../utils/logger.js'); + expect(vi.mocked(log.error).mock.calls.flat().join(' ')).toContain('--role'); + }); + + it('--project resolves each type from its own axis, not from skills', async () => { + const pushedItems: Array> = []; + mockAutoDetectInit.mockResolvedValue({ + localConfig: makeLocalConfig(), + teamConfig: makeTeamConfig(), + }); + mockLoadProjectsManifest.mockResolvedValue({ + version: 1, + projects: [{ + id: 'front-app', + name: 'Front App', + description: '', + resources: { knowledge: ['fe-know'], skills: ['fe-skills'], learnings: [], agents: ['fe-agents'] }, + }], + }); + mockHandlers({ + skills: [{ name: 'skill-a', type: 'skills', sourcePath: '/tmp/skill-a', relativePath: 'skills/skill-a', status: 'new' }], + rules: [{ ...newRule }], + agents: [{ ...newAgent }], + }, pushedItems); + + await push({ all: true, project: 'front-app' }); + + const at = (type: string) => pushedItems.find((i) => i.type === type)?.relativePath; + expect(at('rules')).toBe('rules/fe-know/my-rule.md'); + expect(at('skills')).toBe('skills/fe-skills/skill-a'); + expect(at('agents')).toBe('agents/fe-agents/vr.yaml'); + }); + + it('refuses to push to the shared root when the project declares no namespace for the type', async () => { + const pushedItems: Array> = []; + mockAutoDetectInit.mockResolvedValue({ + localConfig: makeLocalConfig(), + teamConfig: makeTeamConfig(), + }); + mockLoadProjectsManifest.mockResolvedValue({ + version: 1, + projects: [{ + id: 'front-app', name: 'Front App', description: '', + resources: { knowledge: [], skills: ['fe-skills'], learnings: [], agents: [] }, + }], + }); + mockHandlers({ rules: [{ ...newRule }] }, pushedItems); + + await push({ all: true, project: 'front-app' }); + + expect(process.exitCode).toBe(2); + expect(pushedItems).toHaveLength(0); + expect(mockPushRepoBranch).not.toHaveBeenCalled(); + }); + + it('pushes a rules-only scan to a project that declares no skills namespace', async () => { + const pushedItems: Array> = []; + mockAutoDetectInit.mockResolvedValue({ + localConfig: makeLocalConfig(), + teamConfig: makeTeamConfig(), + }); + mockLoadProjectsManifest.mockResolvedValue({ + version: 1, + projects: [{ + id: 'docs-only', name: 'Docs', description: '', + resources: { knowledge: ['docs-know'], skills: [], learnings: [], agents: [] }, + }], + }); + mockHandlers({ rules: [{ ...newRule }] }, pushedItems); + + await push({ all: true, project: 'docs-only' }); + + expect(process.exitCode).toBeUndefined(); + expect(pushedItems[0]?.relativePath).toBe('rules/docs-know/my-rule.md'); + }); + + it('leaves an already-namespaced rule where it is', async () => { + const pushedItems: Array> = []; + mockAutoDetectInit.mockResolvedValue({ + localConfig: makeLocalConfig(), + teamConfig: makeTeamConfig(), + }); + mockHandlers({ + rules: [{ + name: 'frontend/scoped', type: 'rules', sourcePath: '/tmp/scoped.md', + relativePath: 'rules/frontend/scoped.md', status: 'modified', + }], + }, pushedItems); + + await push({ all: true, role: 'pm' }); + + // pushItem writes rather than moves (#654): relocating would leave a copy behind. + expect(pushedItems[0]?.relativePath).toBe('rules/frontend/scoped.md'); + }); + + it('places a new rule in the role knowledge namespace when no flag is given', async () => { + const pushedItems: Array> = []; + mockLoadRolesManifest.mockResolvedValue({ + version: 1, + roles: [ + { id: 'solo', description: 'Solo', resources: { knowledge: ['solo-know'], skills: ['solo-skills'], agents: [] } }, + ], + }); + mockAutoDetectInit.mockResolvedValue({ + localConfig: makeLocalConfig({ primaryRole: 'solo' }), + teamConfig: makeTeamConfig(), + }); + mockHandlers({ rules: [{ ...newRule }] }, pushedItems); + + await push({ all: true }); + + // The knowledge axis, not the skills one. + expect(pushedItems[0]?.relativePath).toBe('rules/solo-know/my-rule.md'); + }); + + it('keeps a new rule at the shared root when the role declares no knowledge namespace', async () => { + const pushedItems: Array> = []; + mockLoadRolesManifest.mockResolvedValue({ + version: 1, + roles: [ + { id: 'solo', description: 'Solo', resources: { knowledge: [], skills: ['solo-skills'], agents: [] } }, + ], + }); + mockAutoDetectInit.mockResolvedValue({ + localConfig: makeLocalConfig({ primaryRole: 'solo' }), + teamConfig: makeTeamConfig(), + }); + mockHandlers({ rules: [{ ...newRule }] }, pushedItems); + + await push({ all: true }); + + expect(pushedItems[0]?.relativePath).toBe('rules/my-rule.md'); + expect(pushedItems[0]?.namespace).toBeUndefined(); + }); + + it('fails on a project with no agents namespace even when the scan skipped the agent', async () => { + const pushedItems: Array> = []; + mockAutoDetectInit.mockResolvedValue({ + localConfig: makeLocalConfig(), + teamConfig: makeTeamConfig(), + }); + mockLoadProjectsManifest.mockResolvedValue({ + version: 1, + projects: [{ + id: 'docs-only', name: 'Docs', description: '', + resources: { knowledge: ['docs-know'], skills: [], learnings: [], agents: [] }, + }], + }); + // The scan drops this one itself, so an error deferred to selection would + // never be raised and the run would end "No new or modified resources". + mockHandlers({ + agents: [{ + name: 'vr', type: 'agents', sourcePath: '/tmp/agents', + relativePath: 'agents/vr.yaml', status: 'modified', needsDestination: true, + skipReason: 'Agent "vr" has no active source. Activate its role or project before pushing local edits.', + }], + }, pushedItems); + + await push({ all: true, project: 'docs-only' }); + + expect(process.exitCode).toBe(2); + expect(pushedItems).toHaveLength(0); + expect(mockPushRepoBranch).not.toHaveBeenCalled(); + const { log } = await import('../utils/logger.js'); + expect(vi.mocked(log.error).mock.calls.flat().join(' ')).toContain('agents namespace'); + }); + + it('still pushes an unrelated rule when the only skipped agent needs a destination the project lacks', async () => { + // A stale edited copy of an agent from a dropped role must not stop a + // rule going out: skipped agents do not block the rest (#649 review). + const pushedItems: Array> = []; + mockAutoDetectInit.mockResolvedValue({ + localConfig: makeLocalConfig(), + teamConfig: makeTeamConfig(), + }); + mockLoadProjectsManifest.mockResolvedValue({ + version: 1, + projects: [{ + id: 'docs-only', name: 'Docs', description: '', + resources: { knowledge: ['docs-know'], skills: [], learnings: [], agents: [] }, + }], + }); + mockHandlers({ + rules: [{ ...newRule }], + agents: [{ + name: 'vr', type: 'agents', sourcePath: '/tmp/agents', + relativePath: 'agents/vr.yaml', status: 'modified', needsDestination: true, + skipReason: 'Agent "vr" has no active source. Activate its role or project before pushing local edits.', + }], + }, pushedItems); + + await push({ all: true, project: 'docs-only' }); + + expect(process.exitCode).not.toBe(2); + expect(pushedItems.map((i) => i.relativePath)).toEqual(['rules/docs-know/my-rule.md']); + const { log } = await import('../utils/logger.js'); + expect(vi.mocked(log.warn).mock.calls.flat().join(' ')).toContain('agents namespace'); + }); + + it('lets a modified namespaced agent through a project whose agents axis is empty', async () => { + const pushedItems: Array> = []; + mockAutoDetectInit.mockResolvedValue({ + localConfig: makeLocalConfig(), + teamConfig: makeTeamConfig(), + }); + mockLoadProjectsManifest.mockResolvedValue({ + version: 1, + projects: [{ + id: 'docs-only', name: 'Docs', description: '', + resources: { knowledge: ['docs-know'], skills: [], learnings: [], agents: [] }, + }], + }); + // Already in a namespace, so it is modified in place and needs no + // placement — an empty agents axis is none of its business. + mockHandlers({ + agents: [{ + name: 'vr', type: 'agents', sourcePath: '/tmp/vr.md', + relativePath: 'agents/hai/vr.yaml', status: 'modified', namespace: 'hai', + }], + }, pushedItems); + + await push({ all: true, project: 'docs-only' }); + + expect(process.exitCode).toBeUndefined(); + expect(pushedItems[0]?.relativePath).toBe('agents/hai/vr.yaml'); + }); + + it('pushes a selected rule when only the unselected skill lacks a project namespace', async () => { + const pushedItems: Array> = []; + mockAutoDetectInit.mockResolvedValue({ + localConfig: makeLocalConfig(), + teamConfig: makeTeamConfig(), + }); + mockLoadProjectsManifest.mockResolvedValue({ + version: 1, + projects: [{ + id: 'docs-only', name: 'Docs', description: '', + resources: { knowledge: ['docs-know'], skills: [], learnings: [], agents: [] }, + }], + }); + mockHandlers({ + skills: [{ name: 'skill-a', type: 'skills', sourcePath: '/tmp/skill-a', relativePath: 'skills/skill-a', status: 'new' }], + rules: [{ ...newRule }], + }, pushedItems); + + // Deselect the skill, keep the rule (item order is skills then rules). + const { askSelection } = await import('../utils/prompt.js'); + vi.mocked(askSelection).mockResolvedValueOnce([1]); + + await push({ project: 'docs-only' }); + + expect(process.exitCode).toBeUndefined(); + expect(pushedItems).toHaveLength(1); + expect(pushedItems[0]?.relativePath).toBe('rules/docs-know/my-rule.md'); + }); + + it('pushes a selected rule when only the unselected new agent lacks a project agents namespace', async () => { + const pushedItems: Array> = []; + mockAutoDetectInit.mockResolvedValue({ + localConfig: makeLocalConfig(), + teamConfig: makeTeamConfig(), + }); + mockLoadProjectsManifest.mockResolvedValue({ + version: 1, + projects: [{ + id: 'docs-only', name: 'Docs', description: '', + resources: { knowledge: ['docs-know'], skills: [], learnings: [], agents: [] }, + }], + }); + // A NEW agent reaches the listing, so the user can deselect it. Failing + // before the selection blocked a rules-only push on an agent that was + // never going out (#649 review). The scan-skipped case is different — see + // the test above — because that agent never reaches the listing at all. + mockHandlers({ + rules: [{ ...newRule }], + agents: [{ ...newAgent }], + }, pushedItems); + + // Deselect the agent, keep the rule (item order is rules then agents). + const { askSelection } = await import('../utils/prompt.js'); + vi.mocked(askSelection).mockResolvedValueOnce([0]); + + await push({ project: 'docs-only' }); + + expect(process.exitCode).toBeUndefined(); + expect(pushedItems).toHaveLength(1); + expect(pushedItems[0]?.relativePath).toBe('rules/docs-know/my-rule.md'); + }); + + it('still fails when the new agent lacking a project agents namespace is selected', async () => { + const pushedItems: Array> = []; + mockAutoDetectInit.mockResolvedValue({ + localConfig: makeLocalConfig(), + teamConfig: makeTeamConfig(), + }); + mockLoadProjectsManifest.mockResolvedValue({ + version: 1, + projects: [{ + id: 'docs-only', name: 'Docs', description: '', + resources: { knowledge: ['docs-know'], skills: [], learnings: [], agents: [] }, + }], + }); + mockHandlers({ + rules: [{ ...newRule }], + agents: [{ ...newAgent }], + }, pushedItems); + + await push({ all: true, project: 'docs-only' }); + + expect(process.exitCode).toBe(2); + expect(pushedItems).toHaveLength(0); + expect(mockPushRepoBranch).not.toHaveBeenCalled(); + const { log } = await import('../utils/logger.js'); + expect(vi.mocked(log.error).mock.calls.flat().join(' ')).toContain('agents namespace'); + }); + + it('still fails when the skill lacking a project namespace is selected', async () => { + const pushedItems: Array> = []; + mockAutoDetectInit.mockResolvedValue({ + localConfig: makeLocalConfig(), + teamConfig: makeTeamConfig(), + }); + mockLoadProjectsManifest.mockResolvedValue({ + version: 1, + projects: [{ + id: 'docs-only', name: 'Docs', description: '', + resources: { knowledge: ['docs-know'], skills: [], learnings: [], agents: [] }, + }], + }); + mockHandlers({ + skills: [{ name: 'skill-a', type: 'skills', sourcePath: '/tmp/skill-a', relativePath: 'skills/skill-a', status: 'new' }], + rules: [{ ...newRule }], + }, pushedItems); + + await push({ all: true, project: 'docs-only' }); + + expect(process.exitCode).toBe(2); + expect(pushedItems).toHaveLength(0); + }); + + it('says so when a new rule stays at the shared root', async () => { + const pushedItems: Array> = []; + mockLoadRolesManifest.mockResolvedValue({ + version: 1, + roles: [ + { id: 'solo', description: 'Solo', resources: { knowledge: [], skills: ['solo-skills'], agents: [] } }, + ], + }); + mockAutoDetectInit.mockResolvedValue({ + localConfig: makeLocalConfig({ primaryRole: 'solo' }), + teamConfig: makeTeamConfig(), + }); + mockHandlers({ rules: [{ ...newRule }] }, pushedItems); + + await push({ all: true }); + + expect(pushedItems[0]?.relativePath).toBe('rules/my-rule.md'); + // Reaching the whole team is the outcome worth naming out loud. + const { log } = await import('../utils/logger.js'); + const said = [...vi.mocked(log.info).mock.calls, ...vi.mocked(log.warn).mock.calls] + .flat().join(' '); + expect(said).toContain('rules/my-rule.md'); + expect(said).toMatch(/everyone|whole team|shared/i); + }); + + it('reads the projects manifest only after the team clone has been pulled', async () => { + const pushedItems: Array> = []; + mockAutoDetectInit.mockResolvedValue({ + localConfig: makeLocalConfig(), + teamConfig: makeTeamConfig(), + }); + mockLoadProjectsManifest.mockResolvedValue({ + version: 1, + projects: [{ + id: 'front-app', name: 'Front', description: '', + resources: { knowledge: ['fe-know'], skills: ['fe-skills'], learnings: [], agents: ['fe-agents'] }, + }], + }); + mockHandlers({ rules: [{ ...newRule }] }, pushedItems); + + await push({ all: true, project: 'front-app' }); + + // Read before the pull, the manifest is the previous pull's copy, and a + // namespace the remote has since changed places this run's new rules by + // the stale mapping (#649 review). + expect(mockPullRepo).toHaveBeenCalled(); + expect(mockLoadProjectsManifest).toHaveBeenCalled(); + const pullOrder = mockPullRepo.mock.invocationCallOrder[0]; + const manifestOrder = mockLoadProjectsManifest.mock.invocationCallOrder[0]; + expect(manifestOrder).toBeGreaterThan(pullOrder); + expect(pushedItems[0]?.relativePath).toBe('rules/fe-know/my-rule.md'); + }); + + it('stops a --project push when the team clone could not be refreshed', async () => { + const pushedItems: Array> = []; + mockAutoDetectInit.mockResolvedValue({ + localConfig: makeLocalConfig(), + teamConfig: makeTeamConfig(), + }); + mockPullRepo.mockRejectedValueOnce(new Error('could not resolve host')); + mockLoadProjectsManifest.mockResolvedValue({ + version: 1, + projects: [{ + id: 'front-app', name: 'Front', description: '', + resources: { knowledge: ['fe-know'], skills: ['fe-skills'], learnings: [], agents: ['fe-agents'] }, + }], + }); + mockHandlers({ rules: [{ ...newRule }] }, pushedItems); + + await push({ all: true, project: 'front-app' }); + + // The manifest in the clone is the previous pull's. A namespace the remote + // has since changed would route this rule to the wrong members, and a + // warning does not stop that (#649 review). + expect(process.exitCode).toBe(1); + expect(pushedItems).toHaveLength(0); + expect(mockPushRepoBranch).not.toHaveBeenCalled(); + const { log } = await import('../utils/logger.js'); + expect(vi.mocked(log.error).mock.calls.flat().join(' ')).toContain('could not be refreshed'); + }); + + it('stops placing a new resource on a stale clone even when that clone has no roles manifest', async () => { + // Its absence is repo state too: a manifest added remotely since the last + // pull would move this rule off the shared root (#649 review). + const pushedItems: Array> = []; + mockAutoDetectInit.mockResolvedValue({ + localConfig: makeLocalConfig({ primaryRole: undefined }), + teamConfig: makeTeamConfig(), + }); + mockPullRepo.mockRejectedValueOnce(new Error('could not resolve host')); + mockHandlers({ rules: [{ ...newRule }] }, pushedItems); + + await push({ all: true }); + + expect(process.exitCode).toBe(1); + expect(pushedItems).toHaveLength(0); + const { log } = await import('../utils/logger.js'); + expect(vi.mocked(log.error).mock.calls.flat().join(' ')).toContain('could not be refreshed'); + }); + + it('stops the push when the reconciled placement records cannot be saved', async () => { + // The sync and the scan read the records back from disk: one that could + // not be withdrawn would still redirect the author's copy (#649 review). + const pushedItems: Array> = []; + mockAutoDetectInit.mockResolvedValue({ + localConfig: makeLocalConfig(), + teamConfig: makeTeamConfig(), + }); + // No records left, so reconcile clears the checkpoint: the state changed. + mockLoadStateForScope.mockImplementation(async () => ({ + lastPush: null, lastPull: null, pushedRules: [], pushedSkills: [], pushedEnvVars: [], + lastUpdateCheck: null, availableUpdate: null, pendingPushes: [], placementsCheckedAt: 'abc', + })); + mockSaveStateForScope.mockRejectedValueOnce(new Error('EACCES: permission denied')); + mockHandlers({ rules: [{ ...newRule }] }, pushedItems); + + await push({ all: true, role: 'pm' }); + + expect(process.exitCode).toBe(1); + expect(pushedItems).toHaveLength(0); + const { log } = await import('../utils/logger.js'); + expect(vi.mocked(log.error).mock.calls.flat().join(' ')).toContain('Nothing was pushed'); + }); + + it('turns a placement into a record only once its file is on the default branch, before scanning', async () => { + // Pushed and awaiting review: nothing on the default branch yet, so no + // record — a PR closed unmerged, branch kept or not, looks exactly like + // this and must not leave one behind (#649 review). + const repoDir = fs.mkdtempSync(path.join(os.tmpdir(), 'teamai-landed-')); + try { + mockAutoDetectInit.mockResolvedValue({ + localConfig: makeLocalConfig({ + repo: { localPath: repoDir, remote: 'https://git.woa.com/test/repo.git' }, + }), + teamConfig: makeTeamConfig(), + }); + const awaiting = { + lastPush: null, lastPull: null, pushedRules: [], pushedSkills: [], + pushedEnvVars: [], lastUpdateCheck: null, availableUpdate: null, placedRules: {}, + pendingPushes: [{ + branch: 'teamai/push/test/20260101-000000', + prUrl: 'https://git.woa.com/mr/14', + createdAt: '2026-01-01T00:00:00.000Z', + items: [{ type: 'rules', name: 'my-rule', relativePath: 'rules/fe-know/my-rule.md', namespace: 'fe-know', placed: true, blob: 'b10b' }], + }], + }; + mockLoadStateForScope.mockImplementation(async () => structuredClone(awaiting)); + mockHandlers({}, []); + + await push({ all: true }); + const recordedEarly = mockSaveStateForScope.mock.calls + .map((call) => call[0] as { placedRules?: Record }) + .some((state) => 'my-rule' in (state.placedRules ?? {})); + expect(recordedEarly).toBe(false); + + // The PR merged: the file is on the default branch now. + fs.mkdirSync(path.join(repoDir, 'rules', 'fe-know'), { recursive: true }); + fs.writeFileSync(path.join(repoDir, 'rules/fe-know', 'my-rule.md'), 'landed'); + mockSaveStateForScope.mockClear(); + + await push({ all: true }); + + const saved = mockSaveStateForScope.mock.calls + .map((call) => call[0] as { placedRules?: Record }) + .find((state) => 'my-rule' in (state.placedRules ?? {})); + expect(saved?.placedRules).toEqual({ 'my-rule': 'rules/fe-know/my-rule.md' }); + } finally { + fs.rmSync(repoDir, { recursive: true, force: true }); + } + }); + + it('moves the placement record to the extension a renamed canonical agent now has', async () => { + const pushedItems: Array> = []; + mockAutoDetectInit.mockResolvedValue({ + localConfig: makeLocalConfig(), + teamConfig: makeTeamConfig(), + }); + mockLoadStateForScope.mockResolvedValue({ + lastPush: null, lastPull: null, pushedRules: [], pushedSkills: [], + pushedEnvVars: [], lastUpdateCheck: null, availableUpdate: null, pendingPushes: [], + placedAgents: { vr: 'agents/fe-agents/vr.md' }, + }); + // The scan followed the record to the legacy .md, but the source is a + // .yaml now: the write goes to one path, so the record and the staged + // file must both follow it, and the .md it replaces must go too. + mockHandlers({ + agents: [{ + name: 'vr', type: 'agents', sourcePath: '/tmp/.teamai/agents/vr.yaml', + relativePath: 'agents/fe-agents/vr.yaml', status: 'modified', namespace: 'fe-agents', + supersedes: 'agents/fe-agents/vr.md', + }], + }, pushedItems); + + await push({ all: true }); + + const staged = mockPushRepoBranch.mock.calls[0]?.[2] as string[]; + expect(staged).toContain('agents/fe-agents/vr.yaml'); + expect(staged).toContain('agents/fe-agents/vr.md'); + // The move is a placement of the new path: it becomes the record once the + // PR merges, and the recorded .md is dropped then, when it is gone. + const saved = mockSaveStateForScope.mock.calls.at(-1)?.[0] as { pendingPushes: Array<{ items: Array> }> }; + expect(saved.pendingPushes.at(-1)?.items).toEqual([ + expect.objectContaining({ type: 'agents', name: 'vr', relativePath: 'agents/fe-agents/vr.yaml', placed: true }), + ]); + }); + + it('rejects an unknown --project even when nothing needs placing', async () => { + const pushedItems: Array> = []; + mockAutoDetectInit.mockResolvedValue({ + localConfig: makeLocalConfig(), + teamConfig: makeTeamConfig(), + }); + mockLoadProjectsManifest.mockResolvedValue({ + version: 1, + projects: [{ + id: 'front-app', name: 'Front App', description: '', + resources: { knowledge: ['fe-know'], skills: ['fe-skills'], learnings: [], agents: [] }, + }], + }); + // Only a modified, already-namespaced rule: nothing reaches the per-axis + // resolver, so the typo would otherwise pass unnoticed. + mockHandlers({ + rules: [{ + name: 'fe-know/scoped', type: 'rules', sourcePath: '/tmp/scoped.md', + relativePath: 'rules/fe-know/scoped.md', status: 'modified', + }], + }, pushedItems); + + await push({ all: true, project: 'typo-id' }); + + expect(process.exitCode).toBe(2); + expect(pushedItems).toHaveLength(0); + }); + + it('keeps the namespace an open PR recorded for a skill even under --role', async () => { + const pushedItems: Array> = []; + mockAutoDetectInit.mockResolvedValue({ + localConfig: makeLocalConfig(), + teamConfig: makeTeamConfig(), + }); + mockLoadStateForScope.mockResolvedValue({ + lastPush: null, lastPull: null, pushedRules: [], pushedSkills: [], + pushedEnvVars: [], lastUpdateCheck: null, availableUpdate: null, + pendingPushes: [{ + branch: 'teamai/push/test/20260101-000000', + prUrl: 'https://git.woa.com/mr/8', + createdAt: '2026-01-01T00:00:00.000Z', + items: [{ type: 'skills', name: 'skill-a', relativePath: 'skills/js/skill-a', namespace: 'js' }], + }], + }); + mockHandlers({ + skills: [{ name: 'skill-a', type: 'skills', sourcePath: '/tmp/skill-a', relativePath: 'skills/skill-a', status: 'new' }], + }, pushedItems); + + await push({ all: true, role: 'pm' }); + + // Supersedes the original #331/#654 rule that the PR's destination always + // won. Matching is by type and name only, so an open PR for a DIFFERENT + // resource of the same name would capture this push and force-push into a + // review it has nothing to do with (#649 review). The flag the user typed + // decides, the open PR is left alone, and the collision is reported. + expect(pushedItems[0]?.relativePath).toBe('skills/pm/skill-a'); + expect(pushedItems[0]?.namespace).toBe('pm'); + const { log } = await import('../utils/logger.js'); + const said = vi.mocked(log.warn).mock.calls.flat().join(' '); + expect(said).toContain('awaiting review at skills/js/skill-a'); + expect(said).toContain('separate PR'); + }); + + it('reuses the namespace recorded for a rule when updating its open PR', async () => { + const pushedItems: Array> = []; + mockAutoDetectInit.mockResolvedValue({ + localConfig: makeLocalConfig({ primaryRole: undefined }), + teamConfig: makeTeamConfig(), + }); + mockLoadStateForScope.mockResolvedValue({ + lastPush: null, lastPull: null, pushedRules: [], pushedSkills: [], + pushedEnvVars: [], lastUpdateCheck: null, availableUpdate: null, + pendingPushes: [{ + branch: 'teamai/push/test/20260101-000000', + prUrl: 'https://git.woa.com/mr/7', + createdAt: '2026-01-01T00:00:00.000Z', + items: [{ type: 'rules', name: 'my-rule', relativePath: 'rules/fe-know/my-rule.md', namespace: 'fe-know' }], + }], + }); + mockHandlers({ rules: [{ ...newRule }] }, pushedItems); + + // No flag this time: the destination must come from the PR record, or the + // force-pushed branch would move the rule to the shared root. + await push({ all: true }); + + expect(pushedItems[0]?.relativePath).toBe('rules/fe-know/my-rule.md'); + expect(pushedItems[0]?.namespace).toBe('fe-know'); + }); + + it('records where a root-level rule was placed so the next scan recognises it', async () => { + mockAutoDetectInit.mockResolvedValue({ + localConfig: makeLocalConfig({ primaryRole: undefined }), + teamConfig: makeTeamConfig(), + }); + mockHandlers({ rules: [{ ...newRule }], agents: [{ ...newAgent }] }, []); + + await push({ all: true, role: 'pm' }); + + // The author's copy stays at the tool's rules root, so the scanner needs + // the record to map it back to rules/pm/ instead of reading it as new. It + // is marked on the pending PR entry and becomes a record when that merges. + const saved = mockSaveStateForScope.mock.calls.at(-1)?.[0] as { + placedRules?: Record; + pendingPushes: Array<{ base?: string; items: Array> }>; + }; + expect(saved.placedRules ?? {}).toEqual({}); + expect(saved.pendingPushes.at(-1)?.items).toEqual(expect.arrayContaining([ + expect.objectContaining({ type: 'rules', name: 'my-rule', relativePath: 'rules/pm/my-rule.md', placed: true }), + ])); + // Landing is later proven only by history after the commit the branch was built on. + expect(saved.pendingPushes.at(-1)?.base).toBe('base000'); + }); + + it('records where it placed a new agent, so the author can still edit it', async () => { + mockAutoDetectInit.mockResolvedValue({ + localConfig: makeLocalConfig(), + teamConfig: makeTeamConfig(), + }); + mockHandlers({ rules: [{ ...newRule }], agents: [{ ...newAgent }] }, []); + + await push({ all: true, role: 'pm' }); + + // AgentsHandler.scanLocalForPush only accepts a source whose namespace is + // ACTIVE here; without the record the author's next edit is skipped as + // "no active source" and the agent they just published is unmaintainable. + const saved = mockSaveStateForScope.mock.calls.at(-1)?.[0] as { pendingPushes: Array<{ items: Array> }> }; + expect(saved.pendingPushes.at(-1)?.items).toEqual(expect.arrayContaining([ + expect.objectContaining({ type: 'agents', name: 'vr', relativePath: 'agents/pm/vr.yaml', placed: true }), + ])); + }); + + it('does not record an agent it merely edited in an already-active namespace', async () => { + mockAutoDetectInit.mockResolvedValue({ + localConfig: makeLocalConfig(), + teamConfig: makeTeamConfig(), + }); + // The scanner found this agent in a namespace that is active HERE, so it + // will find it again. Recording it would turn a temporary activation into + // standing permission to keep editing it after the role or project that + // granted it is dropped (#649 review round 4). + mockHandlers({ + agents: [{ + name: 'vr', type: 'agents', sourcePath: '/tmp/vr.md', + relativePath: 'agents/hai/vr.yaml', status: 'modified', namespace: 'hai', + }], + }, []); + + await push({ all: true }); + + const saved = mockSaveStateForScope.mock.calls.at(-1)?.[0] as { pendingPushes: Array<{ items: Array> }> }; + expect(saved.pendingPushes.at(-1)?.items).toEqual([ + expect.not.objectContaining({ placed: true }), + ]); + }); + + it('refuses a roles manifest whose namespace is not a single path segment', async () => { + const pushedItems: Array> = []; + mockLoadRolesManifest.mockResolvedValue({ + version: 1, + roles: [ + { id: 'solo', description: 'Solo', resources: { knowledge: ['foo/bar'], skills: ['solo'], agents: [] } }, + ], + }); + mockAutoDetectInit.mockResolvedValue({ + localConfig: makeLocalConfig({ primaryRole: 'solo' }), + teamConfig: makeTeamConfig(), + }); + mockHandlers({ rules: [{ ...newRule }] }, pushedItems); + + await push({ all: true }); + + // `--role` and the projects manifest are both checked for this; two levels + // put an agent below the depth pull looks at, and read back as the wrong + // namespace for a rule. + expect(process.exitCode).toBe(2); + expect(pushedItems).toHaveLength(0); + const { log } = await import('../utils/logger.js'); + expect(vi.mocked(log.error).mock.calls.flat().join(' ')).toContain('foo/bar'); + }); + + it('detects a pending namespace recorded only in the path', async () => { + const pushedItems: Array> = []; + mockAutoDetectInit.mockResolvedValue({ + localConfig: makeLocalConfig({ primaryRole: undefined }), + teamConfig: makeTeamConfig(), + }); + // The agent scan records a namespaced destination without setting the + // `namespace` field, so trusting that field let the entry slip past the + // conflict check and be force-pushed into (#649 review). + mockLoadStateForScope.mockResolvedValue({ + lastPush: null, lastPull: null, pushedRules: [], pushedSkills: [], + pushedEnvVars: [], lastUpdateCheck: null, availableUpdate: null, + pendingPushes: [{ + branch: 'teamai/push/test/20260101-000000', + prUrl: 'https://git.woa.com/mr/12', + createdAt: '2026-01-01T00:00:00.000Z', + items: [{ type: 'agents', name: 'vr', relativePath: 'agents/fe-agents/vr.yaml' }], + }], + }); + mockHandlers({ agents: [{ ...newAgent }] }, pushedItems); + + await push({ all: true, role: 'be-agents' }); + + expect(pushedItems[0]?.relativePath).toBe('agents/be-agents/vr.yaml'); + const { log } = await import('../utils/logger.js'); + expect(vi.mocked(log.warn).mock.calls.flat().join(' ')) + .toContain('awaiting review at agents/fe-agents/vr.yaml'); + }); + + it('treats a pending shared-root resource as conflicting with an explicit namespace', async () => { + const pushedItems: Array> = []; + mockAutoDetectInit.mockResolvedValue({ + localConfig: makeLocalConfig({ primaryRole: undefined }), + teamConfig: makeTeamConfig(), + }); + // The open PR holds this rule at the shared root. Reusing its branch would + // force-rebuild it with the namespaced path and silently change the scope + // of a review the user did not name (#649 review). A recorded path with no + // namespace is as much a destination as a namespaced one. + mockLoadStateForScope.mockResolvedValue({ + lastPush: null, lastPull: null, pushedRules: [], pushedSkills: [], + pushedEnvVars: [], lastUpdateCheck: null, availableUpdate: null, + pendingPushes: [{ + branch: 'teamai/push/test/20260101-000000', + prUrl: 'https://git.woa.com/mr/13', + createdAt: '2026-01-01T00:00:00.000Z', + items: [{ type: 'rules', name: 'my-rule', relativePath: 'rules/my-rule.md' }], + }], + }); + mockHandlers({ rules: [{ ...newRule }] }, pushedItems); + + await push({ all: true, role: 'fe-know' }); + + expect(pushedItems[0]?.relativePath).toBe('rules/fe-know/my-rule.md'); + // A new branch, never the pending one. + const branches = mockPushRepoBranch.mock.calls.map((call) => call[3]); + expect(branches).not.toContain('teamai/push/test/20260101-000000'); + const { log } = await import('../utils/logger.js'); + const said = vi.mocked(log.warn).mock.calls.flat().join(' '); + expect(said).toContain('awaiting review at rules/my-rule.md'); + expect(said).toContain('separate PR'); + }); + + it('leaves an open placement PR alone once a shared-root file takes the name', async () => { + const pushedItems: Array> = []; + mockAutoDetectInit.mockResolvedValue({ + localConfig: makeLocalConfig({ primaryRole: undefined }), + teamConfig: makeTeamConfig(), + }); + // my-rule is awaiting review at rules/fe-know/; a teammate has since merged + // an unrelated rules/my-rule.md. Reusing the PR by type and name rebuilt it + // with the author's copy over that shared rule (#649 review). + mockLoadStateForScope.mockResolvedValue({ + lastPush: null, lastPull: null, pushedRules: [], pushedSkills: [], + pushedEnvVars: [], lastUpdateCheck: null, availableUpdate: null, + pendingPushes: [{ + branch: 'teamai/push/test/20260101-000000', + prUrl: 'https://git.woa.com/mr/15', + createdAt: '2026-01-01T00:00:00.000Z', + items: [{ type: 'rules', name: 'my-rule', relativePath: 'rules/fe-know/my-rule.md', namespace: 'fe-know', placed: true, blob: 'b10b' }], + }], + }); + mockHandlers({ + rules: [{ name: 'my-rule', type: 'rules', sourcePath: '/tmp/local/rules/my-rule.md', relativePath: 'rules/my-rule.md', status: 'modified' }], + }, pushedItems); + + await push({ all: true }); + + expect(pushedItems).toHaveLength(0); + expect(mockPushRepoBranch).not.toHaveBeenCalled(); + const { log } = await import('../utils/logger.js'); + expect(vi.mocked(log.warn).mock.calls.flat().join(' ')).toContain('rules/my-rule.md now exists at the shared root'); + }); + + it('keeps updating the open PR of a resource the flag does not move', async () => { + const pushedItems: Array> = []; + mockAutoDetectInit.mockResolvedValue({ + localConfig: makeLocalConfig({ primaryRole: undefined }), + teamConfig: makeTeamConfig(), + }); + // A rule already in fe-know, modified, with its edit under review. --role + // relocates only NEW shared-root rules, so this one stays at its path, and + // a "conflict" with the flag sent the same file to a second PR (#649 review). + mockLoadStateForScope.mockResolvedValue({ + lastPush: null, lastPull: null, pushedRules: [], pushedSkills: [], + pushedEnvVars: [], lastUpdateCheck: null, availableUpdate: null, + pendingPushes: [{ + branch: 'teamai/push/test/20260101-000000', + prUrl: 'https://git.woa.com/mr/14', + createdAt: '2026-01-01T00:00:00.000Z', + items: [{ type: 'rules', name: 'fe-know/my-rule', relativePath: 'rules/fe-know/my-rule.md', namespace: 'fe-know' }], + }], + }); + mockHandlers({ + rules: [{ + name: 'fe-know/my-rule', type: 'rules', sourcePath: '/tmp/local/rules/fe-know/my-rule.md', + relativePath: 'rules/fe-know/my-rule.md', status: 'modified', namespace: 'fe-know', + }], + }, pushedItems); + + await push({ all: true, role: 'other' }); + + expect(pushedItems[0]?.relativePath).toBe('rules/fe-know/my-rule.md'); + const branches = mockPushRepoBranch.mock.calls.map((call) => call[3]); + expect(branches).toEqual(['teamai/push/test/20260101-000000']); + const { log } = await import('../utils/logger.js'); + expect(vi.mocked(log.warn).mock.calls.flat().join(' ')).not.toContain('separate PR'); + }); + + it('--dry-run reports the same destination the real push would use', async () => { + mockAutoDetectInit.mockResolvedValue({ + localConfig: makeLocalConfig(), + teamConfig: makeTeamConfig(), + }); + // An open PR holds the same name at a different namespace. The real push + // ignores it under --role, so the dry run must not report its destination. + mockLoadStateForScope.mockResolvedValue({ + lastPush: null, lastPull: null, pushedRules: [], pushedSkills: [], + pushedEnvVars: [], lastUpdateCheck: null, availableUpdate: null, + pendingPushes: [{ + branch: 'teamai/push/test/20260101-000000', + prUrl: 'https://git.woa.com/mr/11', + createdAt: '2026-01-01T00:00:00.000Z', + items: [{ type: 'rules', name: 'my-rule', relativePath: 'rules/fe-know/my-rule.md', namespace: 'fe-know' }], + }], + }); + mockHandlers({ rules: [{ ...newRule }] }, []); + + await push({ dryRun: true, role: 'pm' }); + + const { log } = await import('../utils/logger.js'); + const said = vi.mocked(log.info).mock.calls.flat().join(' '); + expect(said).toContain('rules/pm/my-rule.md'); + expect(said).not.toContain('rules/fe-know/my-rule.md'); + expect(mockPushRepoBranch).not.toHaveBeenCalled(); + }); + + it('keeps the placement of a group that pushed when a later group fails', async () => { + const pushedItems: Array> = []; + mockAutoDetectInit.mockResolvedValue({ + localConfig: makeLocalConfig({ primaryRole: undefined }), + teamConfig: makeTeamConfig(), + }); + // Two groups: the rule belongs to an open PR, the agent does not. + mockLoadStateForScope.mockResolvedValue({ + lastPush: null, lastPull: null, pushedRules: [], pushedSkills: [], + pushedEnvVars: [], lastUpdateCheck: null, availableUpdate: null, + pendingPushes: [{ + branch: 'teamai/push/test/20260101-000000', + prUrl: 'https://git.woa.com/mr/9', + createdAt: '2026-01-01T00:00:00.000Z', + items: [{ type: 'rules', name: 'my-rule', relativePath: 'rules/pm/my-rule.md', namespace: 'pm' }], + }], + }); + mockHandlers({ rules: [{ ...newRule }], agents: [{ ...newAgent }] }, pushedItems); + // First group pushes, second throws. + mockPushRepoBranch.mockResolvedValueOnce(true).mockRejectedValueOnce(new Error('remote rejected')); + + // The PR's namespace matches what --role asks for, so its branch is reused + // and the run really does have two groups. + await push({ all: true, role: 'pm' }); + + expect(process.exitCode).toBe(1); + // The rule is on the remote now. Losing where it went means the author's + // root copy is reclassified once that PR merges. + const saved = mockSaveStateForScope.mock.calls.at(-1)?.[0] as { pendingPushes: Array<{ branch: string; items: Array> }> }; + const reused = saved.pendingPushes.find((entry) => entry.branch === 'teamai/push/test/20260101-000000'); + expect(reused?.items).toEqual([ + expect.objectContaining({ type: 'rules', name: 'my-rule', relativePath: 'rules/pm/my-rule.md', placed: true }), + ]); + }); + + it('refuses to place a new rule onto an existing team file', async () => { + // The scanner correctly calls an unrelated root rule NEW — no record maps + // it to anything. Placing it on a namespace that already holds that name + // replaces somebody else's rule, silently, in a run they never reviewed. + const repoDir = fs.mkdtempSync(path.join(os.tmpdir(), 'teamai-collide-')); + fs.mkdirSync(path.join(repoDir, 'rules', 'pm'), { recursive: true }); + fs.writeFileSync(path.join(repoDir, 'rules/pm', 'my-rule.md'), 'the team rule'); + const pushedItems: Array> = []; + mockAutoDetectInit.mockResolvedValue({ + localConfig: makeLocalConfig({ + repo: { localPath: repoDir, remote: 'https://git.woa.com/test/repo.git' }, + }), + teamConfig: makeTeamConfig(), + }); + mockHandlers({ rules: [{ ...newRule }] }, pushedItems); + + try { + await push({ all: true, role: 'pm' }); + + expect(process.exitCode).toBe(2); + expect(pushedItems).toHaveLength(0); + expect(mockPushRepoBranch).not.toHaveBeenCalled(); + expect(fs.readFileSync(path.join(repoDir, 'rules/pm', 'my-rule.md'), 'utf-8')) + .toBe('the team rule'); + const { log } = await import('../utils/logger.js'); + expect(vi.mocked(log.error).mock.calls.flat().join(' ')).toContain('rules/pm/my-rule.md'); + } finally { + fs.rmSync(repoDir, { recursive: true, force: true }); + } + }); + + it('refuses to place a new .md agent beside an existing .yaml of the same stem', async () => { + // pull reads a legacy .md as the same agent as its .yaml, so both copies + // would be active at once — the ambiguity pull reports and skips. + const repoDir = fs.mkdtempSync(path.join(os.tmpdir(), 'teamai-collide-')); + fs.mkdirSync(path.join(repoDir, 'agents', 'pm'), { recursive: true }); + fs.writeFileSync(path.join(repoDir, 'agents/pm', 'vr.yaml'), 'name: vr\ndescription: team\ninstructions: x\n'); + const pushedItems: Array> = []; + mockAutoDetectInit.mockResolvedValue({ + localConfig: makeLocalConfig({ + repo: { localPath: repoDir, remote: 'https://git.woa.com/test/repo.git' }, + }), + teamConfig: makeTeamConfig(), + }); + mockHandlers({ + agents: [{ name: 'vr', type: 'agents', sourcePath: '/tmp/vr.md', relativePath: 'agents/vr.md', status: 'new' }], + }, pushedItems); + + try { + await push({ all: true, role: 'pm' }); + + expect(process.exitCode).toBe(2); + expect(pushedItems).toHaveLength(0); + expect(fs.existsSync(path.join(repoDir, 'agents/pm', 'vr.md'))).toBe(false); + const { log } = await import('../utils/logger.js'); + expect(vi.mocked(log.error).mock.calls.flat().join(' ')).toContain('agents/pm/vr.yaml'); + } finally { + fs.rmSync(repoDir, { recursive: true, force: true }); + } + }); + + it('refuses to place a new skill onto an existing team skill', async () => { + const repoDir = fs.mkdtempSync(path.join(os.tmpdir(), 'teamai-collide-')); + fs.mkdirSync(path.join(repoDir, 'skills', 'pm', 'skill-a'), { recursive: true }); + fs.writeFileSync(path.join(repoDir, 'skills/pm/skill-a', 'SKILL.md'), 'the team skill'); + const pushedItems: Array> = []; + mockAutoDetectInit.mockResolvedValue({ + localConfig: makeLocalConfig({ + repo: { localPath: repoDir, remote: 'https://git.woa.com/test/repo.git' }, + }), + teamConfig: makeTeamConfig(), + }); + mockHandlers({ + skills: [{ name: 'skill-a', type: 'skills', sourcePath: '/tmp/skill-a', relativePath: 'skills/skill-a', status: 'new' }], + }, pushedItems); + + try { + await push({ all: true, role: 'pm' }); + + expect(process.exitCode).toBe(2); + expect(pushedItems).toHaveLength(0); + expect(fs.readFileSync(path.join(repoDir, 'skills/pm/skill-a', 'SKILL.md'), 'utf-8')) + .toBe('the team skill'); + } finally { + fs.rmSync(repoDir, { recursive: true, force: true }); + } + }); + + it('still lets a MODIFIED skill land on its own existing directory', async () => { + const repoDir = fs.mkdtempSync(path.join(os.tmpdir(), 'teamai-collide-')); + fs.mkdirSync(path.join(repoDir, 'skills', 'pm', 'skill-a'), { recursive: true }); + fs.writeFileSync(path.join(repoDir, 'skills/pm/skill-a', 'SKILL.md'), 'the team skill'); + const pushedItems: Array> = []; + mockAutoDetectInit.mockResolvedValue({ + localConfig: makeLocalConfig({ + repo: { localPath: repoDir, remote: 'https://git.woa.com/test/repo.git' }, + }), + teamConfig: makeTeamConfig(), + }); + mockHandlers({ + skills: [{ name: 'skill-a', type: 'skills', sourcePath: '/tmp/skill-a', relativePath: 'skills/pm/skill-a', status: 'modified', namespace: 'pm' }], + }, pushedItems); + + try { + await push({ all: true, role: 'pm' }); + + expect(process.exitCode).toBeUndefined(); + expect(pushedItems[0]?.relativePath).toBe('skills/pm/skill-a'); + } finally { + fs.rmSync(repoDir, { recursive: true, force: true }); + } + }); + + it('does not record a rule the scanner already found in a subdirectory', async () => { + mockAutoDetectInit.mockResolvedValue({ + localConfig: makeLocalConfig({ primaryRole: undefined }), + teamConfig: makeTeamConfig(), + }); + mockHandlers({ + rules: [{ + name: 'fe-know/my-rule', type: 'rules', sourcePath: '/tmp/fe-know/my-rule.md', + relativePath: 'rules/fe-know/my-rule.md', status: 'modified', namespace: 'fe-know', + }], + }, []); + + await push({ all: true }); + + // Its local path already carries the namespace, so full-path matching works. + const saved = mockSaveStateForScope.mock.calls.at(-1)?.[0] as { pendingPushes: Array<{ items: Array> }> }; + expect(saved.pendingPushes.at(-1)?.items).toEqual([ + expect.not.objectContaining({ placed: true }), + ]); + }); + it('stops the push when the roles manifest exists but cannot be read', async () => { + const pushedItems: Array> = []; + mockAutoDetectInit.mockResolvedValue({ + localConfig: makeLocalConfig({ primaryRole: 'solo' }), + teamConfig: makeTeamConfig(), + }); + mockLoadRolesManifest.mockRejectedValue(new Error('Invalid roles manifest YAML: bad indentation')); + mockHandlers({ rules: [{ ...newRule }] }, pushedItems); + + await push({ all: true }); + + // Falling back here would publish the rule to the whole team, which is the + // widening #649 is about — and nobody asked for it. + expect(process.exitCode).toBe(2); + expect(pushedItems).toHaveLength(0); + expect(mockPushRepoBranch).not.toHaveBeenCalled(); + const { log } = await import('../utils/logger.js'); + expect(vi.mocked(log.error).mock.calls.flat().join(' ')).toContain('Invalid roles manifest YAML'); + }); + + it('stops the push when the configured role is missing from the manifest', async () => { + const pushedItems: Array> = []; + mockAutoDetectInit.mockResolvedValue({ + localConfig: makeLocalConfig({ primaryRole: 'ghost' }), + teamConfig: makeTeamConfig(), + }); + mockLoadRolesManifest.mockResolvedValue({ + version: 1, + roles: [ + { id: 'solo', description: 'Solo', resources: { knowledge: ['solo-know'], skills: ['solo-skills'], agents: [] } }, + ], + }); + mockHandlers({ rules: [{ ...newRule }] }, pushedItems); + + await push({ all: true }); + + expect(process.exitCode).toBe(2); + expect(pushedItems).toHaveLength(0); + }); + + it('keeps the pre-manifest fallback when the team repo has no roles manifest', async () => { + const pushedItems: Array> = []; + mockAutoDetectInit.mockResolvedValue({ + localConfig: makeLocalConfig({ primaryRole: 'solo' }), + teamConfig: makeTeamConfig(), + }); + mockLoadRolesManifest.mockRejectedValue( + new RolesManifestNotFoundError('/tmp/team-repo/manifest/roles.yaml'), + ); + mockHandlers({ + skills: [{ name: 'skill-a', type: 'skills', sourcePath: '/tmp/skill-a', relativePath: 'skills/skill-a', status: 'new' }], + rules: [{ ...newRule }], + }, pushedItems); + + await push({ all: true }); + + // No manifest at all is the team's actual layout, not a failure: the role id + // still doubles as the skills namespace and the rule stays shared, loudly. + expect(process.exitCode).toBeUndefined(); + const at = (type: string) => pushedItems.find((i) => i.type === type)?.relativePath; + expect(at('skills')).toBe('skills/solo/skill-a'); + expect(at('rules')).toBe('rules/my-rule.md'); + }); + + it('--dry-run reports the destination and pushes nothing', async () => { + const pushedItems: Array> = []; + mockAutoDetectInit.mockResolvedValue({ + localConfig: makeLocalConfig(), + teamConfig: makeTeamConfig(), + }); + mockLoadProjectsManifest.mockResolvedValue({ + version: 1, + projects: [{ + id: 'front-app', name: 'Front App', description: '', + resources: { knowledge: ['fe-know'], skills: ['fe-skills'], learnings: [], agents: ['fe-agents'] }, + }], + }); + mockHandlers({ rules: [{ ...newRule }], agents: [{ ...newAgent }] }, pushedItems); + + await push({ dryRun: true, project: 'front-app' }); + + expect(pushedItems).toHaveLength(0); + expect(mockPushRepoBranch).not.toHaveBeenCalled(); + const { log } = await import('../utils/logger.js'); + const said = vi.mocked(log.info).mock.calls.flat().join(' '); + expect(said).toContain('rules/fe-know/my-rule.md'); + expect(said).toContain('agents/fe-agents/vr.yaml'); + }); + + it('--dry-run fails on a project axis the real push would refuse', async () => { + mockAutoDetectInit.mockResolvedValue({ + localConfig: makeLocalConfig(), + teamConfig: makeTeamConfig(), + }); + mockLoadProjectsManifest.mockResolvedValue({ + version: 1, + projects: [{ + id: 'front-app', name: 'Front App', description: '', + resources: { knowledge: [], skills: ['fe-skills'], learnings: [], agents: [] }, + }], + }); + mockHandlers({ rules: [{ ...newRule }] }, []); + + await push({ dryRun: true, project: 'front-app' }); + + // A dry run that called this viable would be worse than no dry run at all. + expect(process.exitCode).toBe(2); + const { log } = await import('../utils/logger.js'); + expect(vi.mocked(log.error).mock.calls.flat().join(' ')).toContain('knowledge'); + }); +}); diff --git a/src/__tests__/push-skill-flag.test.ts b/src/__tests__/push-skill-flag.test.ts index e7f77aa4..548b4906 100644 --- a/src/__tests__/push-skill-flag.test.ts +++ b/src/__tests__/push-skill-flag.test.ts @@ -14,6 +14,7 @@ const mockPathExists = vi.fn(); const mockListDirs = vi.fn(); vi.mock('../utils/prompt.js', () => ({ + isInteractive: vi.fn(() => true), askQuestion: vi.fn(() => Promise.resolve('1')), askConfirmation: vi.fn(() => Promise.resolve(true)), askSelection: vi.fn((_prompt: string, itemCount: number, defaultAll?: boolean) => { @@ -50,6 +51,12 @@ vi.mock('../utils/git.js', () => ({ checkoutMaster: (...args: unknown[]) => mockCheckoutMaster(...args), generateBranchName: (...args: unknown[]) => mockGenerateBranchName(...args), resetToCleanMaster: vi.fn(), + // Without these the refresh throws inside push, which then treats the clone + // as stale — and a stale clone may not place a new resource without --role. + isDedicatedRepoRoot: vi.fn().mockResolvedValue(true), + getDefaultBranch: vi.fn().mockResolvedValue('main'), + getFileContentAtRev: vi.fn().mockResolvedValue(null), + getHeadCommit: vi.fn().mockResolvedValue('base000'), })); vi.mock('../roles.js', async () => { diff --git a/src/__tests__/push-team-config.test.ts b/src/__tests__/push-team-config.test.ts index 6733158a..464f13d6 100644 --- a/src/__tests__/push-team-config.test.ts +++ b/src/__tests__/push-team-config.test.ts @@ -39,6 +39,7 @@ vi.mock('../read-only.js', () => ({ assertNotReadOnly: vi.fn() })); vi.mock('../utils/pre-push-sync.js', () => ({ syncTeamUpdatesToLocal: vi.fn() })); vi.mock('../utils/prompt.js', () => ({ + isInteractive: vi.fn(() => true), askQuestion: vi.fn(() => Promise.resolve('')), askConfirmation: vi.fn(() => Promise.resolve(true)), askSelection: vi.fn((_p: string, n: number, all?: boolean) => diff --git a/src/__tests__/remove-command.test.ts b/src/__tests__/remove-command.test.ts new file mode 100644 index 00000000..4ce04a18 --- /dev/null +++ b/src/__tests__/remove-command.test.ts @@ -0,0 +1,110 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const mockSaveStateForScope = vi.fn(); +vi.mock('../config.js', () => ({ + autoDetectInit: vi.fn().mockResolvedValue({ + localConfig: { + repo: { localPath: '/tmp/team-repo', remote: 'https://example.test/team/repo.git', kind: 'git' }, + username: 'alice', scope: 'user', additionalRoles: [], + }, + teamConfig: { team: 't', repo: 'https://example.test/team/repo.git', provider: 'git', reviewers: [], toolPaths: {} }, + }), + loadStateForScope: vi.fn().mockResolvedValue({ placedAgents: {}, pendingPushes: [] }), + saveStateForScope: (...args: unknown[]) => mockSaveStateForScope(...args), +})); +vi.mock('../read-only.js', () => ({ assertNotReadOnly: vi.fn() })); +vi.mock('../utils/git.js', () => ({ + pullRepo: vi.fn().mockResolvedValue('up to date'), + pushRepoBranch: vi.fn().mockResolvedValue(true), + checkoutMaster: vi.fn(), + generateBranchName: vi.fn().mockReturnValue('teamai/push/alice/1'), +})); +vi.mock('../utils/pending-push.js', () => ({ + // A placement merged since the last run: the records change and must be saved. + reconcilePlacementRecords: vi.fn().mockResolvedValue(true), +})); +vi.mock('../push.js', () => ({ createPrWithFallback: vi.fn(), filterExistingTopLevelPaths: vi.fn() })); +const handler = { + scanTeamForPull: vi.fn().mockResolvedValue([{ name: 'vr', type: 'agents' }]), + scanLocalForPush: vi.fn().mockResolvedValue([]), + publishedNameFor: vi.fn().mockResolvedValue(null), + removeItem: vi.fn().mockResolvedValue(['agents/fe/vr.yaml', 'agents/be/vr.yaml']), +}; +vi.mock('../resources/index.js', () => ({ getHandler: () => handler })); +vi.mock('../utils/logger.js', () => ({ + log: { info: vi.fn(), success: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, + spinner: vi.fn(() => ({ start: vi.fn().mockReturnThis(), succeed: vi.fn(), fail: vi.fn() })), +})); + +const { remove } = await import('../remove.js'); +const { log } = await import('../utils/logger.js'); + +/** + * `publishedNameFor` reads the placement records back from disk. When a + * placement merged but its record could not be saved, the bare name the author + * types falls back to the stem, and removing that stem removes the agent from + * every namespace (#649 review). So `remove` stops instead. + */ +describe('teamai remove when the placement records cannot be saved', () => { + beforeEach(() => { + vi.clearAllMocks(); + process.exitCode = undefined; + mockSaveStateForScope.mockRejectedValue(Object.assign(new Error('EACCES: permission denied'), { code: 'EACCES' })); + }); + afterEach(() => { process.exitCode = undefined; }); + + it('removes nothing and exits 1', async () => { + await remove('agents', ['vr'], { force: true }); + + expect(handler.removeItem).not.toHaveBeenCalled(); + expect(process.exitCode).toBe(1); + expect(vi.mocked(log.error).mock.calls.flat().join(' ')).toContain('Nothing was removed'); + }); +}); + +/** + * Agents deploy flattened, so the team scan names them by bare stem. A machine + * with no placement record could only type that stem, which removes the agent + * from every namespace (#649 review). `/` names one of them, and a + * bare stem that means several is refused rather than guessed. + */ +describe('teamai remove agents names one agent, not a stem every namespace shares', () => { + const vrIn = (namespace: string) => ({ name: 'vr', type: 'agents', namespace, relativePath: `agents/${namespace}/vr.yaml` }); + + beforeEach(() => { + vi.clearAllMocks(); + process.exitCode = undefined; + mockSaveStateForScope.mockResolvedValue(undefined); + handler.publishedNameFor.mockResolvedValue(null); + handler.removeItem.mockResolvedValue(['agents/fe/vr.yaml']); + }); + afterEach(() => { process.exitCode = undefined; }); + + it('refuses a bare stem that names agents in several namespaces', async () => { + handler.scanTeamForPull.mockResolvedValue([vrIn('fe'), vrIn('be')]); + + await remove('agents', ['vr'], { force: true }); + + expect(handler.removeItem).not.toHaveBeenCalled(); + expect(process.exitCode).toBe(1); + expect(vi.mocked(log.error).mock.calls.flat().join(' ')).toContain('fe/vr, be/vr'); + }); + + it('removes exactly the agent a namespaced name gives', async () => { + handler.scanTeamForPull.mockResolvedValue([vrIn('fe'), vrIn('be')]); + + await remove('agents', ['fe/vr'], { force: true }); + + expect(handler.removeItem).toHaveBeenCalledTimes(1); + expect(handler.removeItem.mock.calls[0]?.[0]).toBe('fe/vr'); + }); + + it('resolves a bare stem that only one namespace has to that namespaced agent', async () => { + handler.scanTeamForPull.mockResolvedValue([vrIn('fe')]); + + await remove('agents', ['vr'], { force: true }); + + // Named exactly, so the tombstone is `fe/vr`, not a stem other namespaces share. + expect(handler.removeItem.mock.calls[0]?.[0]).toBe('fe/vr'); + }); +}); diff --git a/src/__tests__/remove.test.ts b/src/__tests__/remove.test.ts index f8b8d9e9..5d5e1933 100644 --- a/src/__tests__/remove.test.ts +++ b/src/__tests__/remove.test.ts @@ -4,11 +4,15 @@ import os from 'node:os'; import fse from 'fs-extra'; // Mock external dependencies before importing modules +const mockState: { placedRules?: Record } = {}; vi.mock('../config.js', async (importOriginal) => ({ ...(await importOriginal()), requireInit: vi.fn(), loadState: vi.fn(), saveState: vi.fn(), + // The rules scanner and `publishedNameFor` read push placements from + // state.json; tests set `mockState.placedRules` when they need one. + loadStateForScope: vi.fn(async () => mockState), })); vi.mock('../utils/git.js', () => ({ @@ -87,9 +91,120 @@ scope: 'user', afterEach(async () => { vi.unstubAllEnvs(); + delete mockState.placedRules; await fse.remove(tmpDir); }); + /** + * A rule push placed under rules// is published as `/`, but the + * author's own copy never left the rules root, so the name they type is the + * bare one. Answering "not found" about a rule we recorded putting there — or + * deleting rules/.md and leaving the namespaced one published — is the + * finding this covers (#649 review round 3). + */ + describe('a rule published into a namespace', () => { + it('resolves the bare name to the name it was published under', async () => { + await fse.outputFile( + path.join(localConfig.repo.localPath, 'rules', 'fe-know', 'my-rule.md'), 'team content', + ); + mockState.placedRules = { 'my-rule': 'rules/fe-know/my-rule.md' }; + + expect(await handler.publishedNameFor('my-rule', localConfig)).toBe('fe-know/my-rule'); + }); + + it('does not resolve a record whose team file is gone', async () => { + mockState.placedRules = { 'my-rule': 'rules/fe-know/my-rule.md' }; + + expect(await handler.publishedNameFor('my-rule', localConfig)).toBeNull(); + }); + + it('does not resolve a rule this machine never placed', async () => { + await fse.outputFile( + path.join(localConfig.repo.localPath, 'rules', 'fe-know', 'my-rule.md'), 'team content', + ); + + expect(await handler.publishedNameFor('my-rule', localConfig)).toBeNull(); + }); + + it('tombstones only the published name, even when the record makes the root copy ours', async () => { + await fse.outputFile( + path.join(localConfig.repo.localPath, 'rules', 'fe-know', 'my-rule.md'), 'team content', + ); + mockState.placedRules = { 'my-rule': 'rules/fe-know/my-rule.md' }; + + await handler.removeItem('fe-know/my-rule', teamConfig, localConfig); + + // Every member reads the tombstone: a bare `my-rule` would sweep their + // own root rule of that name and stop them publishing one. A copy an + // excluded tool keeps is instead not a push source at all. + const tombstones = await fse.readFile( + path.join(localConfig.repo.localPath, 'rules', '.removed'), 'utf-8', + ); + expect(tombstones.split('\n')).toContain('fe-know/my-rule'); + expect(tombstones.split('\n')).not.toContain('my-rule'); + }); + + it('tombstones only the name given when no record vouches for the bare one', async () => { + await fse.outputFile(path.join(localConfig.repo.localPath, 'rules', 'fe', 'foo.md'), 'team'); + + await handler.removeItem('fe/foo', teamConfig, localConfig); + + const tombstones = await fse.readFile( + path.join(localConfig.repo.localPath, 'rules', '.removed'), 'utf-8', + ); + expect(tombstones.split('\n')).toContain('fe/foo'); + // Tombstoning `foo` would suppress an unrelated personal rule of that name. + expect(tombstones.split('\n')).not.toContain('foo'); + }); + + it('leaves an unrelated root rule that only shares the basename', async () => { + // `remove rules fe/foo` must not take a personal .claude/rules/foo.md + // with it. Nothing on this machine says the two are the same rule. + await fse.outputFile(path.join(localConfig.repo.localPath, 'rules', 'fe', 'foo.md'), 'team'); + await fse.writeFile(path.join(homeDir, '.claude', 'rules', 'foo.md'), 'my own foo'); + + await handler.removeItem('fe/foo', teamConfig, localConfig); + + expect(await fse.pathExists(path.join(localConfig.repo.localPath, 'rules', 'fe', 'foo.md'))).toBe(false); + expect(await fse.readFile(path.join(homeDir, '.claude', 'rules', 'foo.md'), 'utf-8')) + .toBe('my own foo'); + }); + + it('ignores a record that points somewhere else', async () => { + await fse.outputFile(path.join(localConfig.repo.localPath, 'rules', 'fe', 'foo.md'), 'team'); + await fse.writeFile(path.join(homeDir, '.claude', 'rules', 'foo.md'), 'my own foo'); + mockState.placedRules = { foo: 'rules/other-ns/foo.md' }; + + await handler.removeItem('fe/foo', teamConfig, localConfig); + + expect(await fse.readFile(path.join(homeDir, '.claude', 'rules', 'foo.md'), 'utf-8')) + .toBe('my own foo'); + }); + + it('removes the namespaced team file and the author\'s copy at the rules root', async () => { + await fse.outputFile( + path.join(localConfig.repo.localPath, 'rules', 'fe-know', 'my-rule.md'), 'team content', + ); + // The record is what makes the root copy this rule's, and ours to delete. + mockState.placedRules = { 'my-rule': 'rules/fe-know/my-rule.md' }; + // The author's own copy, and the namespaced one a member would receive. + await fse.writeFile(path.join(homeDir, '.claude', 'rules', 'my-rule.md'), 'local'); + await fse.outputFile(path.join(homeDir, '.claude', 'rules', 'fe-know', 'my-rule.md'), 'local'); + + const removed = await handler.removeItem('fe-know/my-rule', teamConfig, localConfig); + + expect(await fse.pathExists( + path.join(localConfig.repo.localPath, 'rules', 'fe-know', 'my-rule.md'), + )).toBe(false); + // Leaving the root copy behind re-publishes the rule on the next push. + expect(await fse.pathExists(path.join(homeDir, '.claude', 'rules', 'my-rule.md'))).toBe(false); + expect(await fse.pathExists( + path.join(homeDir, '.claude', 'rules', 'fe-know', 'my-rule.md'), + )).toBe(false); + expect(removed.length).toBeGreaterThanOrEqual(3); + }); + }); + it('should remove rule from team repo and all tool directories', async () => { // Create the rule in team repo await fse.writeFile(path.join(localConfig.repo.localPath, 'rules', 'my-rule.md'), 'rule content'); diff --git a/src/__tests__/roles.test.ts b/src/__tests__/roles.test.ts index f6b33a2c..afac3a57 100644 --- a/src/__tests__/roles.test.ts +++ b/src/__tests__/roles.test.ts @@ -11,6 +11,7 @@ import { resolveRoleResourceNamespaces, activeRoleIds, loadRolesManifestIfPresent, + RolesManifestNotFoundError, } from '../roles.js'; import type { RolesManifest } from '../roles.js'; @@ -23,6 +24,37 @@ describe('loadRolesManifest', () => { return repoDir; } + /** + * `push` treats a MISSING manifest as the pre-manifest layout, where a role + * id doubles as its skills namespace and a new rule or agent stays at the + * shared root. `readFileSafe` answers null for every failure, so an + * unreadable manifest arrived looking exactly like a missing one — and sent + * those resources to the whole team (#649 review). + */ + // chmod 0o000 has no effect when running as root (CI), so skip — same gate + // as `src/__tests__/git-kind-learnings.test.ts` (#727). + it.skipIf(process.getuid?.() === 0)('reports an existing manifest it cannot read, rather than a missing one', async () => { + const repoDir = writeManifest('version: 1\nroles: []\n'); + const manifestPath = path.join(repoDir, 'manifest', 'roles.yaml'); + chmodSync(manifestPath, 0o000); + + try { + await expect(loadRolesManifest(repoDir)).rejects.toThrow(/could not be read/); + await expect(loadRolesManifest(repoDir)).rejects.not.toBeInstanceOf(RolesManifestNotFoundError); + } finally { + if (existsSync(manifestPath)) chmodSync(manifestPath, 0o600); + rmSync(repoDir, { recursive: true, force: true }); + } + }); + + it('reports a genuinely missing manifest as not found', async () => { + const repoDir = mkdtempSync(path.join(os.tmpdir(), 'teamai-roles-')); + + await expect(loadRolesManifest(repoDir)).rejects.toBeInstanceOf(RolesManifestNotFoundError); + + rmSync(repoDir, { recursive: true, force: true }); + }); + it('parses a valid manifest (with legacy learnings + shareTarget)', async () => { // Old manifests with learnings and shareTarget should still parse without error const repoDir = writeManifest(` diff --git a/src/__tests__/rules.test.ts b/src/__tests__/rules.test.ts index aade1ac8..64acd92a 100644 --- a/src/__tests__/rules.test.ts +++ b/src/__tests__/rules.test.ts @@ -8,6 +8,9 @@ vi.mock('../config.js', async (importOriginal) => ({ requireInit: vi.fn(), loadState: vi.fn(), saveState: vi.fn(), + // The rules scanner reads state.json for push placements; an empty state is + // the default, and tests that need a record override it for one call. + loadStateForScope: vi.fn(async () => ({})), })); vi.mock('../utils/git.js', () => ({ @@ -33,7 +36,8 @@ vi.mock('../utils/logger.js', () => ({ })); import { RulesHandler } from '../resources/rules.js'; -import type { TeamaiConfig, LocalConfig } from '../types.js'; +import { loadStateForScope } from '../config.js'; +import type { TeamaiConfig, LocalConfig, State } from '../types.js'; describe('RulesHandler.scanLocalForPush — modified rule detection', () => { let tmpDir: string; @@ -91,6 +95,16 @@ scope: 'user', expect(item!.status).toBe('modified'); }); + it('does not read rules from a tool this member excluded', async () => { + // `removeItem` leaves an excluded tool's copy alone; read here, it would + // republish a rule the member just removed (#649 review). + await fse.writeFile(path.join(homeDir, '.claude/rules', 'kept-by-excluded.md'), 'old rule'); + + const items = await handler.scanLocalForPush(teamConfig, { ...localConfig, disabledAgents: ['claude'] }); + + expect(items.find((i) => i.name === 'kept-by-excluded')).toBeUndefined(); + }); + it('should NOT include an unchanged rule', async () => { const teamRulesDir = path.join(localConfig.repo.localPath, 'rules'); await fse.writeFile(path.join(teamRulesDir, 'same-rule.md'), 'same content'); @@ -340,6 +354,127 @@ scope: 'user', const names = items.map((i) => i.name); expect(names).not.toContain('common/old-rule'); }); + + + /** + * Once push places a new rule under rules//, the author's own copy stays + * at the tool's rules root. Matching by full path alone would read it as a + * brand-new rule on the next push and send a second copy to the shared root, + * where it would reach the whole team (issue #649). push records the + * placement in state.json, and only that record maps a root-level local rule + * to a namespaced team file: a basename match alone proves nothing, because + * a namespaced team rule is pulled into a namespaced local directory. + */ + function stateWithPlacedRules(placedRules: Record) { + const state: State = { + lastPush: null, lastPull: null, lastPullRev: null, pushedRules: [], pushedSkills: [], + pushedEnvVars: [], pendingPushes: [], lastUpdateCheck: null, availableUpdate: null, + placedRules, + }; + vi.mocked(loadStateForScope).mockResolvedValueOnce(state); + } + + it('matches a root-level local rule against the namespaced copy push recorded for it', async () => { + const teamRulesDir = path.join(localConfig.repo.localPath, 'rules'); + await fse.ensureDir(path.join(teamRulesDir, 'fe-know')); + await fse.writeFile(path.join(teamRulesDir, 'fe-know/my-rule.md'), 'team content'); + stateWithPlacedRules({ 'my-rule': 'rules/fe-know/my-rule.md' }); + + await fse.writeFile(path.join(homeDir, '.claude/rules/my-rule.md'), 'edited locally'); + + const items = await handler.scanLocalForPush(teamConfig, localConfig); + const item = items.find((i) => i.name === 'my-rule'); + expect(item?.status).toBe('modified'); + expect(item?.relativePath).toBe('rules/fe-know/my-rule.md'); + // Recorded on the item too, so an open PR can reuse the destination. + expect(item?.namespace).toBe('fe-know'); + }); + + it('does not re-push a root-level local rule that equals the namespaced copy it was placed at', async () => { + const teamRulesDir = path.join(localConfig.repo.localPath, 'rules'); + await fse.ensureDir(path.join(teamRulesDir, 'fe-know')); + await fse.writeFile(path.join(teamRulesDir, 'fe-know/my-rule.md'), 'same content'); + stateWithPlacedRules({ 'my-rule': 'rules/fe-know/my-rule.md' }); + + await fse.writeFile(path.join(homeDir, '.claude/rules/my-rule.md'), 'same content'); + + const items = await handler.scanLocalForPush(teamConfig, localConfig); + expect(items.map((i) => i.name)).not.toContain('my-rule'); + }); + + it('keeps an unrelated root-level rule new when only its basename matches a namespaced team rule', async () => { + // Another member's machine: the team has rules/fe-know/foo.md, and this + // user wrote their own foo.md at the rules root. Nothing was pushed from + // here, so there is no record — and no grounds to overwrite the team rule. + const teamRulesDir = path.join(localConfig.repo.localPath, 'rules'); + await fse.ensureDir(path.join(teamRulesDir, 'fe-know')); + await fse.writeFile(path.join(teamRulesDir, 'fe-know/foo.md'), 'team rule'); + stateWithPlacedRules({}); + + await fse.writeFile(path.join(homeDir, '.claude/rules/foo.md'), 'unrelated local rule'); + + const items = await handler.scanLocalForPush(teamConfig, localConfig); + const item = items.find((i) => i.name === 'foo'); + expect(item?.status).toBe('new'); + expect(item?.relativePath).toBe('rules/foo.md'); + expect(item?.namespace).toBeUndefined(); + }); + + it('keeps following the record when a shared-root rule with the same name appears later', async () => { + // Another contributor adds rules/my-rule.md after this rule was placed. + // Mapping the author's copy onto it would push their content over an + // unrelated team rule, so the record wins (#649 review round 3). + const teamRulesDir = path.join(localConfig.repo.localPath, 'rules'); + await fse.ensureDir(path.join(teamRulesDir, 'fe-know')); + await fse.writeFile(path.join(teamRulesDir, 'fe-know/my-rule.md'), 'the placed rule'); + await fse.writeFile(path.join(teamRulesDir, 'my-rule.md'), 'somebody else\'s shared rule'); + stateWithPlacedRules({ 'my-rule': 'rules/fe-know/my-rule.md' }); + + await fse.writeFile(path.join(homeDir, '.claude/rules/my-rule.md'), 'edited locally'); + + const items = await handler.scanLocalForPush(teamConfig, localConfig); + const item = items.find((i) => i.name === 'my-rule'); + expect(item?.relativePath).toBe('rules/fe-know/my-rule.md'); + expect(item?.namespace).toBe('fe-know'); + }); + + it('treats a root-level rule as new again once its recorded team file is gone', async () => { + // The rule was removed from the team repo (or its namespace renamed): the + // record no longer points at anything and must not invent a destination. + const teamRulesDir = path.join(localConfig.repo.localPath, 'rules'); + await fse.ensureDir(path.join(teamRulesDir, 'other-ns')); + await fse.writeFile(path.join(teamRulesDir, 'other-ns/my-rule.md'), 'team content'); + stateWithPlacedRules({ 'my-rule': 'rules/fe-know/my-rule.md' }); + + await fse.writeFile(path.join(homeDir, '.claude/rules/my-rule.md'), 'local'); + + const items = await handler.scanLocalForPush(teamConfig, localConfig); + const item = items.find((i) => i.name === 'my-rule'); + expect(item?.status).toBe('new'); + expect(item?.relativePath).toBe('rules/my-rule.md'); + }); + + it('reports a subdirectory rule name as its namespace', async () => { + const teamRulesDir = path.join(localConfig.repo.localPath, 'rules'); + await fse.ensureDir(path.join(teamRulesDir, 'common')); + await fse.writeFile(path.join(teamRulesDir, 'common/coding-standards.md'), 'team'); + + await fse.ensureDir(path.join(homeDir, '.claude/rules/common')); + await fse.writeFile(path.join(homeDir, '.claude/rules/common/coding-standards.md'), 'edited'); + + const items = await handler.scanLocalForPush(teamConfig, localConfig); + const item = items.find((i) => i.name === 'common/coding-standards'); + expect(item?.namespace).toBe('common'); + }); + + it('leaves the namespace unset for a rule at the shared root', async () => { + await fse.writeFile(path.join(homeDir, '.claude/rules/shared.md'), 'everyone'); + + const items = await handler.scanLocalForPush(teamConfig, localConfig); + const item = items.find((i) => i.name === 'shared'); + expect(item?.relativePath).toBe('rules/shared.md'); + expect(item?.namespace).toBeUndefined(); + }); }); describe('RulesHandler.scanTeamForPull — subdirectory support', () => { @@ -483,6 +618,154 @@ scope: 'user', expect(await fse.pathExists(path.join(localRulesDir, 'hooks.md'))).toBe(false); }); + /** + * A rule published into a namespace keeps the author's copy at the rules + * ROOT under its bare name, while the desired set holds `/` — or + * nothing at all when the namespace is not active here. Sweeping by name + * alone therefore deleted the author's own file, local edits included + * (#649 review). + */ + it("spares the author's root copy of a rule published into a namespace", async () => { + const teamRulesDir = path.join(localConfig.repo.localPath, 'rules'); + await fse.outputFile(path.join(teamRulesDir, 'fe-know/my-rule.md'), 'team content'); + await fse.writeFile(path.join(teamRulesDir, 'other.md'), 'other'); + const localRulesDir = path.join(homeDir, '.claude/rules'); + await fse.writeFile(path.join(localRulesDir, 'my-rule.md'), 'my local edits'); + vi.mocked(loadStateForScope).mockResolvedValue({ + lastPush: null, lastPull: null, lastPullRev: null, pushedRules: [], pushedSkills: [], + pushedEnvVars: [], pendingPushes: [], lastUpdateCheck: null, availableUpdate: null, + placedRules: { 'my-rule': 'rules/fe-know/my-rule.md' }, + } as State); + + await handler.pullAllRules(teamConfig, localConfig, [ + { name: 'other', type: 'rules', sourcePath: path.join(teamRulesDir, 'other.md'), relativePath: 'rules/other.md', status: 'new' }, + ]); + + expect(await fse.readFile(path.join(localRulesDir, 'my-rule.md'), 'utf-8')) + .toBe('my local edits'); + }); + + /** + * When that namespace IS active here, the team file is delivered — and it + * used to land at `/rules//` beside the author's root copy, + * so a tool that loads rules recursively applied both, and they disagreed as + * soon as the team file moved on. The record names the root copy as this + * rule's local file, so delivery updates it and takes the duplicate with it + * (#649 review). + */ + it("delivers a rule this machine placed onto the author's root copy, not beside it", async () => { + const teamRulesDir = path.join(localConfig.repo.localPath, 'rules'); + await fse.outputFile(path.join(teamRulesDir, 'fe-know/my-rule.md'), 'team content'); + const localRulesDir = path.join(homeDir, '.claude/rules'); + await fse.writeFile(path.join(localRulesDir, 'my-rule.md'), 'stale root copy'); + await fse.outputFile(path.join(localRulesDir, 'fe-know/my-rule.md'), 'duplicate from an earlier pull'); + vi.mocked(loadStateForScope).mockResolvedValue({ + lastPush: null, lastPull: null, lastPullRev: null, pushedRules: [], pushedSkills: [], + pushedEnvVars: [], pendingPushes: [], lastUpdateCheck: null, availableUpdate: null, + placedRules: { 'my-rule': 'rules/fe-know/my-rule.md' }, + } as State); + + await handler.pullAllRules(teamConfig, localConfig); + + expect(await fse.readFile(path.join(localRulesDir, 'my-rule.md'), 'utf-8')).toBe('team content'); + expect(await fse.pathExists(path.join(localRulesDir, 'fe-know/my-rule.md'))).toBe(false); + }); + + it('does not redirect a placed rule onto a root path a shared-root rule of the same name owns', async () => { + const teamRulesDir = path.join(localConfig.repo.localPath, 'rules'); + await fse.outputFile(path.join(teamRulesDir, 'fe-know/my-rule.md'), 'the author\'s namespaced rule'); + await fse.writeFile(path.join(teamRulesDir, 'my-rule.md'), 'an unrelated rule for everyone'); + const localRulesDir = path.join(homeDir, '.claude/rules'); + vi.mocked(loadStateForScope).mockResolvedValue({ + lastPush: null, lastPull: null, lastPullRev: null, pushedRules: [], pushedSkills: [], + pushedEnvVars: [], pendingPushes: [], lastUpdateCheck: null, availableUpdate: null, + placedRules: { 'my-rule': 'rules/fe-know/my-rule.md' }, + } as State); + + await handler.pullAllRules(teamConfig, localConfig); + + // Both would otherwise land on my-rule.md, in whichever order the loop + // ran; the shared-root rule owns that path and the namespaced one keeps its own. + expect(await fse.readFile(path.join(localRulesDir, 'my-rule.md'), 'utf-8')).toBe('an unrelated rule for everyone'); + expect(await fse.readFile(path.join(localRulesDir, 'fe-know/my-rule.md'), 'utf-8')).toBe('the author\'s namespaced rule'); + }); + + it('delivers a namespaced rule another member placed to its namespace directory', async () => { + const teamRulesDir = path.join(localConfig.repo.localPath, 'rules'); + await fse.outputFile(path.join(teamRulesDir, 'fe-know/my-rule.md'), 'team content'); + const localRulesDir = path.join(homeDir, '.claude/rules'); + // A record for a DIFFERENT namespace is not this rule's. + vi.mocked(loadStateForScope).mockResolvedValue({ + lastPush: null, lastPull: null, lastPullRev: null, pushedRules: [], pushedSkills: [], + pushedEnvVars: [], pendingPushes: [], lastUpdateCheck: null, availableUpdate: null, + placedRules: { 'my-rule': 'rules/be-know/my-rule.md' }, + } as State); + + await handler.pullAllRules(teamConfig, localConfig); + + expect(await fse.readFile(path.join(localRulesDir, 'fe-know/my-rule.md'), 'utf-8')).toBe('team content'); + expect(await fse.pathExists(path.join(localRulesDir, 'my-rule.md'))).toBe(false); + }); + + it("spares the author's root copy while its placement is still awaiting review", async () => { + // Pushed, not merged: no team file, no record yet. The pending entry is + // what says this copy is ours. + const teamRulesDir = path.join(localConfig.repo.localPath, 'rules'); + await fse.writeFile(path.join(teamRulesDir, 'other.md'), 'other'); + const localRulesDir = path.join(homeDir, '.claude/rules'); + await fse.writeFile(path.join(localRulesDir, 'my-rule.md'), 'awaiting review'); + vi.mocked(loadStateForScope).mockResolvedValue({ + lastPush: null, lastPull: null, lastPullRev: null, pushedRules: [], pushedSkills: [], + pushedEnvVars: [], lastUpdateCheck: null, availableUpdate: null, placedRules: {}, + pendingPushes: [{ + branch: 'teamai/push/me/1', prUrl: null, createdAt: '2026-01-01T00:00:00.000Z', + items: [{ type: 'rules', name: 'my-rule', relativePath: 'rules/fe-know/my-rule.md', namespace: 'fe-know', placed: true }], + }], + } as State); + + await handler.pullAllRules(teamConfig, localConfig); + + expect(await fse.readFile(path.join(localRulesDir, 'my-rule.md'), 'utf-8')).toBe('awaiting review'); + }); + + it("spares the author's root copy while its PR is pending, even once the placement mark is spent", async () => { + // Reconcile spends the mark on a placement it cannot prove (no blob, or + // the path arrived with other content), while the PR may still be open: + // the copy is still the author's work (#649 review). + const teamRulesDir = path.join(localConfig.repo.localPath, 'rules'); + await fse.writeFile(path.join(teamRulesDir, 'other.md'), 'other'); + const localRulesDir = path.join(homeDir, '.claude/rules'); + await fse.writeFile(path.join(localRulesDir, 'my-rule.md'), 'awaiting review'); + vi.mocked(loadStateForScope).mockResolvedValue({ + lastPush: null, lastPull: null, lastPullRev: null, pushedRules: [], pushedSkills: [], + pushedEnvVars: [], lastUpdateCheck: null, availableUpdate: null, placedRules: {}, + pendingPushes: [{ + branch: 'teamai/push/me/1', prUrl: null, createdAt: '2026-01-01T00:00:00.000Z', + items: [{ type: 'rules', name: 'my-rule', relativePath: 'rules/fe-know/my-rule.md', namespace: 'fe-know', placed: false }], + }], + } as State); + + await handler.pullAllRules(teamConfig, localConfig); + + expect(await fse.readFile(path.join(localRulesDir, 'my-rule.md'), 'utf-8')).toBe('awaiting review'); + }); + + it('still sweeps a root rule whose record points at a file that is gone', async () => { + const teamRulesDir = path.join(localConfig.repo.localPath, 'rules'); + await fse.writeFile(path.join(teamRulesDir, 'other.md'), 'other'); + const localRulesDir = path.join(homeDir, '.claude/rules'); + await fse.writeFile(path.join(localRulesDir, 'my-rule.md'), 'orphaned'); + vi.mocked(loadStateForScope).mockResolvedValue({ + lastPush: null, lastPull: null, lastPullRev: null, pushedRules: [], pushedSkills: [], + pushedEnvVars: [], pendingPushes: [], lastUpdateCheck: null, availableUpdate: null, + placedRules: { 'my-rule': 'rules/fe-know/my-rule.md' }, + } as State); + + await handler.pullAllRules(teamConfig, localConfig); + + expect(await fse.pathExists(path.join(localRulesDir, 'my-rule.md'))).toBe(false); + }); + it('should remove stale files in subdirectories', async () => { const teamRulesDir = path.join(localConfig.repo.localPath, 'rules'); diff --git a/src/__tests__/self-mode-push-scan.test.ts b/src/__tests__/self-mode-push-scan.test.ts index f37ecae6..239184aa 100644 --- a/src/__tests__/self-mode-push-scan.test.ts +++ b/src/__tests__/self-mode-push-scan.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import path from 'node:path'; import os from 'node:os'; import fse from 'fs-extra'; +import { execFileSync } from 'node:child_process'; vi.mock('../utils/logger.js', () => ({ log: { @@ -16,7 +17,7 @@ vi.mock('../utils/logger.js', () => ({ import { SkillsHandler } from '../resources/skills.js'; import { RulesHandler } from '../resources/rules.js'; -import type { TeamaiConfig, LocalConfig } from '../types.js'; +import { getDataHome, type TeamaiConfig, type LocalConfig } from '../types.js'; /** * Regression for single-repo mode: users edit team knowledge directly under @@ -135,4 +136,38 @@ describe('single-repo mode: push scans .teamai knowledge dir', () => { expect(item).toBeDefined(); expect(item!.status).toBe('new'); }); + + it('does not push an old copy of a placed rule over a newer team version', async () => { + // Nothing refreshes .teamai/rules/my-rule.md after it was placed at + // rules/fe/my-rule.md, so once a teammate edits that file the root copy is + // an older version nobody edited — not a change to push (#649 review). + const worktree = path.dirname(worktreeTeamai); + const run = (args: string[]) => execFileSync('git', args, { cwd: worktree, encoding: 'utf8', env: { + ...process.env, GIT_AUTHOR_NAME: 'T', GIT_AUTHOR_EMAIL: 't@t', GIT_COMMITTER_NAME: 'T', GIT_COMMITTER_EMAIL: 't@t', + } }); + const placed = path.join(worktreeTeamai, 'rules', 'fe', 'my-rule.md'); + run(['init', '-q', '-b', 'main']); + await fse.outputFile(placed, '# Rule as placed\n'); + run(['add', '-A']); run(['commit', '-q', '-m', 'placement merged']); + await fse.outputFile(placed, '# Rule, improved by a teammate\n'); + run(['add', '-A']); run(['commit', '-q', '-m', 'teammate edit']); + await fse.writeFile(path.join(bizRoot, '.teamai', 'rules', 'my-rule.md'), '# Rule as placed\n'); + await fse.outputJson(path.join(getDataHome(localConfig), 'state.json'), { + placedRules: { 'my-rule': 'rules/fe/my-rule.md' }, + }); + + const items = await new RulesHandler().scanLocalForPush(teamConfig, localConfig); + + expect(items.map((i) => i.name)).not.toContain('my-rule'); + }); + + it('still scans .teamai/rules when enabledAgents is set, as single-repo init always writes it', async () => { + // The synthetic scan source is not a tool, so the excluded-tool gate must + // not read it as one outside `enabledAgents` (#649 review). + await fse.writeFile(path.join(bizRoot, '.teamai', 'rules', 'new-rule.md'), '# New rule\n'); + + const items = await new RulesHandler().scanLocalForPush(teamConfig, { ...localConfig, enabledAgents: ['claude'] }); + + expect(items.map((i) => i.name)).toContain('new-rule'); + }); }); diff --git a/src/__tests__/skip-uninstalled-tools.test.ts b/src/__tests__/skip-uninstalled-tools.test.ts index f34ddf86..f4ac0fc8 100644 --- a/src/__tests__/skip-uninstalled-tools.test.ts +++ b/src/__tests__/skip-uninstalled-tools.test.ts @@ -8,6 +8,9 @@ vi.mock('../config.js', async (importOriginal) => ({ requireInit: vi.fn(), loadState: vi.fn(), saveState: vi.fn(), + // pullAllRules reads placement records so its stale sweep spares the + // author's own copy of a rule published into a namespace. + loadStateForScope: vi.fn(async () => ({})), })); vi.mock('../utils/git.js', () => ({ diff --git a/src/index.ts b/src/index.ts index 55659204..87ef267d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -99,8 +99,9 @@ program .description('Push local resources to team repo') .option('--all', 'Push all without confirmation') .option('--skill ', 'Push a specific skill by path (e.g., ~/.claude/skills/hai/my-skill or skills/hai_dev/my-skill)') - .option('--role ', 'Target role namespace for pushed project skills') - .option('--project ', 'Target a project: push skills into the project\'s skills namespace (from manifest/projects.yaml)') + .option('--role ', 'Namespace for new skills, rules and agents (skills//, rules//, agents//)') + .option('--project ', "Target a project: each new resource goes to that project's namespace for its own type " + + '— skills, knowledge for rules, agents (from manifest/projects.yaml)') .action(async (cmdOpts) => { const globalOpts = program.opts() as GlobalOptions; const { push } = await import('./push.js'); diff --git a/src/projects.ts b/src/projects.ts index 4f78f9fe..6506b607 100644 --- a/src/projects.ts +++ b/src/projects.ts @@ -147,10 +147,15 @@ export function describeProjects(projects: Array, ): ResourceItem[] { - const kept = agentNamespaces - ? agents.filter((agent) => !agent.namespace || agentNamespaces.includes(agent.namespace)) - : agents; + const kept = selectAgentsForDirectory(agents, agentNamespaces, placedAgents); const seen = new Map(); for (const agent of kept) { @@ -422,7 +423,12 @@ export async function resolveDesiredAgents( roleContext: RolePullContext | null, ): Promise { const items = await getHandler('agents').scanTeamForPull(teamConfig, localConfig); - return filterAgentsByNamespaces(items, roleContext ? roleContext.activeNamespaces.agents : null); + const { placedAgents } = await loadStateForScope(localConfig); + return filterAgentsByNamespaces( + items, + roleContext ? roleContext.activeNamespaces.agents : null, + placedAgents, + ); } // Deployment adds a CONTRIBUTORS file that the team source may not have; ignore it @@ -639,7 +645,11 @@ async function cleanupTombstonedResources( for (const { type, toolPathField } of tombstoneTypes) { const handler = getHandler(type); - const tombstones = await handler.readTombstones(localConfig); + // Agents deploy flattened, so a namespaced agent tombstone has to be read + // as the stem the local copy carries (`AgentsHandler.removedStems`). + const tombstones = type === 'agents' + ? await (handler as AgentsHandler).removedStems(freshConfig, localConfig) + : await handler.readTombstones(localConfig); if (tombstones.size === 0) continue; for (const [tool, toolPath] of Object.entries(scopedToolPaths(freshConfig, localConfig))) { @@ -769,6 +779,28 @@ async function pullForScope( return; } + // Settle the placement records against the tree just refreshed, before + // delivery reads them: a placement whose PR has merged becomes a record, one + // whose file the team deleted stops being one, and one shadowed by a new + // shared-root file of the same name is withdrawn (#649 review). + // In single-repo mode the refresh leaves the member's own checkout as it is — + // a feature branch, or a main not pulled yet — so the records are settled + // against origin/ as a ref instead: a record dropped against that + // checkout would never come back (#649 review). + if (!options.dryRun) { + try { + const tip = localConfig.repo.kind === 'self' + ? `origin/${await getDefaultBranch(localConfig.repo.localPath)}` + : undefined; + const recordsState = await loadStateForScope(localConfig); + if (await reconcilePlacementRecords(localConfig.repo.localPath, recordsState, tip)) { + await saveStateForScope(recordsState, localConfig); + } + } catch (e) { + log.debug(`[${scopeLabel}] Placement record cleanup skipped: ${(e as Error).message}`); + } + } + // Publish what contribute queued. Here rather than inside the refresh, which // returns early for single-repo and HTTP: contribute tells the member the next // pull will retry, and that has to hold in every mode. pull() holds the diff --git a/src/push-namespaces.ts b/src/push-namespaces.ts new file mode 100644 index 00000000..660a25dc --- /dev/null +++ b/src/push-namespaces.ts @@ -0,0 +1,172 @@ +/** + * Namespace placement for `teamai push`. + * + * The team repo scopes a resource by its first directory under the resource + * root: `skills///`, `rules//.md`, `agents//.yaml`. + * `pull` filters each of those against the member's active namespaces, and a + * resource written to the shared root ships to everyone. + * + * Push therefore has to decide, per resource type, which namespace a brand-new + * resource lands in. That decision used to be inlined in `pushCore` for skills + * alone, which is why a rule or an agent pushed with `--role`/`--project` still + * reached the whole team (issue #649). It lives here so every type answers the + * same question the same way, and so the answer can be tested without a repo. + */ +import { + isSafeNamespaceSegment, findProject, unknownProjectMessage, + type ProjectResourceType, type ProjectsManifest, +} from './projects.js'; +import type { ResourceItem, ResourceType } from './types.js'; + +/** Resource types `pull` namespaces, and push therefore has to place. */ +export type PlaceableType = 'skills' | 'rules' | 'agents'; + +export const PLACEABLE_TYPES: readonly PlaceableType[] = ['skills', 'rules', 'agents']; + +export function isPlaceableType(type: ResourceType): type is PlaceableType { + return (PLACEABLE_TYPES as readonly ResourceType[]).includes(type); +} + +/** + * The manifest axis each type is namespaced by. A rule is knowledge, not + * skills: `ProjectResourceNamespacesSchema` lets a project declare different + * namespaces for the two, so resolving a rule from `resources.skills` would + * target the wrong directory (issue #649, comment 3). + */ +export const NAMESPACE_AXIS = { + skills: 'skills', + rules: 'knowledge', + agents: 'agents', +} as const satisfies Record; + +/** + * True when `item` would be written to the shared root — no namespace segment + * between the resource root and the resource itself. Those are the only items + * push places; anything already namespaced keeps the path it came with, since + * `pushItem` writes rather than moves and relocating would leave the original + * behind. + */ +export function isAtSharedRoot(item: ResourceItem): boolean { + return item.relativePath.split('/').length === 2; +} + +/** + * Insert `namespace` after the resource root. Only the directory changes, so + * the basename — `.yaml` or a legacy `.md` for agents, a bare directory name + * for skills — survives without the caller knowing which it has. + */ +export function withNamespace(relativePath: string, namespace: string): string { + const [root, ...rest] = relativePath.split('/'); + return [root, namespace, ...rest].join('/'); +} + +/** + * Where a skill lands in `namespace`. Built from the root rather than through + * `withNamespace`, because a skill's team path is derived from its name alone + * (`src/resources/skills.ts` keeps the leaf as the name): this REPLACES any + * namespace the item already carries, where `withNamespace` would insert a + * second one. + */ +export function skillNamespacePath(namespace: string, name: string): string { + return `skills/${namespace}/${name}`; +} + +/** A namespace, or why one could not be resolved — phrased for the CLI user. */ +export type NamespaceResolution = + | { ok: true; namespace: string } + | { ok: false; message: string }; + +/** + * The namespace `--project ` places `type` in, read from that type's own + * axis in `manifest/projects.yaml`. + * + * Returns a failure rather than throwing, and rather than falling back to the + * shared root: a resource written to the root reaches the whole team, which is + * the outcome the flag was used to avoid. The caller reports the message and + * exits without pushing anything. + */ +export function resolveProjectNamespace( + manifest: ProjectsManifest, + projectId: string, + type: PlaceableType, +): NamespaceResolution { + const project = findProject(manifest, projectId); + if (!project) { + return { ok: false, message: unknownProjectMessage(manifest, projectId) }; + } + + const axis = NAMESPACE_AXIS[type]; + const namespaces = project.resources[axis]; + if (namespaces.length === 0) { + return { + ok: false, + message: `Project "${projectId}" declares no ${axis} namespace, so there is nowhere to put the new ${type}. ` + + `Add one to manifest/projects.yaml, or use --role to target a namespace explicitly.`, + }; + } + if (namespaces.length > 1) { + return { + ok: false, + message: `Project "${projectId}" maps ${type} to multiple ${axis} namespaces (${namespaces.join(', ')}); ` + + 'use --role to pick one.', + }; + } + + const namespace = namespaces[0]; + if (!isSafeNamespaceSegment(namespace)) { + return { + ok: false, + message: `Project "${projectId}" declares an unusable ${axis} namespace "${namespace}": ` + + "it must be a single path segment (letters, digits, '.', '_', '-'; no '/', '\\', or '..').", + }; + } + + return { ok: true, namespace }; +} + +/** + * Where push put a root-level local resource, from the record it kept in + * `state.json` (`placedRules`, `placedAgents`). The author's copy stays at the + * tool's resource root after a push places it under `//`, so without + * this record the next scan reads it as a brand-new resource and sends a second + * copy to the shared root — where it reaches the whole team (#649). + * + * Returns null unless the record is one push could have written: inside `root`, + * namespaced, free of traversal, and named after `name`. `state.json` is a file + * on disk, so a record that fails any of those is treated as absent rather than + * followed. The caller still has to check that the file is there — a record + * pointing at a removed or renamed resource proves nothing. + * + * Both the push scanner and the pre-push sync resolve through this. They must + * agree: when only one of them followed the record, the sync skipped a + * teammate's newer version and the scan then pushed the stale copy over it. + */ +export function placedResourcePath( + placed: Record | undefined, + root: 'rules' | 'agents', + name: string, +): string | null { + // A name that already carries a namespace matches its team file by full path + // and never needs a record. + if (!placed || name.includes('/')) return null; + + const recorded = placed[name]; + if (!recorded) return null; + + const segments = recorded.split('/'); + if (segments.length !== 3) return null; + if (segments[0] !== root) return null; + if (segments.some((segment) => segment === '' || segment === '.' || segment === '..')) return null; + // The namespace must be the same safe segment push itself would write. A + // `\` in it is a separator on Windows, where `path.join` then walks out of + // the resource root that the `/` split above seemed to confine it to. + if (!isSafeNamespaceSegment(segments[1] ?? '')) return null; + // Exactly the resource's own file: `.md` for a rule, `.yaml` or a + // legacy `.md` for an agent. A prefix test would accept + // `.backup.md` and redirect scanning, syncing and removal onto an + // unrelated file that happens to sit there. + const allowed = root === 'rules' ? [`${name}.md`] : [`${name}.yaml`, `${name}.md`]; + if (!allowed.includes(segments[2])) return null; + + return recorded; +} diff --git a/src/push.ts b/src/push.ts index 4934a72f..e7e62148 100644 --- a/src/push.ts +++ b/src/push.ts @@ -4,9 +4,9 @@ import { autoDetectInit, loadStateForScope, saveStateForScope } from './config.j import { assertNotReadOnly } from './read-only.js'; import { createGit, pullRepo, pushRepoBranch, checkoutMaster, generateBranchName, - resetToCleanMaster, isDedicatedRepoRoot, getDefaultBranch, getFileContentAtRev, + resetToCleanMaster, isDedicatedRepoRoot, getDefaultBranch, getFileContentAtRev, getHeadCommit, } from './utils/git.js'; -import { +import { reconcilePlacementRecords, findPendingForItem, partiallySelectedEntries, pendingNamespaceFor, planPushGroups, prunePendingPushes, recordPendingPush, toPendingItems, type PushGroup, } from './utils/pending-push.js'; @@ -18,21 +18,19 @@ import { scanTeamRepoNamespaces } from './resources/skills.js'; import type { GlobalOptions, ResourceItem, ResourceType, LocalConfig, TeamaiConfig, State, } from './types.js'; -import { getDataHome, SYNC_LOCK_FILENAME } from './types.js'; +import { getDataHome, SELF_KNOWLEDGE_SCAN_KEY, SYNC_LOCK_FILENAME } from './types.js'; import { acquireLock, releaseLock } from './update.js'; import { assertSafePath, assertSafeResourceName, defaultAllowedRoots } from './utils/path-safety.js'; -import { loadRolesManifest, resolveRoleResourceNamespaces } from './roles.js'; -import { askQuestion, askSelection } from './utils/prompt.js'; +import { loadRolesManifest, resolveRoleResourceNamespaces, RolesManifestNotFoundError } from './roles.js'; +import type { ProjectsManifest } from './projects.js'; +import { isSafeNamespaceSegment } from './projects.js'; +import { + isAtSharedRoot, isPlaceableType, NAMESPACE_AXIS, PLACEABLE_TYPES, resolveProjectNamespace, + skillNamespacePath, withNamespace, type PlaceableType, +} from './push-namespaces.js'; +import { askQuestion, askSelection, isInteractive } from './utils/prompt.js'; import { pathExists, pruneEmptyDirs, readFileSafe, writeFile } from './utils/fs.js'; -/** - * Synthetic toolPaths key used only to make `teamai push` scan the active tree's - * .teamai/{skills,rules} in single-repo mode (see pushCore). It is never written - * to disk and never used by pull — the leading marker keeps it from colliding - * with any real agent id. - */ -const SELF_KNOWLEDGE_SCAN_KEY = '__teamai_self_knowledge__'; - /** * Filter a list of repo-root-relative paths (e.g. "rules/", "env/") down to * those that actually exist on disk. `git add` throws `pathspec did not match @@ -59,27 +57,157 @@ export async function filterExistingTopLevelPaths( return result; } +/** Candidate namespaces, or why the question could not be answered. */ +type CandidateResolution = + | { ok: true; candidates: string[] } + | { ok: false; message: string }; + /** - * Resolve available skill namespaces for the current user. - * Returns the deduplicated list from the manifest (via role config), - * or falls back to [primaryRole] if no manifest exists. + * The namespaces this user could push a new `type` into, on that type's own + * axis: the deduplicated list from the roles manifest, or — for skills, which + * are the only type with a detector — the namespace directories the team repo + * already has. + * + * A manifest that EXISTS but cannot answer — unparseable, or missing the role + * this directory is configured with — is a failure, not an empty list. Falling + * back on it would place a new rule or agent at the shared root, which sends it + * to the whole team: the exact widening #649 is about, and one the user never + * asked for. A manifest that is ABSENT is the pre-manifest convention instead, + * where the shared root is the team's actual layout, so it keeps the legacy + * fallback and the loud warning that goes with it. */ -async function resolveSkillNamespaces( - repoPath: string, - primaryRole: string, - additionalRoles: string[], -): Promise { +async function namespaceCandidates( + type: PlaceableType, + localConfig: LocalConfig, +): Promise { + if (localConfig.primaryRole) { + try { + const manifest = await loadRolesManifest(localConfig.repo.localPath); + const namespaces = resolveRoleResourceNamespaces({ + manifest, + primaryRole: localConfig.primaryRole, + additionalRoles: localConfig.additionalRoles ?? [], + }); + const axis = NAMESPACE_AXIS[type]; + const candidates = namespaces[axis]; + // A namespace is ONE directory under the resource root. `--role` and the + // projects manifest are both checked for that; the roles manifest was + // not, so `foo/bar` went through and pushed an agent to a depth `pull` + // never looks at, and a rule whose namespace then read back as `foo`. + const unsafe = candidates.find((namespace) => !isSafeNamespaceSegment(namespace)); + if (unsafe !== undefined) { + return { + ok: false, + message: `The roles manifest declares an unusable ${axis} namespace "${unsafe}": ` + + "it must be a single path segment (letters, digits, '.', '_', '-'; no '/', '\\', or '..'). " + + 'Fix manifest/roles.yaml, or pass --role to name the namespace for this push.', + }; + } + return { ok: true, candidates }; + } catch (e) { + if (!(e instanceof RolesManifestNotFoundError)) { + return { + ok: false, + message: `Cannot resolve where new ${type} should go: ${(e as Error).message}. ` + + 'Fix manifest/roles.yaml, run `teamai roles set `, or pass --role ' + + 'to name the namespace for this push.', + }; + } + // Legacy fallback: with no manifest at all a role id doubles as its + // skills namespace. That convention only ever existed for skills. + return { ok: true, candidates: type === 'skills' ? [localConfig.primaryRole] : [] }; + } + } + + // No role configured. Skills can still be placed by detecting the team repo's + // existing namespace directories; rules and agents have no such detector, so + // a new one stays at the shared root. + if (type !== 'skills') return { ok: true, candidates: [] }; try { - const manifest = await loadRolesManifest(repoPath); - const namespaces = resolveRoleResourceNamespaces({ - manifest, - primaryRole, - additionalRoles, - }); - return namespaces.skills; - } catch { - return [primaryRole]; + return { ok: true, candidates: await scanTeamRepoNamespaces(localConfig.repo.localPath) }; + } catch (e) { + return { + ok: false, + message: `Cannot list the team repo's skills namespaces: ${(e as Error).message}. ` + + 'Pass --role to name the namespace for this push.', + }; + } +} + +/** + * Where a new root-level resource should land, or why push must stop. The two + * stop states are separate because they exit differently: a manifest that + * cannot answer is a failure (exit 2), while a selection the user typed wrong + * ends the run without an error code, as it did before. + */ +type NewResourceDestination = + | { kind: 'namespace'; namespace: string } + | { kind: 'shared-root' } + | { kind: 'unresolvable'; message: string } + | { kind: 'invalid-selection'; message: string }; + +/** + * Decide the namespace for the new root-level resources of one type. + * + * Every axis answers this the same way — that consistency is the fix for #649 — + * but each reads its own namespace list, so a project whose `knowledge` and + * `skills` namespaces differ sends a rule and a skill to different directories. + */ +async function resolveNamespaceForNew( + type: PlaceableType, + options: { role?: string; project?: string; silent?: boolean }, + localConfig: LocalConfig, + projectsManifest: ProjectsManifest | null, +): Promise { + if (options.project && projectsManifest) { + const resolved = resolveProjectNamespace(projectsManifest, options.project, type); + return resolved.ok + ? { kind: 'namespace', namespace: resolved.namespace } + : { kind: 'unresolvable', message: resolved.message }; + } + + // An explicit --role is a literal namespace on every axis (already checked + // for path traversal before selection). + if (options.role) return { kind: 'namespace', namespace: options.role }; + + const resolution = await namespaceCandidates(type, localConfig); + if (!resolution.ok) return { kind: 'unresolvable', message: resolution.message }; + const { candidates } = resolution; + if (candidates.length === 0) return { kind: 'shared-root' }; + if (candidates.length === 1) return { kind: 'namespace', namespace: candidates[0] }; + if (options.silent) { + // Skills keep their historical silent default (the primary role id); no + // other axis ever had that convention, so they take the first candidate. + const skillsDefault = type === 'skills' ? localConfig.primaryRole : undefined; + return { kind: 'namespace', namespace: skillsDefault ?? candidates[0] }; + } + // No terminal to ask on (CI, a hook, TEAMAI_NONINTERACTIVE): say what the + // choice is and how to make it, instead of failing inside the prompt. + if (!isInteractive()) { + return { + kind: 'unresolvable', + message: `Several ${NAMESPACE_AXIS[type]} namespaces could take new ${type} (${candidates.join(', ')}), ` + + 'and there is no terminal to ask on. Pass --role to name one.', + }; + } + + console.log(''); + console.log(`Which namespace should new ${type} be pushed to?`); + candidates.forEach((ns, index) => { + console.log(` ${index + 1}. ${ns}`); + }); + console.log(''); + const answer = await askQuestion( + `Choose namespace [1-${candidates.length}] (default: 1 = ${candidates[0]}): `, + ); + const selection = answer ? Number.parseInt(answer, 10) : 1; + if (Number.isNaN(selection) || selection < 1 || selection > candidates.length) { + return { + kind: 'invalid-selection', + message: `Invalid selection. Choose a number between 1 and ${candidates.length}.`, + }; } + return { kind: 'namespace', namespace: candidates[selection - 1] }; } /** @@ -152,6 +280,166 @@ export { createPrWithFallback }; */ type PushGroupOutcome = 'pushed' | 'nochange' | 'pr-failed' | 'failed'; +/** + * The paths that would make `placedAt` a second copy of the same resource. + * An agent is canonically `.yaml`, but `pull` reads a legacy `.md` + * as the same agent, so a new `.md` landing beside an existing `.yaml` (or the + * reverse) produces exactly the ambiguity pull reports and skips. Checking the + * proposed path alone misses that. + */ +function collisionPaths(type: PlaceableType, placedAt: string): string[] { + if (type !== 'agents') return [placedAt]; + const stem = placedAt.replace(/\.(yaml|md)$/, ''); + return [`${stem}.yaml`, `${stem}.md`]; +} + +/** The team-relative path `pushItem` retired while writing `item`, if any. */ +function supersededPathOf(item: ResourceItem): string | undefined { + return 'supersedes' in item && typeof item.supersedes === 'string' ? item.supersedes : undefined; +} + +/** + * Give every resource waiting in an open PR back the destination that PR + * recorded, rather than asking again — a different answer would silently move + * the resource, and the branch is force-pushed, so the old copy would not even + * stay behind. Runs before placement, so a resource with a recorded namespace + * is no longer at the shared root and placement leaves it alone. + */ +function reuseRecordedDestinations(groups: PushGroup[]): void { + for (const group of groups) { + if (!group.reuse) continue; + log.info( + `Updating existing PR instead of creating a new one: ${group.reuse.prUrl ?? group.reuse.branch}`, + ); + for (const item of group.items) { + if (item.status !== 'new' || !isPlaceableType(item.type)) continue; + const ns = pendingNamespaceFor(group.reuse, item); + if (!ns) continue; + if (item.type === 'skills') { + // A skill's path is derived from its name, so the recorded namespace + // replaces whatever a --role/--project override wrote above. + item.namespace = ns; + item.relativePath = skillNamespacePath(ns, item.name); + } else if (isAtSharedRoot(item)) { + // A rule or agent carries its own path from the scanner, and that path + // is authoritative when it already names a namespace (#654). + item.namespace = ns; + item.relativePath = withNamespace(item.relativePath, ns); + } + } + } +} + +/** + * Place the NEW root-level resources of `items` in a namespace, printing where + * each one goes. One decision per axis: skills from the `skills` namespaces, + * rules from `knowledge`, agents from `agents`. Before #649 only skills were + * placed, so a new rule or agent landed at the shared root and pull shipped it + * to every member. Only items that would otherwise land at the root are + * touched — anything the scanner already namespaced keeps the path it came with. + * + * Returns false when the push must stop; it has already reported why and set + * `process.exitCode`. `--dry-run` runs this too, so it shows the destinations + * and fails on the same unresolvable axis the real command would. + */ +async function placeNewResources(args: { + items: ResourceItem[]; + options: { role?: string; project?: string; silent?: boolean }; + localConfig: LocalConfig; + projectsManifest: ProjectsManifest | null; + skillsDestinationError?: string; + /** The clone could not be refreshed this run; its manifests may be stale. */ + teamRepoStale?: boolean; +}): Promise { + const { items, options, localConfig, projectsManifest, skillsDestinationError, teamRepoStale } = args; + + // A project that declares no skills namespace only blocks the push once a + // skill is actually selected, so a rule can still go out from a scan that + // happens to contain an unrelated skill. + if (skillsDestinationError && items.some((i) => i.type === 'skills')) { + log.error(skillsDestinationError); + process.exitCode = 2; + return false; + } + + for (const type of PLACEABLE_TYPES) { + const newAtRoot = items.filter( + (i) => i.type === type && i.status === 'new' && !i.namespace && isAtSharedRoot(i), + ); + if (newAtRoot.length === 0) continue; + + // Same reasoning as --project in pushCore: a namespace resolved from an + // unrefreshed clone may name the wrong members. Every unflagged answer + // reads that clone — the roles manifest, its ABSENCE (one added remotely + // since the last pull would move new rules and agents off the shared + // root), and the skills namespaces detected from its tree — so only an + // explicit --role is safe here (#649 review). + if (teamRepoStale && !options.role) { + log.error( + `Cannot place new ${type}: the team repo could not be refreshed, so where new ${type} belong ` + + '(manifest/roles.yaml, or the namespaces the repo already has) may be out of date. ' + + 'Fix the pull and retry, or name the namespace with --role .', + ); + process.exitCode = 1; + return false; + } + + const destination = await resolveNamespaceForNew(type, options, localConfig, projectsManifest); + switch (destination.kind) { + case 'unresolvable': + log.error(destination.message); + process.exitCode = 2; + return false; + case 'invalid-selection': + log.error(destination.message); + return false; + case 'shared-root': + // The one destination that reaches the whole team is the one worth + // saying out loud, so it is never the result of a silent fallback. + for (const item of newAtRoot) { + log.warn(`[${type}] ${item.name} → ${item.relativePath} (shared with everyone: no namespace resolved)`); + } + continue; + case 'namespace': + for (const item of newAtRoot) { + const placedAt = withNamespace(item.relativePath, destination.namespace); + // This resource is NEW here, so nothing of ours is at that path yet. + // Anything already there is somebody else's, and `pushItem` writes + // rather than merges: placing on top of it would replace their work + // with ours, silently, in a run they never reviewed. + let taken: string | undefined; + for (const candidate of collisionPaths(type, placedAt)) { + if (await pathExists(path.join(localConfig.repo.localPath, candidate))) { + taken = candidate; + break; + } + } + if (taken) { + log.error( + `[${type}] ${item.name} cannot be placed: ${taken} already exists in the team repo, ` + + `and this is a new ${type.slice(0, -1)}, so pushing it there would ` + + (taken === placedAt ? 'overwrite that copy. ' : 'leave two copies of the same agent. ') + + 'Pull and edit the existing one, rename yours, or pass --role to choose another namespace.', + ); + process.exitCode = 2; + return false; + } + item.namespace = destination.namespace; + item.relativePath = placedAt; + // The silent widening in #649 was the real damage: say where it went. + log.info(`[${type}] ${item.name} → ${item.relativePath}`); + } + break; + default: { + const unhandled: never = destination; + throw new Error(`Unhandled namespace destination: ${JSON.stringify(unhandled)}`); + } + } + } + + return true; +} + /** * Push each selected resource into the team repo, commit it on a branch, and * open (or update) the matching PR. On a thrown failure it rolls back the copies @@ -181,6 +469,10 @@ async function pushGroup(args: { await handler.pushItem(item, teamConfig, localConfig); workingTreeDirtied = true; pushedFiles.push(item.relativePath); + // A file this write retired (a canonical agent renamed .md ↔ .yaml) is + // tracked on the default branch, so staging its path stages the removal. + const supersedes = supersededPathOf(item); + if (supersedes) pushedFiles.push(supersedes); } // Refresh marketplace.json if it exists and skills were pushed @@ -213,6 +505,9 @@ async function pushGroup(args: { const gitFiles = [...new Set([...pushedFiles, ...existingSweepers, ...configFiles])]; const branchName = reuse?.branch ?? generateBranchName(localConfig.username); const commitMsg = `[teamai] Push ${items.length} resource(s) from ${localConfig.username}`; + // The default-branch commit the branch is built on, which bounds the + // history that can prove a placement landed (`reconcilePlacementRecords`). + const base = await getHeadCommit(localConfig.repo.localPath) ?? undefined; const hasChanges = await pushRepoBranch( localConfig.repo.localPath, @@ -279,12 +574,16 @@ async function pushGroup(args: { // Remember the open PR so the next run updates it instead of opening a // duplicate. Recorded even when PR creation failed: the branch is on the - // remote, so pushing again must reuse it. + // remote, so pushing again must reuse it. A PR retry pushed nothing, so + // the branch — and the blobs and base that prove its placements — is the + // one already recorded; this run has no branch checked out to hash. recordPendingPush(pushState, { branch: branchName, prUrl, createdAt: new Date().toISOString(), - items: toPendingItems(items), + ...(hasChanges + ? { base, items: await toPendingItems(items, localConfig.repo.localPath) } + : { base: reuse?.base, items: reuse?.items ?? [] }), }); // Switch back to the default branch so the next group starts clean @@ -332,39 +631,24 @@ export async function push( const { localConfig, teamConfig } = await autoDetectInit(); assertNotReadOnly(localConfig, 'teamai push'); - // --project is a destination override expressed as a logical project: resolve - // it to the project's skills namespace (from manifest/projects.yaml) and reuse - // the --role landing logic below. Deliberately manifest-resolved, not the raw - // project id, so it agrees with what pull syncs (issue #375 P2 lesson). - if (options.project) { - if (options.role) { - log.error('Use either --role or --project, not both.'); - process.exitCode = 2; - return; - } - const { loadProjectsManifest, resolveProjectResourceNamespaces } = await import('./projects.js'); - const manifest = await loadProjectsManifest(localConfig.repo.localPath); - if (!manifest) { - log.error('This team repo defines no projects (no manifest/projects.yaml).'); - process.exitCode = 2; - return; - } - let skillNamespaces: string[]; - try { - skillNamespaces = resolveProjectResourceNamespaces({ manifest, activeProjects: [options.project] }).skills; - } catch (e) { - log.error((e as Error).message); - process.exitCode = 2; - return; - } - if (skillNamespaces.length !== 1) { - log.error(skillNamespaces.length === 0 - ? `Project "${options.project}" declares no skills namespace; use --role to target one explicitly.` - : `Project "${options.project}" maps to multiple skills namespaces (${skillNamespaces.join(', ')}); use --role to pick one.`); - process.exitCode = 2; - return; - } - options.role = skillNamespaces[0]; + // --project is a destination override expressed as a logical project. Each + // resource type then resolves from its OWN axis in manifest/projects.yaml — + // skills from `skills`, rules from `knowledge`, agents from `agents` — because + // a project may declare different namespaces for each (issue #649). A missing + // namespace only blocks a push that actually selects that type: the skills + // axis resolves against the scan, because it also relocates modified skills + // and the listing has to show where they go, but a failure there is held + // until the selection proves a skill is going out. + // Deliberately manifest-resolved, not the raw project id, so it agrees with + // what pull syncs (issue #375 P2 lesson). + // The manifest itself is read in `pushCore`, AFTER the team clone is pulled: + // read here it would be the previous pull's copy, and a project whose + // namespaces changed on the remote would place this run's new rules and + // agents by the stale mapping (#649 review). + if (options.project && options.role) { + log.error('Use either --role or --project, not both.'); + process.exitCode = 2; + return; } try { const configContent = await readFileSafe(path.join(localConfig.repo.localPath, 'teamai.yaml')); @@ -462,7 +746,7 @@ export async function push( async function pushCore( localConfig: LocalConfig, teamConfig: TeamaiConfig, - options: GlobalOptions & { all?: boolean; role?: string }, + options: GlobalOptions & { all?: boolean; role?: string; project?: string }, initialPendingTeamConfig: string | null = null, result?: { completed: boolean }, ): Promise { @@ -484,6 +768,9 @@ async function pushCore( // working-tree content before the reset and restore it after pull, so config edits // survive and get committed alongside resources (see gitFiles construction below). let pendingTeamConfig: string | null = initialPendingTeamConfig; + // Set when the pull below failed: everything read from the clone after this + // point is the previous pull's, manifests included. + let teamRepoStale = false; if (!selfMode) { const pullSpin = spinner('Pulling latest changes...').start(); try { @@ -516,15 +803,86 @@ async function pushCore( } pullSpin.succeed('Up to date'); } catch (e) { + teamRepoStale = true; pullSpin.warn(`Pull failed: ${(e as Error).message}`); } } + // --project is a destination override expressed as a logical project. Each + // resource type then resolves from its OWN axis in manifest/projects.yaml — + // skills from `skills`, rules from `knowledge`, agents from `agents` — because + // a project may declare different namespaces for each (issue #649). A missing + // namespace only blocks a push that actually selects that type: the skills + // axis resolves against the scan, because it also relocates modified skills + // and the listing has to show where they go, but a failure there is held + // until the selection proves a skill is going out. + // Deliberately manifest-resolved, not the raw project id, so it agrees with + // what pull syncs (issue #375 P2 lesson). Read from the clone the pull above + // just refreshed (or the fresh worktree, in self mode), never from an earlier + // state of it. + let projectsManifest: ProjectsManifest | null = null; + if (options.project) { + // A warning is not enough here: with the clone unrefreshed, a namespace + // the remote has changed would send this run's new rules and agents to + // the members of the OLD one, and nothing later in the run can tell. + if (teamRepoStale) { + log.error( + 'Cannot resolve --project destinations: the team repo could not be refreshed, so ' + + 'manifest/projects.yaml may be stale. Fix the pull and retry, or name the namespace with --role .', + ); + process.exitCode = 1; + return; + } + const { loadProjectsManifest, findProject, unknownProjectMessage } = await import('./projects.js'); + projectsManifest = await loadProjectsManifest(localConfig.repo.localPath); + if (!projectsManifest) { + log.error('This team repo defines no projects (no manifest/projects.yaml).'); + process.exitCode = 2; + return; + } + // The id is checked here, not with the namespaces: a typo must fail even on + // a push where nothing needs placing, instead of being silently ignored. + if (!findProject(projectsManifest, options.project)) { + log.error(unknownProjectMessage(projectsManifest, options.project)); + process.exitCode = 2; + return; + } + } + + // Settle the placement records against the clone just pulled, BEFORE the + // scan reads them: a placement whose PR has merged becomes a record, one + // whose file the team deleted stops being one, and one shadowed by a new + // shared-root file of the same name is withdrawn (#649 review). Not when the + // clone is stale itself — a file missing from an unrefreshed tree proves nothing. + // Not best-effort: the pre-push sync and the scan read the records back from + // disk, so a record that could not be withdrawn (a shared-root file now + // shadows it) would still redirect the author's root copy onto the + // namespaced file and push that shared content over it (#649 review). + if (!teamRepoStale) { + try { + const recordsState = await loadStateForScope(localConfig); + if (await reconcilePlacementRecords(localConfig.repo.localPath, recordsState)) { + await saveStateForScope(recordsState, localConfig); + } + } catch (e) { + log.error( + `Could not bring this machine's placement records up to date (${(e as Error).message}), ` + + 'so where your resources belong cannot be worked out safely. Nothing was pushed. ' + + 'Check that the teamai state file is writable, then retry.', + ); + process.exitCode = 1; + return; + } + } + // Sync team repo updates to local tool directories before scanning. // This prevents files changed by teammates from being falsely flagged as "modified". try { const state = await loadStateForScope(localConfig); - await syncTeamUpdatesToLocal(teamConfig, localConfig, state.lastPullRev); + // placedRules redirects a root-authored rule to the rules// file push + // put it in, so a teammate's newer version syncs down instead of being + // overwritten by the stale root copy the scan would otherwise call modified. + await syncTeamUpdatesToLocal(teamConfig, localConfig, state.lastPullRev, state.placedRules); } catch (e) { log.debug(`Pre-push sync skipped: ${(e as Error).message}`); } @@ -560,12 +918,47 @@ async function pushCore( const pushableTypes: ResourceType[] = ['skills', 'rules', 'env', 'agents']; const fullScan: ResourceItem[] = []; + // Agents are the one type whose SCAN needs the destination: it has to tell + // "an edit of the team's copy" from "a new agent for this namespace", and an + // explicit --role/--project is what answers that. Rules and skills are placed + // after selection, so their scan needs nothing. An unsafe --role resolves to + // no candidate here and is rejected with exit 2 before anything is pushed. + let requestedAgentsNamespace: string | undefined; + let agentsDestinationError: string | undefined; + if (options.role) { + requestedAgentsNamespace = options.role; + } else if (options.project && projectsManifest) { + const resolved = resolveProjectNamespace(projectsManifest, options.project, 'agents'); + if (resolved.ok) { + requestedAgentsNamespace = resolved.namespace; + } else { + agentsDestinationError = resolved.message; + } + } + for (const type of pushableTypes) { const handler = getHandler(type); - const items = await handler.scanLocalForPush(scanTeamConfig, localConfig); + const items = await handler.scanLocalForPush( + scanTeamConfig, + localConfig, + type === 'agents' ? { namespace: requestedAgentsNamespace } : undefined, + ); fullScan.push(...items); } + // A project that cannot answer for agents is reported below for an agent the + // scan itself dropped — skipped as "no active source" with `needsDestination` + // set. That one never reaches the listing, so deferring its error until the + // selection proves it is going out means never raising it. + // + // A NEW agent is different: it is listed, so the user can deselect it, and + // step 4 raises the same error if it stays selected. Failing for it here + // blocked a rules-only push on an agent that was never going out (#649 + // review). An agent already in a namespace is modified in place and needs no + // placement, so an empty agents axis is none of its business either. + const skippedForWantOfDestination = (item: ResourceItem): boolean => item.type === 'agents' + && 'needsDestination' in item && item.needsDestination === true; + // Preserve blocked items in the full scan so their pending PR records survive. // Exclude them before selection and grouping: pushItem cannot write their paths. const allItems = fullScan.filter((item) => { @@ -577,6 +970,20 @@ async function pushCore( return true; }); + // Such an agent is skipped like any other, and a skipped agent does not + // block the rest of the push: a stale copy of an agent from a dropped role + // must not stop an unrelated rule going out (#649 review). The error is the + // outcome only when nothing else is left, which is when the run would end + // "No new or modified resources" on a flag it could not honour. + if (agentsDestinationError && fullScan.some(skippedForWantOfDestination)) { + if (allItems.length === 0) { + log.error(agentsDestinationError); + process.exitCode = 2; + return; + } + log.warn(`${agentsDestinationError} The agents skipped above are left out; everything else in this push goes on.`); + } + // Keep the full scan before --skill/--role narrow allItems. prunePendingPushes // must see every pending resource that is still locally present, or narrowing to // one skill would drop the other skills' open-PR records and duplicate them next run. @@ -710,26 +1117,64 @@ async function pushCore( } } - // An explicit --role is a destination override for every selected skill, - // including modified skills. Keep relativePath aligned with pushItem's + // An explicit --role or --project is a destination override for every selected + // skill, including modified ones. Keep relativePath aligned with pushItem's // destination so git stages the files that were actually copied (#331). + // Rules and agents are deliberately NOT relocated here: pushItem writes rather + // than moves, so moving one that already lives in a namespace would leave the + // original behind (#654). They are placed in step 4, and only when new. + let skillsDestination: string | undefined; + // Held rather than reported: the skills axis resolves here so the listing can + // show where a relocated skill goes, but a project that cannot answer for + // skills must only stop the push if a skill is actually selected. + let skillsDestinationError: string | undefined; if (options.role) { try { + // --role now names a directory on every axis, so this message must not + // claim the problem is with a skill. assertSafeResourceName(options.role); + } catch (e) { + log.error(`Invalid --role value "${options.role}": ${(e as Error).message}`); + process.exitCode = 2; + return; + } + skillsDestination = options.role; + } else if (options.project && projectsManifest && allItems.some((i) => i.type === 'skills')) { + const resolved = resolveProjectNamespace(projectsManifest, options.project, 'skills'); + if (resolved.ok) { + skillsDestination = resolved.namespace; + } else { + skillsDestinationError = resolved.message; + } + } + if (skillsDestination) { + try { for (const item of allItems) { if (item.type === 'skills') { assertSafeResourceName(item.name); } } } catch (e) { - log.error(`Invalid skill role or name: ${(e as Error).message}`); + log.error(`Invalid skill name: ${(e as Error).message}`); process.exitCode = 2; return; } for (const item of allItems) { if (item.type !== 'skills') continue; - item.namespace = options.role; - item.relativePath = `skills/${options.role}/${item.name}`; + const placedAt = skillNamespacePath(skillsDestination, item.name); + // Same rule as step 4: a MODIFIED skill is meant to land on its own + // existing directory, a new one must never land on somebody else's. + if (item.status === 'new' && await pathExists(path.join(localConfig.repo.localPath, placedAt))) { + log.error( + `[skills] ${item.name} cannot be placed: ${placedAt} already exists in the team repo, ` + + 'and this is a new skill, so pushing it there would overwrite that copy. ' + + 'Pull and edit the existing one, rename yours, or pass --role to choose another namespace.', + ); + process.exitCode = 2; + return; + } + item.namespace = skillsDestination; + item.relativePath = placedAt; } } @@ -749,6 +1194,43 @@ async function pushCore( } const pendingPushes = pushState.pendingPushes; + // A rule or agent awaiting review in a namespace whose name a shared-root + // file now takes: the scan maps the author's root copy onto that shared file + // and calls it modified. Reusing the open PR — matched by type and name — + // would rebuild it with the author's content over the shared file and drop + // the namespaced change from review (#649 review). The shared root owns the + // name in every tool dir, as reconcile already rules for a record, so this + // copy is left out: the open PR stays as it is. + for (let i = allItems.length - 1; i >= 0; i--) { + const item = allItems[i]; + if (!item || (item.type !== 'rules' && item.type !== 'agents')) continue; + if (item.status !== 'modified' || !isAtSharedRoot(item)) continue; + const awaiting = pendingPushes.flatMap((entry) => entry.items.map((recorded) => ({ entry, recorded }))) + .find(({ recorded }) => recorded.type === item.type && recorded.name === item.name + && recorded.relativePath.split('/').length === 3); + if (!awaiting) continue; + log.warn( + `[${item.type}] ${item.name}: ${item.relativePath} now exists at the shared root, so your local ${item.name} ` + + `follows that file and is left out of this push. ${awaiting.recorded.relativePath} stays as it is in ` + + `${awaiting.entry.prUrl ?? `branch ${awaiting.entry.branch}`}.`, + ); + allItems.splice(i, 1); + } + + // The flag places new resources only. An edit of a shared-root rule or agent + // stays at the shared root, which reaches every member — say so rather than + // let the flag look as if it had scoped it. + if (options.role || options.project) { + for (const item of allItems) { + if ((item.type === 'rules' || item.type === 'agents') && item.status === 'modified' && isAtSharedRoot(item)) { + log.warn( + `[${item.type}] ${item.name} is an edit of the shared-root ${item.relativePath}, which every member receives; ` + + `${options.role ? '--role' : '--project'} only places new resources, so it stays there.`, + ); + } + } + } + if (allItems.length === 0) { // No resource changes, but the user may have edited teamai.yaml (sources / // publicSkills) via `teamai source add`. Push that config change on its own @@ -761,6 +1243,59 @@ async function pushCore( return; } + // An open PR is matched by type and name alone. When the user has NAMED a + // destination, a pending entry that put the same-named resource somewhere + // else is a different resource: reusing its branch would force-push this + // content into that PR and move it to the wrong namespace (#649 review). + const requestedNamespaceFor = (type: PlaceableType): string | undefined => { + if (options.role) return options.role; + if (!options.project || !projectsManifest) return undefined; + const resolved = resolveProjectNamespace(projectsManifest, options.project, type); + return resolved.ok ? resolved.namespace : undefined; + }; + // Only what the flag actually MOVES can conflict with it: every selected + // skill (the override above), and a rule or agent only while it is new and at + // the shared root (step 4). Anything else keeps the path it was scanned with, + // so its open PR is still the right one to update, and treating it as a + // conflict opened a second PR on the same file (#649 review). + const scannedByKey = new Map(allItems.map((item) => [`${item.type}:${item.name}`, item])); + const movedByFlag = (item: ResourceItem): boolean => item.type === 'skills' + || (item.status === 'new' && !item.namespace && isAtSharedRoot(item)); + const conflictsWithRequest = ( + recorded: { type: string; name: string; namespace?: string; relativePath: string }, + ): boolean => { + if (!isPlaceableType(recorded.type as ResourceType)) return false; + const scanned = scannedByKey.get(`${recorded.type}:${recorded.name}`); + if (!scanned || !movedByFlag(scanned)) return false; + // The path, not the field: a scan can record an item whose destination is + // namespaced while leaving `namespace` unset, and trusting the field let + // those entries slip past the check and be force-pushed into (#649 review). + const segments = recorded.relativePath.split('/'); + const recordedNamespace = recorded.namespace + ?? (segments.length === 3 ? segments[1] : undefined); + // A recorded path with no namespace is the shared root — as much a + // destination as any namespace. Letting it through would reuse that PR's + // branch and rebuild it with the namespaced path, moving a review the + // user did not name from "everyone" to one namespace (#649 review). + const requested = requestedNamespaceFor(recorded.type as PlaceableType); + return requested !== undefined && requested !== recordedNamespace; + }; + const reusablePending = pendingPushes.filter((entry) => { + const conflicting = entry.items.filter(conflictsWithRequest); + if (conflicting.length === 0) return true; + // Neither silent answer is safe: honouring the PR ignores the flag the user + // typed, and reusing the branch force-pushes this content into a review it + // may have nothing to do with. Say what is happening and open a new PR. + for (const recorded of conflicting) { + log.warn( + `[${recorded.type}] ${recorded.name} is awaiting review at ${recorded.relativePath} ` + + `(${entry.prUrl ?? entry.branch}). This push names a different namespace, so it goes to a ` + + 'separate PR and that one is left untouched.', + ); + } + return false; + }); + // ── Step 1: Display ALL scanned items with numbers ───────────────── console.log(''); console.log(`Found ${allItems.length} resource(s) to push:`); @@ -772,9 +1307,10 @@ async function pushCore( const num = `${i + 1}.`.padStart(4); console.log(` ${num} [${item.type}] ${item.name}${statusLabel}`); console.log(` from: ${item.sourcePath}`); - // Show destination for modified skills that already have a namespace - if (item.type === 'skills' && item.namespace) { - console.log(` to: skills/${item.namespace}/${item.name}`); + // Show the destination whenever it is already namespaced — which one it is + // decides who receives the resource, so it is not obvious from the name. + if (!isAtSharedRoot(item)) { + console.log(` to: ${item.relativePath}`); } const openPrs = findPendingForItem(pendingPushes, item); if (openPrs.length > 0) { @@ -794,8 +1330,19 @@ async function pushCore( console.log(''); } - // ── Step 2: Dry run exits after display ──────────────────────────── + // ── Step 2: Dry run resolves placement, then exits ────────────── + // Everything is treated as selected, so the run reports the destination of + // every new resource and fails on a project axis that cannot answer. Exiting + // before this would let a dry run call a push viable that the real command + // refuses — and say nothing about who the new resources reach. if (options.dryRun) { + // Same two steps, same order as a real run: an open PR's recorded + // destination first, then placement for whatever is still at the root. + reuseRecordedDestinations(planPushGroups(allItems, reusablePending)); + const placed = await placeNewResources({ + items: allItems, options, localConfig, projectsManifest, skillsDestinationError, teamRepoStale, + }); + if (!placed) return; log.info('Dry run — no changes made'); return; } @@ -821,115 +1368,22 @@ async function pushCore( // branch, which updates it in place; everything else goes into a new PR. Both // can happen in one run, so editing a resource under review updates its PR // without dragging unrelated resources into that review. - const groups = planPushGroups(selectedItems, pendingPushes); - for (const group of groups) { - if (!group.reuse) continue; - log.info( - `Updating existing PR instead of creating a new one: ${group.reuse.prUrl ?? group.reuse.branch}`, - ); - // Reuse the destination chosen when that PR was opened rather than asking - // again — a different answer would silently move the skill. - for (const item of group.items) { - if (item.type !== 'skills' || item.status !== 'new') continue; - const ns = pendingNamespaceFor(group.reuse, item); - if (!ns) continue; - item.namespace = ns; - item.relativePath = `skills/${ns}/${item.name}`; - } - } - for (const entry of partiallySelectedEntries(selectedItems, pendingPushes)) { + const groups = planPushGroups(selectedItems, reusablePending); + reuseRecordedDestinations(groups); + // The conflicting entries dropped above are deliberately not reused, so they + // are not "partly selected" either — warning about them would contradict the + // warning already given. + for (const entry of partiallySelectedEntries(selectedItems, reusablePending)) { log.warn( `Only part of ${entry.prUrl ?? entry.branch} is selected, so the selected resources go into a ` + 'new PR and will exist in both. Select all of its resources to update it in place instead.', ); } - // ── Step 4: Resolve namespace for NEW skills only (after selection) ─ - const newSkills = selectedItems.filter( - (i) => i.type === 'skills' && i.status === 'new' && !i.namespace, - ); - let resolvedNamespaceForNew: string | undefined; - - if (newSkills.length > 0) { - if (options.role) { - // Explicit --role flag: use as namespace directly (backward compat) - resolvedNamespaceForNew = options.role; - } else if (localConfig.primaryRole) { - try { - const skillNamespaces = await resolveSkillNamespaces( - localConfig.repo.localPath, - localConfig.primaryRole, - localConfig.additionalRoles ?? [], - ); - - if (skillNamespaces.length === 0) { - resolvedNamespaceForNew = undefined; - } else if (skillNamespaces.length === 1) { - resolvedNamespaceForNew = skillNamespaces[0]; - } else if (options.silent) { - resolvedNamespaceForNew = localConfig.primaryRole; - } else { - console.log(''); - console.log('Which namespace should new skills be pushed to?'); - skillNamespaces.forEach((ns, index) => { - console.log(` ${index + 1}. ${ns}`); - }); - console.log(''); - const answer = await askQuestion( - `Choose namespace [1-${skillNamespaces.length}] (default: 1 = ${skillNamespaces[0]}): `, - ); - const selection = answer ? Number.parseInt(answer, 10) : 1; - if (Number.isNaN(selection) || selection < 1 || selection > skillNamespaces.length) { - log.error(`Invalid selection. Choose a number between 1 and ${skillNamespaces.length}.`); - return; - } - resolvedNamespaceForNew = skillNamespaces[selection - 1]; - } - } catch (e) { - log.error((e as Error).message); - return; - } - } else { - // No role configured — auto-detect namespaces from team repo structure - try { - const detectedNamespaces = await scanTeamRepoNamespaces(localConfig.repo.localPath); - - if (detectedNamespaces.length === 0) { - resolvedNamespaceForNew = undefined; - } else if (detectedNamespaces.length === 1) { - resolvedNamespaceForNew = detectedNamespaces[0]; - } else if (options.silent) { - resolvedNamespaceForNew = detectedNamespaces[0]; - } else { - console.log(''); - console.log('Which namespace should new skills be pushed to?'); - detectedNamespaces.forEach((ns, index) => { - console.log(` ${index + 1}. ${ns}`); - }); - console.log(''); - const answer = await askQuestion( - `Choose namespace [1-${detectedNamespaces.length}] (default: 1 = ${detectedNamespaces[0]}): `, - ); - const selection = answer ? Number.parseInt(answer, 10) : 1; - if (Number.isNaN(selection) || selection < 1 || selection > detectedNamespaces.length) { - log.error(`Invalid selection. Choose a number between 1 and ${detectedNamespaces.length}.`); - return; - } - resolvedNamespaceForNew = detectedNamespaces[selection - 1]; - } - } catch { - resolvedNamespaceForNew = undefined; - } - } - - // Apply namespace to new skills - for (const item of newSkills) { - if (resolvedNamespaceForNew) { - item.namespace = resolvedNamespaceForNew; - item.relativePath = `skills/${resolvedNamespaceForNew}/${item.name}`; - } - } - } + // ── Step 4: Place NEW root-level resources in a namespace (after selection) ─ + if (!await placeNewResources({ + items: selectedItems, options, localConfig, projectsManifest, skillsDestinationError, teamRepoStale, + })) return; // ── Step 5: Push each group — one branch/PR per group ────────────── // Config edits ride along with the first group so they land in a single PR. @@ -953,6 +1407,10 @@ async function pushCore( process.exitCode = 1; return; } + // Where this group's placed resources went travels on its pending entry + // (`toPendingItems`), written by `pushGroup` when the branch reaches the + // remote; it becomes a record once the file lands on the default branch + // (`reconcilePlacementRecords`). if (outcome === 'pushed') anyPushed = true; if (outcome === 'pr-failed') anyPrFailed = true; configRider = false; @@ -968,6 +1426,10 @@ async function pushCore( if (item.type === 'rules' && !state.pushedRules.includes(item.name)) { state.pushedRules.push(item.name); } + // A root-level local rule that landed under rules// is still authored + // at the tool's rules root, so the scanner needs this record to recognise + // it next time (RulesHandler.scanLocalForPush). A rule the scanner already + // found in a subdirectory carries the namespace in its name and needs none. if (item.type === 'env' && !state.pushedEnvVars.includes(item.name)) { state.pushedEnvVars.push(item.name); } diff --git a/src/remove.ts b/src/remove.ts index e5674227..b94c0ae5 100644 --- a/src/remove.ts +++ b/src/remove.ts @@ -1,4 +1,5 @@ import { autoDetectInit, loadStateForScope, saveStateForScope } from './config.js'; +import { reconcilePlacementRecords } from './utils/pending-push.js'; import { assertNotReadOnly } from './read-only.js'; import { pullRepo, pushRepoBranch, checkoutMaster, generateBranchName } from './utils/git.js'; import { createPrWithFallback, filterExistingTopLevelPaths } from './push.js'; @@ -58,10 +59,43 @@ async function removeCore( // Pull latest before making changes. In self mode the worktree is already a // fresh checkout of origin/, so skip the pull. + // A clone that could not be refreshed is not the default branch: a placement + // merged since the last pull is not recorded there, so the bare name the + // author types falls back to the stem and removes that agent from every + // namespace (#649 review). Removing is a write, so stop instead of guessing. if (!selfMode) { try { await pullRepo(localConfig.repo.localPath); - } catch { /* continue even if pull fails */ } + } catch (e) { + log.error( + `The team repo could not be refreshed (${(e as Error).message}), so what "${names.join(', ')}" ` + + 'names cannot be resolved against the current default branch. Nothing was removed. ' + + 'Fix the pull (run `teamai pull` to see why) and retry.', + ); + process.exitCode = 1; + return; + } + } + + // `publishedNameFor` below resolves the bare name the author types through + // the placement record, and a placement becomes a record only once it has + // landed on the default branch — which this may be the first command to see. + // Not best-effort here: `publishedNameFor` reads the records back from disk, + // so a placement that merged but could not be saved as a record resolves to + // the bare stem — and that removes the agent from every namespace. + try { + const recordsState = await loadStateForScope(localConfig); + if (await reconcilePlacementRecords(localConfig.repo.localPath, recordsState)) { + await saveStateForScope(recordsState, localConfig); + } + } catch (e) { + log.error( + `Could not bring this machine's placement records up to date (${(e as Error).message}), ` + + 'so the names given cannot be resolved safely. Nothing was removed. ' + + 'Check that the teamai state file is writable, then retry.', + ); + process.exitCode = 1; + return; } const handler = getHandler(type as ResourceType); @@ -69,11 +103,55 @@ async function removeCore( // Verify which resources exist const teamItems = await handler.scanTeamForPull(teamConfig, localConfig); const localItems = await handler.scanLocalForPush(teamConfig, localConfig); - const allNames = new Set([...teamItems.map((i) => i.name), ...localItems.map((i) => i.name)]); + // Agents deploy flattened, so the team scan names them by bare stem. Their + // `/` is what names ONE of them: without it a machine holding no + // placement record could only type the stem, which removes that agent from + // every namespace (#649 review). + const qualified = (item: { name: string; namespace?: string }): string => ( + type === 'agents' && item.namespace ? `${item.namespace}/${item.name}` : item.name + ); + const allNames = new Set([...teamItems.map(qualified), ...localItems.map((i) => i.name)]); const found: string[] = []; const notFound: string[] = []; + let ambiguous = false; for (const name of names) { + // The placement record is consulted FIRST. A resource this machine placed + // in a namespace is published as `/`, while the author's local + // copy — and so the name they type — is the bare one; and the LOCAL scan + // contributes that bare name whenever their copy has edits. Taking the + // bare match would delete the local copy, report success, and leave the + // namespaced team file published (#649 review). + const published = await handler.publishedNameFor(name, localConfig); + if (published) { + // Not cross-checked against `allNames`: `publishedNameFor` has already + // proved the file is in the team repo, and the scans do not all spell a + // namespaced resource the same way — `scanTeamForPull` reports an agent + // by its bare stem, so requiring membership here silently fell back to + // the bare name and removed that agent from EVERY namespace (#649 review). + log.info(`${name} was published as ${published}`); + found.push(published); + continue; + } + if (type === 'agents' && !name.includes('/')) { + const sameStem = teamItems.filter((item) => item.name === name).map(qualified); + if (sameStem.length > 1) { + log.error( + `"${name}" names agents in several places (${sameStem.join(', ')}), and removing it would take ` + + `all of them. Name the one to remove, e.g. \`teamai remove agents ${sameStem[0]}\`.`, + ); + ambiguous = true; + continue; + } + // The one team agent of that stem, named exactly, so the tombstone names + // it and not the stem every other namespace shares. + const only = sameStem[0]; + if (only && only !== name) { + log.info(`${name} is ${only}`); + found.push(only); + continue; + } + } if (allNames.has(name)) { found.push(name); } else { @@ -85,6 +163,14 @@ async function removeCore( log.warn(`Not found (skipping): ${notFound.join(', ')}`); } + // Stop the whole run rather than remove the other names alone: the user + // asked for all of them, and has to say which of the ambiguous ones. + if (ambiguous) { + log.error('Nothing was removed.'); + process.exitCode = 1; + return; + } + if (found.length === 0) { log.error('No matching resources found to remove'); log.info(`Available ${type}:`); @@ -197,6 +283,11 @@ async function removeCore( if (type === 'rules') { state.pushedRules = state.pushedRules.filter((r) => !found.includes(r)); } + // Placement records are NOT dropped here: the removal exists only on its push + // branch until the PR merges, and a retry meanwhile must still resolve the + // bare name to the one namespaced file — for an agent, the bare stem removes + // it from every namespace (#649 review). `reconcilePlacementRecords` drops a + // record once the default branch no longer has its file. // `wiki` is not tracked in pushedX state; nothing to clean here. await saveStateForScope(state, localConfig); } diff --git a/src/resources/agents.ts b/src/resources/agents.ts index 9c1883ec..c551e083 100644 --- a/src/resources/agents.ts +++ b/src/resources/agents.ts @@ -1,7 +1,7 @@ import path from 'node:path'; import { isDeepStrictEqual } from 'node:util'; import { parse as parseYaml } from 'yaml'; -import { isToolInstalledForConfig, ResourceHandler } from './base.js'; +import { isToolInstalledForConfig, ResourceHandler, type ScanForPushOptions } from './base.js'; import type { ResourceItem, ResourceItemStatus, DeliveryTarget, TeamaiConfig, LocalConfig } from '../types.js'; import { listFiles, listDirs, pathExists, copyFile, ensureDir, remove, fileContentEqual, getFileMtime, writeFile, readFileSafe } from '../utils/fs.js'; import { log } from '../utils/logger.js'; @@ -10,6 +10,9 @@ import { BUILTIN_AGENT_NAMES } from '../builtin-agents.js'; import { resolveResourceNamespaces } from '../resource-namespaces.js'; import { isSafeNamespaceSegment } from '../projects.js'; import { assertWithinRoot } from '../utils/path-safety.js'; +import { loadStateForScope } from '../config.js'; +import { placedResourcePath } from '../push-namespaces.js'; +import { getFileContentAtRev, getFileContentWhenAdded, isPastVersionOf } from '../utils/git.js'; import { parseAgentYaml, serializeAgentYaml, @@ -38,8 +41,22 @@ export interface AgentResourceItem extends ResourceItem { mergedSpec?: AgentSpec; /** Human-readable reason to skip this item during pushItem (merge failed). */ skipReason?: string; + /** + * Set when the scan could not find a team source this directory may write to, + * so the agent needs a destination named before it can go anywhere. `push` + * reads it to decide whether a `--project` whose agents axis is empty is a + * problem for THIS run: a modified agent already in a namespace needs no + * placement and must not be blocked by it (#649 review). + */ + needsDestination?: boolean; /** True when item came from a legacy .md team-repo file (older format). */ legacy?: boolean; + /** + * Team-relative path of the file this push retires: the recorded canonical + * file when the author renamed their source from `.md` to `.yaml` or back. + * `pushItem` deletes it and `push` stages the deletion and moves the record. + */ + supersedes?: string; } /** @@ -51,9 +68,86 @@ export interface AgentResourceItem extends ResourceItem { * * Tools without an `agents` path in toolPaths are silently skipped. */ +/** + * The agents this directory should hold: the ones in an active namespace, plus + * any this machine published into a namespace it does not activate — push lets + * the author keep editing those through the placement record, so pull has to + * deliver them or the local copy never tracks the team file (#649). + * + * A stem an ACTIVE namespace already claims is left alone: agents deploy + * flattened, so two would collide on one filename, and the active one is the + * agent deployed here. + * + * Delivery and revocation both resolve through this. They must agree — when + * only delivery knew about the record, `pull` wrote the agent and the + * revocation pass deleted it again in the same run. + */ +export function selectAgentsForDirectory( + agents: ResourceItem[], + activeNamespaces: string[] | null, + placedAgents?: Record, +): ResourceItem[] { + if (activeNamespaces === null) return agents; + + const active = agents.filter( + (agent) => !agent.namespace || activeNamespaces.includes(agent.namespace), + ); + if (!placedAgents) return active; + + const claimed = new Set(active.map((agent) => agent.name)); + const recovered = agents.filter((agent) => agent.namespace + && !claimed.has(agent.name) + && placedResourcePath(placedAgents, 'agents', agent.name) + === `agents/${agent.namespace}/${path.basename(agent.relativePath)}`); + return recovered.length > 0 ? [...active, ...recovered] : active; +} + export class AgentsHandler extends ResourceHandler { readonly type = 'agents' as const; + /** + * The tombstones as flattened local copies see them. Removing `fe/vr` + * tombstones only `fe/vr`, but every member holds that agent as `/vr`, + * so the tombstone alone never reaches their copy, and the next push reads it + * as a new agent and republishes it (#649 review). `vr` counts as removed + * here only when both hold: + * - `fe/vr` could have been delivered HERE: `fe` is active, or nothing is + * filtered, or this machine placed `vr` in `fe` (its record, or the one + * reconcile retired when the file was deleted). On a member who never + * had `fe`, a `vr` is their own agent, and deleting it — or refusing to + * push it — takes something that was never the team's. + * - THIS directory is not meant to hold another agent of that stem, by the + * same selection pull delivers with (`selectAgentsForDirectory`): while a + * `be/vr` is delivered here, the copy is be/vr's, and suppressing it is + * what round 8 of the review ruled out. + */ + async removedStems(teamConfig: TeamaiConfig, localConfig: LocalConfig): Promise> { + const tombstones = await this.readTombstones(localConfig); + const removed = new Set(tombstones); + if (![...tombstones].some((tombstone) => tombstone.includes('/'))) return removed; + const activeNamespaces = (await resolveResourceNamespaces(localConfig))?.activeNamespaces.agents ?? null; + const { placedAgents, retiredPlacedAgents } = await loadStateForScope(localConfig); + // This machine's record — live, or dropped when the team deleted the file — + // says its flattened copy stood for the agent in that namespace. + const placedIn = (stem: string): string | undefined => ( + placedResourcePath(placedAgents, 'agents', stem) ?? placedResourcePath(retiredPlacedAgents, 'agents', stem) + )?.split('/')[1]; + const desired = new Set(selectAgentsForDirectory( + await this.scanTeamForPull(teamConfig, localConfig), + activeNamespaces, + placedAgents, + ).map((agent) => agent.name)); + for (const tombstone of tombstones) { + const segments = tombstone.split('/'); + if (segments.length !== 2) continue; + const [namespace, stem] = segments as [string, string]; + const deliveredHere = activeNamespaces === null || activeNamespaces.includes(namespace) + || placedIn(stem) === namespace; + if (deliveredHere && !desired.has(stem)) removed.add(stem); + } + return removed; + } + /** * Scan local AI tool agents/ directories for files that are new or modified * compared to the team repo. Groups by agent name stem across all tools. @@ -61,9 +155,14 @@ export class AgentsHandler extends ResourceHandler { * New format (.yaml in team repo): attempts multi-tool reverse + merge. * Built-in CLI agents are excluded from push. */ - async scanLocalForPush(teamConfig: TeamaiConfig, localConfig: LocalConfig): Promise { + async scanLocalForPush( + teamConfig: TeamaiConfig, + localConfig: LocalConfig, + options?: ScanForPushOptions, + ): Promise { + const requestedNamespace = options?.namespace; const teamAgentsDir = path.join(localConfig.repo.localPath, 'agents'); - const tombstones = await this.readTombstones(localConfig); + const tombstones = await this.removedStems(teamConfig, localConfig); // Single-repo mode: users drop canonical agent files straight into the repo's // own .teamai/agents/ (.yaml, or legacy .md) rather than authoring // them in a tool's agents dir. Those are ALREADY in team-repo format, so we @@ -71,6 +170,17 @@ export class AgentsHandler extends ResourceHandler { // origin/ checkout so only genuine additions/edits surface. These win // over the reverse-parse path below on name conflicts (explicit canonical is // authoritative). Active tree = projectRoot (kept intact by withKnowledgeWorktree). + // An agent this machine published with --role/--project lives in a + // namespace this directory need not have activated. Without the record it + // would read as "no active source" and the author could never edit the + // agent they just created (#649 review). + const { placedAgents, lastPullRev, pendingPushes } = await loadStateForScope(localConfig); + // Agents this machine placed in a namespace and has awaiting review: the + // open PR is their destination, not "no active source". + const pendingPlacedAgents = new Set((pendingPushes ?? []).flatMap((entry) => entry.items) + .filter((item) => item.type === 'agents' && item.relativePath.split('/').length === 3) + .map((item) => item.name)); + const directItems: AgentResourceItem[] = []; const directStems = new Set(); if (isSelfMode(localConfig) && localConfig.projectRoot) { @@ -87,17 +197,55 @@ export class AgentsHandler extends ResourceHandler { if (BUILTIN_AGENT_NAMES.has(stem)) continue; const activePath = path.join(dir, file); - const basePath = path.join(localConfig.repo.localPath, relDir, file); - const baseExists = await pathExists(basePath); - if (baseExists && await fileContentEqual(activePath, basePath)) continue; // unchanged + let teamRelPath = `${relDir}/${file}`; + let basePath = path.join(localConfig.repo.localPath, teamRelPath); + let baseExists = await pathExists(basePath); + let supersedes: string | undefined; + // A canonical source authored at .teamai/agents/ root and placed + // under agents// has nothing at agents/.yaml, so without + // the record it reads as brand new — and the collision check then + // refuses the very agent this machine published (#649 review). + if (!baseExists && !namespace) { + const placed = placedResourcePath(placedAgents, 'agents', stem); + if (placed && await pathExists(path.join(localConfig.repo.localPath, placed))) { + // The destination keeps the record's directory but THIS file's + // extension: `pushItem` writes by the source's extension, so a + // relativePath still naming the recorded `.md` would stage a + // path nothing was written to, and leave that `.md` behind. + teamRelPath = `${path.posix.dirname(placed)}/${file}`; + if (teamRelPath !== placed) supersedes = placed; + basePath = path.join(localConfig.repo.localPath, placed); + baseExists = true; + // Nothing refreshes this root file after placement — pull + // deploys to tool dirs, and the pre-push sync covers those only + // — so `lastPullRev` says nothing about it. A copy equal to an + // OLDER version of the team file is one nobody edited, and + // pushing it would revert whoever changed the file since + // (#649 review). + if (!supersedes && !await fileContentEqual(activePath, basePath) + && await isPastVersionOf(localConfig.repo.localPath, activePath, placed)) { + directItems.push({ name: stem, type: 'agents', sourcePath: activePath, + relativePath: placed, status: 'modified', namespace: placed.split('/')[1], + skipReason: `${path.relative(localConfig.projectRoot, activePath)} is an older version of ${placed}, ` + + 'which has changed on the team since. Copy the current file over it (or delete it) before editing.' }); + directStems.add(stem); + continue; + } + } + } + if (baseExists && !supersedes && await fileContentEqual(activePath, basePath)) continue; // unchanged directItems.push({ name: stem, type: 'agents', sourcePath: activePath, - relativePath: `${relDir}/${file}`, + relativePath: teamRelPath, status: (baseExists ? 'modified' : 'new') as ResourceItemStatus, legacy: isMd, + ...(baseExists && teamRelPath !== `${relDir}/${file}` + ? { namespace: teamRelPath.split('/')[1] } + : {}), + ...(supersedes ? { supersedes } : {}), }); directStems.add(stem); } @@ -110,6 +258,11 @@ export class AgentsHandler extends ResourceHandler { for (const [tool, toolPath] of Object.entries(scopedToolPaths(teamConfig, localConfig))) { if (!toolPath.agents) continue; + // An excluded tool is neither written nor cleaned by teamai, so what it + // holds is not a source either: `removeItem` leaves its copy behind, and + // a namespaced removal tombstones only `/`, so reading that + // copy republished the agent just removed (#649 review). + if (isAgentExcluded(localConfig, tool)) continue; const baseDir = resolveToolBaseDir(tool, localConfig); const agentsDir = path.join(baseDir, toolPath.agents); if (!await pathExists(agentsDir)) continue; @@ -146,18 +299,56 @@ export class AgentsHandler extends ResourceHandler { // A modified agent must be written back where it lives, so its namespace // directory is carried into `relativePath` below. const sources = await findTeamAgentFiles(teamAgentsDir, stem); - const candidates = sources.filter( - (file) => activeNamespaces === null || !file.namespace || activeNamespaces.includes(file.namespace), + const placedNamespace = placedResourcePath(placedAgents, 'agents', stem)?.split('/')[1]; + // Which team file this local agent is a copy of, in the order pull + // delivers them: an active source (the shared root counts) is what was + // deployed here, then the one this machine's record names. Only when + // neither exists does an explicit --role/--project decide — the agent + // then needs a destination, and a same-stem copy in some other inactive + // namespace is a different agent that must not block it (#649 review). + // Letting the flag win outright compared an active agent's rendering + // with the requested namespace's file and overwrote it without an edit. + const active = sources.filter( + (file) => activeNamespaces === null || !file.namespace + || activeNamespaces.includes(file.namespace), ); + const recorded = placedNamespace ? sources.filter((file) => file.namespace === placedNamespace) : []; + // Two active sources stay ambiguous even under a flag: the flattened file + // cannot say which one it was delivered from, and picking the requested + // one compared the other's untouched copy with it (#649 review). + const candidates = active.length > 0 ? active : recorded; if (candidates.length > 1) { items.push({ name: stem, type: 'agents', sourcePath: teamAgentsDir, relativePath: `agents/${stem}.yaml`, status: 'modified', skipReason: `Ambiguous agent "${stem}": multiple active sources (${candidates.map((file) => file.path).join(', ')}). Give active agents unique names before pushing.` }); continue; } - if (sources.length > 0 && candidates.length === 0) { + // A shared-root copy is always active, so it is a candidate above and + // this local file is an edit of it: no namespaced second copy is made. + // + // With a destination named that already holds this stem — never + // delivered here, so this local file is not a copy of it — the agent is + // new there and would land on somebody else's, which rules already + // refuse (#649 review). + const inRequested = requestedNamespace + ? sources.find((file) => file.namespace === requestedNamespace) + : undefined; + if (candidates.length === 0 && inRequested) { + const taken = `agents/${requestedNamespace}/${stem}${inRequested.ext}`; + items.push({ name: stem, type: 'agents', sourcePath: teamAgentsDir, + relativePath: taken, status: 'new', namespace: requestedNamespace, + skipReason: `Agent "${stem}" cannot be placed: ${taken} already exists in the team repo and was never ` + + 'delivered here, so this local copy is not an edit of it and pushing would overwrite it. ' + + 'Activate that namespace and pull to edit the existing one, rename yours, or pick another namespace with --role .' }); + continue; + } + // With no destination named, a stem that exists only in namespaces this + // directory has not activated is not ours to edit — unless this machine + // has it awaiting review as a placement, whose open PR it goes back to. + if (!requestedNamespace && sources.length > 0 && candidates.length === 0 && !pendingPlacedAgents.has(stem)) { items.push({ name: stem, type: 'agents', sourcePath: teamAgentsDir, relativePath: `agents/${stem}.yaml`, status: 'modified', + needsDestination: true, skipReason: `Agent "${stem}" has no active source. Activate its role or project before pushing local edits.` }); continue; } @@ -184,7 +375,7 @@ export class AgentsHandler extends ResourceHandler { // Compare like with like: native files against a native rendering of // the canonical YAML. Unchanged/untargeted copies must not join a merge. for (const [tool, filePath] of toolFiles) { - if (!isKnownTool(tool) || isAgentExcluded(localConfig, tool) + if (!isKnownTool(tool) || (canonicalSpec.targets && !canonicalSpec.targets.includes(tool))) { toolFiles.delete(tool); continue; @@ -219,6 +410,18 @@ export class AgentsHandler extends ResourceHandler { if (!hasChange) continue; + // Only a copy that differs is worth holding: an unchanged one — the + // author's own merged edit included — has nothing to overwrite with. + if (located && active.length === 0 && recorded.length > 0) { + const recordedPath = `agents/${located.namespace}/${stem}${located.ext}`; + if (await recordedAgentMovedOn(localConfig.repo.localPath, recordedPath, lastPullRev)) { + items.push({ name: stem, type: 'agents', sourcePath: teamAgentsDir, + relativePath: recordedPath, status: 'modified', namespace: located.namespace, + skipReason: staleRecordedAgentReason(stem, recordedPath) }); + continue; + } + } + const status: ResourceItemStatus = (hasTeamYaml || hasTeamMd) ? 'modified' : 'new'; // Determine representative source path (prefer highest mtime) @@ -273,6 +476,10 @@ export class AgentsHandler extends ResourceHandler { relativePath: `${teamDir}/${stem}.yaml`, status, mergedSpec: mergeResult.spec, + // Carried explicitly: an open PR records this item, and a record + // with no namespace reads as "shared root" to everything that + // later compares destinations (#649 review). + ...(located?.namespace ? { namespace: located.namespace } : {}), }); continue; } @@ -286,6 +493,7 @@ export class AgentsHandler extends ResourceHandler { relativePath: `${teamDir}/${stem}.md`, status, skipReason, + ...(located?.namespace ? { namespace: located.namespace } : {}), }); } @@ -372,6 +580,14 @@ export class AgentsHandler extends ResourceHandler { await ensureDir(path.dirname(dest)); await copyFile(item.sourcePath, dest); } + // The recorded file under the other extension is the same agent; two + // canonical files for one stem is what pull reports as a collision. + if (agentItem.supersedes) { + const retired = path.resolve(localConfig.repo.localPath, agentItem.supersedes); + assertWithinRoot(path.join(localConfig.repo.localPath, 'agents'), retired, + `Invalid superseded agent path outside team repo agents directory: ${agentItem.supersedes}`); + if (retired !== dest) await remove(retired); + } log.debug(`Copied agent ${item.name} → team repo (${ext} verbatim)`); } @@ -426,19 +642,82 @@ export class AgentsHandler extends ResourceHandler { * Tries both .yaml and .md extensions in the team repo. * Records a tombstone to prevent re-push. */ + /** + * `vr` when push placed it at `agents/fe/vr.yaml`: the author's local copy is + * at the tool's agents root, so the name they type is the bare one. Without + * this, `remove` matched that bare name and deleted every `vr` in every + * namespace — other people's agents included (#649 review). + */ + async publishedNameFor(name: string, localConfig: LocalConfig): Promise { + const placed = placedResourcePath( + (await loadStateForScope(localConfig)).placedAgents, 'agents', name, + ); + if (!placed) return null; + if (!await pathExists(path.join(localConfig.repo.localPath, placed))) return null; + return placed.slice('agents/'.length).replace(/\.(yaml|md)$/, ''); + } + + /** + * Remove an agent from the team repo and all local AI tool agents/ directories. + * + * `name` is either a bare stem, which still means "this agent wherever it + * lives", or the published `/` that `publishedNameFor` resolved — + * and that one names exactly one file, so only it is removed. The local sweep + * covers both spellings: a placed agent leaves the author's copy at the + * agents root while every other member receives it under `/`. + */ async removeItem(name: string, teamConfig: TeamaiConfig, localConfig: LocalConfig): Promise { const removed: string[] = []; const teamAgentsDir = path.join(localConfig.repo.localPath, 'agents'); + const stem = path.basename(name); - // Root or agents//, both extensions, every namespace the stem lives in. + // Both extensions, and every namespace a BARE stem lives in. A published + // `/` resolves to exactly one file, because the root directory is + // one of the directories probed and `/.yaml` sits under it — so + // naming a namespace leaves the same stem in other namespaces alone. for (const located of await findTeamAgentFiles(teamAgentsDir, name)) { await remove(located.path); removed.push(located.path); } + // Only the name given. Agents deploy FLATTENED — `~/.claude/agents/` — + // so a bare-stem tombstone is read globally by both the push scan and the + // post-pull cleanup: removing `fe/vr` would suppress and delete `be/vr` the + // moment that namespace became active (#649 review). Rules can afford the + // bare spelling because they keep their namespace directory locally. await this.addTombstone(name, localConfig); + // The author's own copy IS flattened, though, and it is theirs only when + // this machine's record says the file just removed is where push put it. + const localNames = new Set([name]); + if (stem !== name) { + const placed = placedResourcePath( + (await loadStateForScope(localConfig)).placedAgents, 'agents', stem, + ); + if (placed === `agents/${name}.yaml` || placed === `agents/${name}.md`) { + localNames.add(stem); + } + } + + // Single-repo mode: the canonical source lives in the repo's own + // .teamai/agents/, and the scan picks it up directly. Leaving it behind + // republishes the agent on the next push, and a bare-stem tombstone cannot + // stop that without suppressing the same stem in every other namespace, + // because agents deploy flattened (#649 review). + if (isSelfMode(localConfig) && localConfig.projectRoot) { + const activeAgentsDir = path.join(localConfig.projectRoot, '.teamai', 'agents'); + for (const localName of localNames) { + for (const ext of ['.yaml', '.md'] as const) { + const filePath = path.join(activeAgentsDir, `${localName}${ext}`); + if (await pathExists(filePath)) { + await remove(filePath); + removed.push(filePath); + } + } + } + } + for (const [tool, toolPath] of Object.entries(scopedToolPaths(teamConfig, localConfig))) { if (!toolPath.agents) continue; // A tool the member excluded is not ours to write to, so it is not ours @@ -446,12 +725,14 @@ export class AgentsHandler extends ResourceHandler { if (isAgentExcluded(localConfig, tool)) continue; const baseDir = resolveToolBaseDir(tool, localConfig); // Try every native agent extension: the render format varies per tool. - for (const ext of AGENT_FILE_EXTENSIONS) { - const filePath = path.join(baseDir, toolPath.agents, `${name}${ext}`); - if (await pathExists(filePath)) { - await remove(filePath); - removed.push(filePath); - log.debug(`Removed agent ${name} from ${tool}`); + for (const localName of localNames) { + for (const ext of AGENT_FILE_EXTENSIONS) { + const filePath = path.join(baseDir, toolPath.agents, `${localName}${ext}`); + if (await pathExists(filePath)) { + await remove(filePath); + removed.push(filePath); + log.debug(`Removed agent ${localName} from ${tool}`); + } } } } @@ -474,9 +755,16 @@ export class AgentsHandler extends ResourceHandler { activeNamespaces: string[], ): Promise { const items = await this.scanTeamForPull(teamConfig, localConfig); - const isActive = (item: AgentResourceItem): boolean => !item.namespace || activeNamespaces.includes(item.namespace); - const active = items.filter(isActive); - const inactive = items.filter((item) => !isActive(item) && !BUILTIN_AGENT_NAMES.has(item.name)); + // The same selection `pull` delivers with, records included: revoking an + // agent this machine published would delete the copy pull had just written. + const { placedAgents } = await loadStateForScope(localConfig); + const kept = new Set( + selectAgentsForDirectory(items, activeNamespaces, placedAgents).map((item) => item.relativePath), + ); + const active = items.filter((item) => kept.has(item.relativePath)); + const inactive = items.filter( + (item) => !kept.has(item.relativePath) && !BUILTIN_AGENT_NAMES.has(item.name), + ); if (inactive.length === 0) return; for (const { tool, dir: destDir } of await this.agentToolDirs(teamConfig, localConfig)) { @@ -607,6 +895,29 @@ export async function listTeamAgentDirs(teamAgentsDir: string): Promise { + const current = await readFileSafe(path.join(repoPath, relPath)); + if (current === null) return false; + const baseline = (lastPullRev ? await getFileContentAtRev(repoPath, lastPullRev, `./${relPath}`) : null) + ?? await getFileContentWhenAdded(repoPath, relPath); + return baseline !== null && baseline.toString('utf-8') !== current; +} + +function staleRecordedAgentReason(stem: string, relPath: string): string { + return `Agent "${stem}" (${relPath}) changed on the team since this machine last synced it, ` + + 'so pushing your copy would overwrite that change. `teamai pull` replaces your copy with the team version, ' + + 'so first copy your edit aside, then pull, reapply it, and push again.'; +} + /** * Every team file for a stem, root first, then namespaces in directory order, * `.yaml` before `.md` in each. A stem may legitimately live in several diff --git a/src/resources/base.ts b/src/resources/base.ts index 0280d997..dec681ae 100644 --- a/src/resources/base.ts +++ b/src/resources/base.ts @@ -45,6 +45,18 @@ export async function isToolInstalledForConfig( * Abstract base class for resource handlers. * Each resource type (skills, rules, docs, env, agents, hooks, mcp) implements this. */ +/** + * What `push` knows before it scans. Only the destination an explicit + * `--role`/`--project` names, and only agents read it: their scan has to + * decide which team file a local edit is an edit OF, and that answer changes + * when the user has named a namespace (see `AgentsHandler.scanLocalForPush`). + * Rules and skills are placed after selection, so their scan needs nothing. + */ +export interface ScanForPushOptions { + /** The namespace `--role ` / `--project ` resolved to, if any. */ + namespace?: string; +} + export abstract class ResourceHandler { abstract readonly type: ResourceType; @@ -55,6 +67,7 @@ export abstract class ResourceHandler { abstract scanLocalForPush( teamConfig: TeamaiConfig, localConfig: LocalConfig, + options?: ScanForPushOptions, ): Promise; /** @@ -94,6 +107,18 @@ export abstract class ResourceHandler { localConfig: LocalConfig, ): Promise; + /** + * The name this resource is published under, when the user typed a different + * one. `remove` matches what the user types against the team repo, where a + * placed resource lives at `//`; the author's local copy is + * still at the resource root, so they know it by its bare name and `remove` + * would answer "not found". Handlers that keep a placement record resolve it + * here. Returns null when there is nothing to translate. + */ + async publishedNameFor(_name: string, _localConfig: LocalConfig): Promise { + return null; + } + /** * Where `item` lands for each tool that can receive it on this machine. * diff --git a/src/resources/rules.ts b/src/resources/rules.ts index bd4a0fdf..88eb9bd3 100644 --- a/src/resources/rules.ts +++ b/src/resources/rules.ts @@ -3,7 +3,7 @@ import { isToolInstalledForConfig, ResourceHandler } from './base.js'; import type { ResourceItem, ResourceItemStatus, DeliveryTarget, TeamaiConfig, LocalConfig } from '../types.js'; import { listFilesRecursive, pathExists, copyFile, ensureDir, remove, fileContentEqual, getFileMtime, listDirs, readFileSafe, writeFile } from '../utils/fs.js'; import { log } from '../utils/logger.js'; -import { TEAMAI_RULES_START, TEAMAI_RULES_END, resolveBaseDir, resolveToolBaseDir, isAgentExcluded, scopedToolPaths } from '../types.js'; +import { TEAMAI_RULES_START, TEAMAI_RULES_END, resolveBaseDir, resolveToolBaseDir, isAgentExcluded, scopedToolPaths, SELF_KNOWLEDGE_SCAN_KEY } from '../types.js'; import { EXCLUDED_RULE_NAMES } from '../builtin-rules.js'; import { teamRuleToCursorMdc, mergeCursorBodyIntoTeamMd, cursorMdcBodyEqualsTeamMd } from './cursor-mdc.js'; import { @@ -12,6 +12,9 @@ import { teamRuleToCopilotInstructions, } from './copilot-instructions.js'; import { assertWithinRoot } from '../utils/path-safety.js'; +import { loadStateForScope } from '../config.js'; +import { placedResourcePath } from '../push-namespaces.js'; +import { isPastVersionOf } from '../utils/git.js'; import { ruleFileExtensionForTool, ruleStemFromFilename, @@ -41,8 +44,20 @@ export class RulesHandler extends ResourceHandler { // Read tombstones to skip previously deleted resources const tombstones = await this.readTombstones(localConfig); + // A rule placed under rules// on an earlier push is still authored at + // the tool's rules root, so matching on the full path alone would read it + // as brand new and send a second copy to the shared root — where it would + // reach the whole team (issue #649). state.json records where this machine + // placed each root-level rule, and that record — not the basename — maps + // the local copy back to its team file. A namespaced team rule is pulled + // into a namespaced local directory, so a root-level local rule that only + // shares a basename with one, and has no record, is unrelated and stays new. + const placedRules = (await loadStateForScope(localConfig)).placedRules; + // Collect the best candidate for each rule name across all tool directories - const candidates = new Map(); + const candidates = new Map(); // One read per team rule, shared across every tool dir that compares against it. const teamContentCache = new Map(); const readTeamRule = async (filePath: string): Promise => { @@ -57,6 +72,12 @@ export class RulesHandler extends ResourceHandler { for (const [tool, toolPath] of Object.entries(scopedToolPaths(teamConfig, localConfig))) { const rulesPath = toolPath.rules; if (!rulesPath) continue; + // Not written or cleaned by teamai, so not a source either: `removeItem` + // leaves an excluded tool's copy behind, and read here it would republish + // the rule just removed (#649 review). The single-repo scan source is not + // a tool, and `enabledAgents` — which single-repo init always writes — + // never lists it. + if (tool !== SELF_KNOWLEDGE_SCAN_KEY && isAgentExcluded(localConfig, tool)) continue; const rulesDir = path.join(resolveToolBaseDir(tool, localConfig), rulesPath); if (!await pathExists(rulesDir)) continue; @@ -75,7 +96,17 @@ export class RulesHandler extends ResourceHandler { const localFilePath = path.join(rulesDir, file); // Team repo always stores `.md`, keyed by rule name. - const teamFileName = `${name}.md`; + let teamFileName = `${name}.md`; + // The record comes first. A shared-root rule that appears later with + // the same basename belongs to whoever added it, and mapping the + // author's copy onto it would push their content over that rule. A + // record whose team file is gone (rule removed, namespace renamed) no + // longer proves anything, so the rule is new again. + const placed = placedResourcePath(placedRules, 'rules', name); + const placedName = placed?.slice('rules/'.length); + if (placedName && teamRules.has(placedName)) teamFileName = placedName; + + const teamRelPath = `rules/${teamFileName}`; if (teamRules.has(teamFileName)) { // File exists in team repo — check if content differs @@ -93,12 +124,26 @@ export class RulesHandler extends ResourceHandler { ? copilotInstructionsBodyEqualsTeamMd(localRule, teamRule) : await fileContentEqual(localFilePath, teamFilePath); if (equal) continue; // This tool dir's copy is identical, skip + // Single-repo mode: nothing refreshes the author's `.teamai/rules` + // copy of a rule placed under a namespace — pull deploys to tool + // dirs and the pre-push sync covers those — so a copy equal to an + // OLDER version of the team file is one nobody edited, and pushing + // it would revert whoever changed the rule since (#649 review). + if (tool === SELF_KNOWLEDGE_SCAN_KEY && teamFileName === placedName + && await isPastVersionOf(localConfig.repo.localPath, localFilePath, teamRelPath)) { + log.warn( + `[rules] Skipped ${name}: ${path.relative(resolveToolBaseDir(tool, localConfig), localFilePath)} is an ` + + `older version of ${teamRelPath}, which has changed on the team since. ` + + 'Copy the current file over it (or delete it) before editing.', + ); + continue; + } // Content differs — candidate for "modified" const mtime = await getFileMtime(localFilePath); const existing = candidates.get(name); if (!existing || mtime > existing.mtime) { - candidates.set(name, { sourcePath: localFilePath, mtime, status: 'modified' }); + candidates.set(name, { sourcePath: localFilePath, mtime, status: 'modified', teamRelPath }); } } else { // File does not exist in team repo — candidate for "new". @@ -110,12 +155,12 @@ export class RulesHandler extends ResourceHandler { const existing = candidates.get(name); if (!existing) { const mtime = await getFileMtime(localFilePath); - candidates.set(name, { sourcePath: localFilePath, mtime, status: 'new' }); + candidates.set(name, { sourcePath: localFilePath, mtime, status: 'new', teamRelPath }); } else if (existing.status === 'new') { // Multiple tool dirs have the same new file — pick latest mtime const mtime = await getFileMtime(localFilePath); if (mtime > existing.mtime) { - candidates.set(name, { sourcePath: localFilePath, mtime, status: 'new' }); + candidates.set(name, { sourcePath: localFilePath, mtime, status: 'new', teamRelPath }); } } } @@ -125,12 +170,17 @@ export class RulesHandler extends ResourceHandler { // Convert candidates map to items array const items: ResourceItem[] = []; for (const [name, candidate] of candidates) { + // `rules//.md` is namespaced; `rules/.md` is shared. State + // it on the item so an open PR can reuse the destination, the way skills do. + const segments = candidate.teamRelPath.split('/'); + const namespace = segments.length > 2 ? segments[1] : undefined; items.push({ name, type: 'rules', sourcePath: candidate.sourcePath, - relativePath: `rules/${name}.md`, + relativePath: candidate.teamRelPath, status: candidate.status, + ...(namespace ? { namespace } : {}), }); } @@ -202,6 +252,7 @@ export class RulesHandler extends ResourceHandler { // missing file is. Only a comparison against the render can see that, and // the render belongs here rather than in a second copy inside `doctor`. const source = await readFileSafe(item.sourcePath); + const localName = await this.localNameFor(item.name, localConfig); const targets: DeliveryTarget[] = []; for (const [tool, toolPath] of Object.entries(scopedToolPaths(teamConfig, localConfig))) { if (isAgentExcluded(localConfig, tool)) continue; @@ -214,20 +265,48 @@ export class RulesHandler extends ResourceHandler { } const destDir = path.join(resolveToolBaseDir(tool, localConfig), toolPath.rules); + const ext = ruleFileExtensionForTool(tool); targets.push({ tool, - dest: path.join(destDir, `${item.name}${ruleFileExtensionForTool(tool)}`), + dest: path.join(destDir, `${localName}${ext}`), content: source === null ? undefined : renderRuleForTool(tool, source), + ...(localName !== item.name + ? { supersedes: path.join(destDir, `${item.name}${ext}`) } + : {}), }); } return targets; } + /** + * The name a delivered rule has in a tool's rules directory. It is the + * team name — `fe-know/my-rule` lands at `rules/fe-know/my-rule.*` — except + * for a rule THIS machine placed: push left the author's copy at the rules + * root under the bare name, and that copy is the one the scanner and the + * pre-push sync read, so delivery updates it rather than writing a second + * copy beside it that a tool loading rules recursively would apply as well + * (#649 review). + */ + private async localNameFor(teamName: string, localConfig: LocalConfig): Promise { + const bareName = path.basename(teamName); + if (bareName === teamName) return teamName; + const placed = placedResourcePath( + (await loadStateForScope(localConfig)).placedRules, 'rules', bareName, + ); + if (placed !== `rules/${teamName}.md`) return teamName; + // A shared-root rule of the same name owns the root path in every tool + // dir; delivering both there would leave whichever wrote last. The + // reconcile pass withdraws the record for this case, but delivery must + // not depend on having run after it. + if (await pathExists(path.join(localConfig.repo.localPath, 'rules', `${bareName}.md`))) return teamName; + return bareName; + } + /** * Pull a single rule file to all configured AI tool rules/ directories. */ async pullItem(item: ResourceItem, teamConfig: TeamaiConfig, localConfig: LocalConfig): Promise { - for (const { tool, dest, content } of await this.deliveryTargets(teamConfig, localConfig, item)) { + for (const { tool, dest, content, supersedes } of await this.deliveryTargets(teamConfig, localConfig, item)) { const destDir = path.dirname(dest); try { if (content === undefined) { @@ -238,8 +317,11 @@ export class RulesHandler extends ResourceHandler { await writeFile(dest, content); // Drop the `.md` copy left by an older layout; a tool that reads a // derived extension does not read it, and it would outlive the rule. - const legacyCopy = path.join(destDir, `${item.name}.md`); + const legacyCopy = path.join(destDir, `${path.basename(dest, path.extname(dest))}.md`); if (dest !== legacyCopy) await remove(legacyCopy); + // The namespaced copy an earlier pull wrote beside the author's root + // copy: the same rule twice, for a tool that loads rules recursively. + if (supersedes) await remove(supersedes); log.debug(`Synced rule ${item.name} → ${tool}`); } catch (e) { log.warn(`Failed to sync rule ${item.name} to ${tool}: ${(e as Error).message}`); @@ -247,8 +329,28 @@ export class RulesHandler extends ResourceHandler { } } + /** + * `my-rule` when push placed it at `rules/fe-know/my-rule.md`: the author + * types the name their local copy has, which is the bare one. + */ + async publishedNameFor(name: string, localConfig: LocalConfig): Promise { + const placed = placedResourcePath( + (await loadStateForScope(localConfig)).placedRules, 'rules', name, + ); + if (!placed) return null; + if (!await pathExists(path.join(localConfig.repo.localPath, placed))) return null; + return placed.slice('rules/'.length, -'.md'.length); + } + /** * Remove a rule from the team repo and all local AI tool rules/ directories. + * + * `name` may be the published one (`fe-know/my-rule`) or the bare one the + * author's own copy carries (`my-rule`) — `remove` resolves the first through + * `publishedNameFor`, so both reach the same team file. The local sweep below + * covers both spellings, because a rule placed in a namespace leaves the + * author's copy at the rules root while every other member receives it at + * `rules//`. */ async removeItem(name: string, teamConfig: TeamaiConfig, localConfig: LocalConfig): Promise { const removed: string[] = []; @@ -260,7 +362,26 @@ export class RulesHandler extends ResourceHandler { removed.push(teamFile); } - // Record tombstone so the resource won't be re-pushed + // The author's own copy is at the rules root under the bare name, whatever + // namespace the team file ended up in. Leaving it behind re-publishes the + // rule on the next push — but only THIS machine's placement record makes + // that copy ours to delete. Without it, `remove rules fe/foo` would take an + // unrelated personal .claude/rules/foo.md with it (#649 review). + const localNames = new Set([name]); + const bareName = path.basename(name); + if (bareName !== name) { + const placed = placedResourcePath( + (await loadStateForScope(localConfig)).placedRules, 'rules', bareName, + ); + if (placed === `rules/${name}.md`) localNames.add(bareName); + } + + // Record a tombstone so the resource won't be re-pushed. Only the name + // given: every member reads the tombstone, and a bare `` would sweep + // and suppress their own unrelated root rule of that name (#649 review). + // Members hold a namespaced rule under `/`, which the published name + // matches; the author's root copy is swept below, and a copy an excluded + // tool keeps is not a push source (`scanLocalForPush`). await this.addTombstone(name, localConfig); // Remove from each tool's rules directory. `.mdc` tools may have an older @@ -273,12 +394,14 @@ export class RulesHandler extends ResourceHandler { if (isAgentExcluded(localConfig, tool)) continue; const baseDir = resolveToolBaseDir(tool, localConfig); const extensions = new Set([ruleFileExtensionForTool(tool), '.md']); - for (const extension of extensions) { - const filePath = path.join(baseDir, toolPath.rules, `${name}${extension}`); - if (await pathExists(filePath)) { - await remove(filePath); - removed.push(filePath); - log.debug(`Removed rule ${name} from ${tool}`); + for (const localName of localNames) { + for (const extension of extensions) { + const filePath = path.join(baseDir, toolPath.rules, `${localName}${extension}`); + if (await pathExists(filePath)) { + await remove(filePath); + removed.push(filePath); + log.debug(`Removed rule ${localName} from ${tool}`); + } } } } @@ -332,6 +455,32 @@ export class RulesHandler extends ResourceHandler { // 1.5. Clean up stale local rule files not present in team repo const teamRuleNames = new Set(rules.map((r) => r.name)); + // A rule this machine published into a namespace keeps the author's copy at + // the rules ROOT under its bare name. The desired set never contains that + // name — it is `/` there, or absent when the namespace is not + // active here — so the sweep below would delete the author's own file, + // local edits and all (#649 review). The record is what marks it as ours, + // and only while the team file it points at still exists. Before the PR + // merges there is no record yet — the placement is on the pending entry — + // and the copy is just as much ours then. + const { placedRules, pendingPushes } = await loadStateForScope(localConfig); + for (const name of Object.keys(placedRules ?? {})) { + const placed = placedResourcePath(placedRules, 'rules', name); + if (placed && await pathExists(path.join(localConfig.repo.localPath, placed))) { + teamRuleNames.add(name); + } + } + // Any pending entry that carries a root-authored rule at a namespaced path, + // not only one still marked `placed`: reconcile spends the mark on a + // placement it cannot prove, while the PR may still be open and the copy + // is still the author's work (#649 review). + for (const entry of pendingPushes ?? []) { + for (const item of entry.items) { + if (item.type === 'rules' && !item.name.includes('/') && item.relativePath.split('/').length === 3) { + teamRuleNames.add(item.name); + } + } + } const tombstones = await this.readTombstones(localConfig); for (const [tool, toolPath] of Object.entries(scopedToolPaths(teamConfig, localConfig))) { if (!toolPath.rules) continue; diff --git a/src/roles.ts b/src/roles.ts index 143a7ab9..d7406e7e 100644 --- a/src/roles.ts +++ b/src/roles.ts @@ -1,7 +1,7 @@ import path from 'node:path'; import YAML from 'yaml'; import { z } from 'zod'; -import { readFileSafe, readFileIfExists, ensureDir, writeFile } from './utils/fs.js'; +import { readFileSafe, readFileIfExists, ensureDir, pathExists, writeFile } from './utils/fs.js'; const ROLE_RESOURCE_TYPES = ['knowledge', 'skills', 'agents'] as const; @@ -91,11 +91,34 @@ function validateManifestShape(raw: unknown): RolesManifest { return manifest; } +/** + * The team repo has no `manifest/roles.yaml` at all — a distinct case from one + * that exists but cannot be parsed. `push` treats them differently: an absent + * manifest is the pre-manifest layout, where a role id doubles as its skills + * namespace, while an unreadable one is a failure that must stop the push + * rather than let a new rule or agent fall back to the shared root (#649). + */ +export class RolesManifestNotFoundError extends Error { + constructor(manifestPath: string) { + super(`Roles manifest not found: ${manifestPath}`); + this.name = 'RolesManifestNotFoundError'; + } +} + export async function loadRolesManifest(repoPath: string): Promise { const manifestPath = path.join(repoPath, 'manifest', 'roles.yaml'); const content = await readFileSafe(manifestPath); - if (!content) { - throw new Error(`Roles manifest not found: ${manifestPath}`); + if (content === null) { + // `readFileSafe` answers null for EVERY failure, so "no such file" and + // "cannot read it" arrive identically. Only the first is the pre-manifest + // layout; treating a permission error as that one sends new rules and + // agents to the shared root, which is the whole team (#649). + if (await pathExists(manifestPath)) { + throw new Error( + `Roles manifest exists but could not be read: ${manifestPath}. Check its permissions.`, + ); + } + throw new RolesManifestNotFoundError(manifestPath); } let raw: unknown; diff --git a/src/types.ts b/src/types.ts index 7e73adfa..6dffa604 100644 --- a/src/types.ts +++ b/src/types.ts @@ -593,6 +593,22 @@ export const PendingPushItemSchema = z.object({ relativePath: z.string(), /** Skill namespace chosen at push time, reapplied when the PR is updated. */ namespace: z.string().optional(), + /** + * True when this push PLACED the resource: a root-authored rule or agent + * written under `//`. Once `relativePath` is on the default + * branch — the PR merged — it becomes a `placedRules`/`placedAgents` record + * (`reconcilePlacementRecords`). Until then nothing records it, so a PR + * closed unmerged leaves no record behind, branch deleted or not. + */ + placed: z.boolean().optional(), + /** + * Git blob id of the file this push wrote at `relativePath`, for a placed + * item. Landing is proven by that blob appearing in the default branch's + * history for the path after the entry's `base` — not by the path merely + * existing, which another + * member's unrelated file would also satisfy. + */ + blob: z.string().optional(), }); /** @@ -607,6 +623,12 @@ export const PendingPushSchema = z.object({ branch: z.string(), prUrl: z.string().nullable().default(null), createdAt: z.string(), + /** + * Default-branch commit the branch was built on. A placed item's `blob` + * proves landing only in commits after it: the same content may have sat + * at that path before this push, and that history proves nothing about it. + */ + base: z.string().optional(), items: z.array(PendingPushItemSchema).default([]), }); @@ -625,6 +647,39 @@ export const StateSchema = z.object({ /** Tool targets that completed the last inherited user-resource pull. */ lastInheritedPullTargets: z.array(z.string()).optional(), pushedRules: z.array(z.string()).default([]), + /** + * Where push placed each root-level local rule inside the team repo, by rule + * name, e.g. `{ "my-rule": "rules/fe-know/my-rule.md" }`. The author's copy + * stays at the tool's rules root after push, so without this record the next + * scan would read it as a brand-new rule. Only a rule this machine pushed is + * recorded: an unrelated local rule that merely shares a basename with a + * namespaced team rule has no entry and is never matched to it. Optional + * for the same reason as `coAuthorManaged`; absent reads as an empty map. + */ + placedRules: z.record(z.string(), z.string()).optional(), + /** + * Where push placed each new agent inside the team repo, by agent name, e.g. + * `{ "vr": "agents/fe-agents/vr.yaml" }`. `AgentsHandler.scanLocalForPush` + * only accepts a team source whose namespace this directory has ACTIVE, so + * without this record an author who published an agent with `--role`/ + * `--project` could never edit it again: the file they created reads as + * inactive and the push is skipped. Same shape and same caveats as + * `placedRules`. + */ + placedAgents: z.record(z.string(), z.string()).optional(), + /** + * Default-branch commit the placement records were last checked against. A + * record whose file was deleted after it is dropped even if something is at + * that path again: whatever is there now is somebody else's. + */ + placementsCheckedAt: z.string().optional(), + /** + * `placedAgents` records dropped because the team deleted their file, by + * agent name. The author's flattened copy stood for that namespaced agent, so + * once it is tombstoned the copy is the removed agent's, even though no + * record or active namespace says so any longer (`AgentsHandler.removedStems`). + */ + retiredPlacedAgents: z.record(z.string(), z.string()).optional(), pushedSkills: z.array(z.string()).default([]), pushedEnvVars: z.array(z.string()).default([]), /** Push branches whose PR is still open — see PendingPushSchema. */ @@ -690,6 +745,13 @@ export interface ResourceDiff { export interface DeliveryTarget { tool: string; dest: string; + /** + * A path this delivery makes redundant, removed once `dest` is written: a + * rule this machine placed in a namespace is delivered onto the author's + * root copy, and the `/` copy an earlier pull wrote is the same + * rule twice. + */ + supersedes?: string; /** * The exact bytes `pullItem` writes at `dest`, for a handler that renders * its destination rather than copying a tree there. It is what tells a copy @@ -1649,6 +1711,14 @@ export function isAgentDisabled(localConfig: { disabledAgents?: string[] }, tool * the team scoped its opt-in with `--agent` (undefined whitelist = all * installed tools). */ +/** + * Synthetic toolPaths key used only to make `teamai push` scan the active tree's + * .teamai/{skills,rules} in single-repo mode (see pushCore). It is never written + * to disk and never used by pull — the leading marker keeps it from colliding + * with any real agent id. It is not a tool, so tool exclusions never apply to it. + */ +export const SELF_KNOWLEDGE_SCAN_KEY = '__teamai_self_knowledge__'; + export function isAgentExcluded( localConfig: { disabledAgents?: string[]; enabledAgents?: string[] }, tool: string, diff --git a/src/utils/git.ts b/src/utils/git.ts index 84a6a49e..a2c6e567 100644 --- a/src/utils/git.ts +++ b/src/utils/git.ts @@ -842,6 +842,113 @@ export async function resetToCleanMaster(git: SimpleGit, localPath?: string): Pr } } +/** The git blob id of a working-tree file, as `git hash-object` reports it; null if unreadable. */ +export async function hashObject(repoPath: string, filePath: string): Promise { + try { + return (await createGit(repoPath).raw(['hash-object', '--', filePath])).trim() || null; + } catch { + return null; + } +} + +/** + * Whether `blob` became or stopped being the content of `filePath` in a commit + * reachable from `tip` (HEAD by default) after `since` (the whole history when + * absent). Squash- and rebase-merges rewrite commits but keep the blob, so this + * is what tells "our push landed here" from "somebody else created this path" + * when the branch itself is no longer around to ask. Null when git cannot say. + */ +export async function blobInHistory( + repoPath: string, + blob: string, + filePath: string, + since?: string, + tip = 'HEAD', +): Promise { + try { + const range = since ? `${since}..${tip}` : tip; + const out = await createGit(repoPath).raw(['log', range, `--find-object=${blob}`, '--format=%H', '--', filePath]); + return out.trim().length > 0; + } catch (e) { + log.debug(`git log --find-object failed for ${filePath}: ${(e as Error).message}`); + return null; + } +} + +/** + * Whether a commit reachable from `tip` (HEAD by default) after `since` deleted + * `filePath`. A path that exists now may still have been deleted and recreated + * in that range, by someone else. Null when git cannot say. + */ +export async function pathDeletedSince( + repoPath: string, + since: string, + filePath: string, + tip = 'HEAD', +): Promise { + return pathChangedSince(repoPath, since, filePath, tip, 'D'); +} + +/** Whether a commit reachable from `tip` after `since` added `filePath`. Null when git cannot say. */ +export async function pathAddedSince( + repoPath: string, + since: string, + filePath: string, + tip = 'HEAD', +): Promise { + return pathChangedSince(repoPath, since, filePath, tip, 'A'); +} + +async function pathChangedSince( + repoPath: string, + since: string, + filePath: string, + tip: string, + filter: 'A' | 'D', +): Promise { + try { + const out = await createGit(repoPath).raw(['log', `${since}..${tip}`, `--diff-filter=${filter}`, '--format=%H', '--', filePath]); + return out.trim().length > 0; + } catch (e) { + log.debug(`git log --diff-filter=${filter} failed for ${filePath}: ${(e as Error).message}`); + return null; + } +} + +/** + * The content `filePath` had in the latest commit that added it, or null. For + * a resource created after the last pull there is no `lastPullRev` version to + * compare with, and this is the version the member's copy started from. + */ +export async function getFileContentWhenAdded(repoPath: string, filePath: string): Promise { + try { + const sha = (await createGit(repoPath).raw(['log', 'HEAD', '--diff-filter=A', '--format=%H', '-1', '--', filePath])).trim(); + return sha ? await getFileContentAtRev(repoPath, sha, `./${filePath}`) : null; + } catch (e) { + log.debug(`git log --diff-filter=A failed for ${filePath}: ${(e as Error).message}`); + return null; + } +} + +/** Full commit id of `rev` (HEAD by default), or null when it names none. */ +export async function getHeadCommit(localPath: string, rev = 'HEAD'): Promise { + try { + return (await createGit(localPath).raw(['rev-parse', '--verify', `${rev}^{commit}`])).trim() || null; + } catch { + return null; + } +} + +/** + * Whether `absFile`'s content was, at some point on the current branch, the + * content of `relPath`. A local copy that matches an OLDER team version and + * not the current one is a stale copy nobody edited, not a local change. + */ +export async function isPastVersionOf(repoPath: string, absFile: string, relPath: string): Promise { + const blob = await hashObject(repoPath, absFile); + return blob !== null && await blobInHistory(repoPath, blob, relPath) === true; +} + /** * Get the raw content of a file at a specific git revision. * Uses `git show :` to retrieve historical file content. diff --git a/src/utils/pending-push.ts b/src/utils/pending-push.ts index 8344c03e..81c8a357 100644 --- a/src/utils/pending-push.ts +++ b/src/utils/pending-push.ts @@ -12,7 +12,10 @@ * and force-push the recorded branch — updating the existing PR in place — * instead of opening a duplicate. */ -import { remoteBranchExists } from './git.js'; +import path from 'node:path'; +import { pathExists } from './fs.js'; +import { remoteBranchExists, hashObject, blobInHistory, pathAddedSince, pathDeletedSince, getHeadCommit, getFileContentAtRev } from './git.js'; +import { placedResourcePath } from '../push-namespaces.js'; import { log } from './logger.js'; import type { PendingPush, ResourceItem, State } from '../types.js'; @@ -135,11 +138,238 @@ export function recordPendingPush(state: State, entry: PendingPush): void { ]; } -export function toPendingItems(items: ResourceItem[]): PendingPush['items'] { - return items.map((i) => ({ - type: i.type, - name: i.name, - relativePath: i.relativePath, - namespace: i.namespace, - })); +/** + * The pending-entry view of the pushed items. Called after `pushItem` and + * before the branch is switched away, while `relativePath` is the file this + * run wrote: a placed item records its blob so landing can later be proven. + */ +export async function toPendingItems(items: ResourceItem[], repoPath: string): Promise { + const out: PendingPush['items'] = []; + for (const i of items) { + // A placement is marked only with the blob that can prove it landed: + // without one, reconcile would have nothing but the path existing, which + // another member's later file at that path also satisfies (#649 review). + const blob = isPlacement(i) ? await hashObject(repoPath, i.relativePath) : null; + if (isPlacement(i) && !blob) { + log.warn(`[${i.type}] ${i.name}: could not hash ${i.relativePath}, so this machine will not treat it as its placement once merged.`); + } + out.push({ + type: i.type, + name: i.name, + relativePath: i.relativePath, + namespace: i.namespace, + ...(blob ? { placed: true, blob } : {}), + }); + } + return out; +} + +/** + * Whether pushing `item` PLACED it: a root-authored rule or agent that this + * run wrote under `//`, which is what a placement record is for. + * The author's copy stays at the tool's resource root, so the scanner needs + * the record to recognise the two as one resource (`RulesHandler`), and + * `AgentsHandler` needs it to accept a source whose namespace this directory + * has not activated. + * + * Only a `new` item that ended up namespaced counts. A `modified` one was + * found in its namespace by the scanner, which means that namespace is active + * here and the scan will find it again; recording it would turn a temporary + * activation into standing permission to keep editing an agent long after the + * role or project that granted it was dropped. The one exception is an agent + * the scan reached THROUGH its record and rewrote under another extension + * (`supersedes`): the record has to follow it to the new path. + */ +export function isPlacement(item: ResourceItem): boolean { + if (item.type === 'agents' && 'supersedes' in item && typeof item.supersedes === 'string') return true; + if (item.status !== 'new' || !item.namespace) return false; + // A rule the scanner already found in a subdirectory carries the namespace + // in its name and matches by full path, so it needs no record. + if (item.type === 'rules') return !item.name.includes('/'); + return item.type === 'agents'; +} + +/** The shared-root file(s) that, if present, mean `name` is not ours to redirect. */ +function sharedRootPaths(root: 'rules' | 'agents', name: string): string[] { + return root === 'rules' ? [`rules/${name}.md`] : [`agents/${name}.yaml`, `agents/${name}.md`]; +} + +/** + * Bring the placement records (`placedRules`, `placedAgents`) in line with + * the default branch as just pulled. Three moves, in this order: + * + * 1. A placement still listed on a pending push whose pushed blob entered + * the default branch's history for that path after the revision the + * push branch was built on has landed — the PR merged, + * however the platform merged it — and becomes a record. The path merely + * existing is not enough: another member may have created it after the + * PR was closed, and recording it then would hand their file to this + * author. Nothing is recorded before landing, so a PR closed unmerged + * leaves no record whether or not its branch was deleted, and no provider + * has to be asked whether a PR is open. While the PR is open the pending + * entry itself routes the author's edits back to it (`reuseRecordedDestinations`). + * Recording consumes the mark, so a placement is recorded exactly once, + * and a placement whose path was deleted after it landed is spent unrecorded. + * 2. A record whose file is gone from the default branch is dropped: the + * team deleted the resource. Kept, it would come true again the day + * another member creates that path, and their unrelated resource would + * then read as this author's. For the same reason a record whose file + * was deleted since the last check (`placementsCheckedAt`) is dropped + * even when the path exists again. + * 3. A record whose bare name is now ALSO a shared-root file is dropped, with + * a warning: the author's root copy can no longer stand for the namespaced + * resource, because the shared-root rule of that name is what every tool + * dir holds at that path, and following the record would push that + * unrelated rule over the author's namespaced one. + * + * Runs after the pull in `push` and after the refresh in `pull`, before + * anything reads the records. Returns whether `state` changed. + * + * `tip` names the default branch as a git ref, for a checkout that is not the + * default branch itself — a single-repo member's own working tree. Without + * it, the working tree and HEAD are the default branch as just pulled. + */ +export async function reconcilePlacementRecords( + repoPath: string, + state: Pick, + tip?: string, +): Promise { + // A ref that cannot be resolved says nothing about any file: reading every + // record as "gone" against it would drop them all. + if (tip && !await getHeadCommit(repoPath, tip)) { + log.debug(`Placement records not reconciled: ${tip} cannot be resolved here`); + return false; + } + const exists = tip + ? async (rel: string) => await getFileContentAtRev(repoPath, tip, `./${rel}`) !== null + : (rel: string) => pathExists(path.join(repoPath, rel)); + const history = tip ?? 'HEAD'; + let changed = false; + const checkedAt = state.placementsCheckedAt; + // Recorded in step 1 of this very run, against their own `base`: the + // previous checkpoint predates them and says nothing about them. + const recordedNow = new Set(); + const fieldFor = (type: string): 'placedRules' | 'placedAgents' | null => ( + type === 'rules' ? 'placedRules' : type === 'agents' ? 'placedAgents' : null + ); + + // 1. Landed placements become records. + for (const entry of state.pendingPushes ?? []) { + for (const item of entry.items) { + if (!item.placed) continue; + const field = fieldFor(item.type); + if (!field) continue; + // Without the blob push wrote, landing cannot be told from another + // member creating the same path after this PR closed unmerged, so the + // mark is spent rather than recorded on the path existing (#649 review). + if (!item.blob) { + log.debug(`Not recording placement ${field}.${item.name}: no blob to prove it landed`); + item.placed = false; + changed = true; + continue; + } + if (!await exists(item.relativePath)) continue; + // Bounded by `base`: the same bytes may have sat at this path before the + // push, and a PR closed unmerged must not borrow that history. + if (await blobInHistory(repoPath, item.blob, item.relativePath, entry.base, history) !== true) { + // The path arrived after this push, but never with what it pushed: a + // reviewer changed the PR before a squash merge, or somebody else + // created the path. The two cannot be told apart, so nothing is + // recorded — but say so once, or the author's next push meets a + // collision on what may well be their own file. + if (entry.base && await pathAddedSince(repoPath, entry.base, item.relativePath, history) === true) { + log.warn( + `[${item.type}] ${item.name}: ${item.relativePath} reached the default branch after your push, but not ` + + 'with the content you pushed, so it is not treated as yours. If a reviewer changed your PR before it ' + + `merged, run \`teamai pull\` and edit ${item.relativePath} as the team file it now is; your root copy ` + + 'would otherwise be pushed as a new resource.', + ); + item.placed = false; + delete item.blob; + changed = true; + } + continue; + } + // Placement refuses an occupied path, so a deletion since `base` came + // after this placement landed: what is there now was recreated by + // someone else, and the placement is spent without a record. + if (entry.base && await pathDeletedSince(repoPath, entry.base, item.relativePath, history) === true) { + log.debug(`Not recording placement ${field}.${item.name}: ${item.relativePath} was deleted after it landed`); + item.placed = false; + delete item.blob; + changed = true; + continue; + } + log.debug(`Recording placement ${field}.${item.name} → ${item.relativePath}: landed on the default branch`); + state[field] = { ...state[field], [item.name]: item.relativePath }; + recordedNow.add(`${field}:${item.name}`); + // Consumed: a placement is recorded once. Left marked, it would record + // again after the team deleted the file and another member recreated + // the path — the blob stays in history, so the check above would still + // pass — and hand their file to this author. + item.placed = false; + delete item.blob; + changed = true; + } + } + + // 2 and 3. Records the default branch no longer backs. + for (const [field, root] of [['placedRules', 'rules'], ['placedAgents', 'agents']] as const) { + const records = state[field]; + if (!records) continue; + const kept: Record = {}; + for (const [name, recorded] of Object.entries(records)) { + const valid = placedResourcePath(records, root, name); + if (valid && !await exists(valid)) { + log.debug(`Dropping placement record ${field}.${name} → ${recorded}: gone from the default branch`); + // The author's flattened copy stood for this agent; once the removal + // is tombstoned, that is still what the copy is (`removedStems`). + if (field === 'placedAgents') state.retiredPlacedAgents = { ...state.retiredPlacedAgents, [name]: valid }; + changed = true; + continue; + } + // Deleted and recreated between two checks — a removal that merged, then + // another member's resource at the same path — is not this author's. + if (valid && checkedAt && !recordedNow.has(`${field}:${name}`) + && await pathDeletedSince(repoPath, checkedAt, valid, history) === true) { + log.debug(`Dropping placement record ${field}.${name} → ${recorded}: deleted from the default branch since the last check`); + // The file this record named was deleted, exactly as in the branch + // above; what is there now is somebody else's, but the author's copy + // still stood for the removed agent (#649 review). + if (field === 'placedAgents') state.retiredPlacedAgents = { ...state.retiredPlacedAgents, [name]: valid }; + changed = true; + continue; + } + let shadowed: string | undefined; + for (const candidate of sharedRootPaths(root, name)) { + if (await exists(candidate)) { shadowed = candidate; break; } + } + if (shadowed) { + log.warn( + `[${root}] ${name}: ${shadowed} now exists at the shared root, so your local ${name} follows that ` + + `file from here on and no longer stands for ${recorded}. Edit ${recorded} through its namespace.`, + ); + changed = true; + continue; + } + kept[name] = recorded; + } + state[field] = kept; + } + + // The revision the surviving records were checked against, so the next run + // sees a deletion that happened in between even if the path is back by then. + // With no record left there is nothing it could vouch for, and kept it would + // later read a deletion from before a new record existed as that record's. + if (Object.keys(state.placedRules ?? {}).length + Object.keys(state.placedAgents ?? {}).length > 0) { + const head = await getHeadCommit(repoPath, history); + if (head && head !== state.placementsCheckedAt) { + state.placementsCheckedAt = head; + changed = true; + } + } else if (state.placementsCheckedAt !== undefined) { + delete state.placementsCheckedAt; + changed = true; + } + return changed; } diff --git a/src/utils/pre-push-sync.ts b/src/utils/pre-push-sync.ts index df5d3486..d6e56a50 100644 --- a/src/utils/pre-push-sync.ts +++ b/src/utils/pre-push-sync.ts @@ -13,12 +13,13 @@ import { readFileSafe, writeFile, } from './fs.js'; -import { getFileContentAtRev } from './git.js'; +import { getFileContentAtRev, getFileContentWhenAdded } from './git.js'; import { ResourceHandler } from '../resources/base.js'; import { ruleFileExtensionForTool, usesCursorMdcRules } from '../resources/rule-format.js'; import { teamRuleToCursorMdc, cursorMdcBodyEqualsTeamMd } from '../resources/cursor-mdc.js'; import { EXCLUDED_RULE_NAMES } from '../builtin-rules.js'; import { log } from './logger.js'; +import { placedResourcePath } from '../push-namespaces.js'; /** * Sync team repo updates to local tool directories BEFORE scanning for push. @@ -35,11 +36,19 @@ import { log } from './logger.js'; * genuine edits — leave it alone for scanLocalForPush to pick up. * * This is a no-op when `lastPullRev` is null (first run or after re-init). + * + * `placedRules` is `state.placedRules`: where push put each root-level local + * rule inside the team repo. A rule authored at the tool's rules root and + * placed under `rules//` has no `rules/.md` to compare against, so + * without this map the three-way check below would skip it and the scanner — + * which DOES follow the map — would then read the stale root copy as a local + * modification and push it over a teammate's newer version. */ export async function syncTeamUpdatesToLocal( teamConfig: TeamaiConfig, localConfig: LocalConfig, lastPullRev: string | null, + placedRules: Record | undefined = undefined, ): Promise { if (!lastPullRev) { log.debug('No lastPullRev — skipping pre-push sync'); @@ -49,7 +58,7 @@ export async function syncTeamUpdatesToLocal( const repoPath = localConfig.repo.localPath; const baseDir = resolveBaseDir(localConfig); - await syncRulesToLocal(teamConfig, localConfig, repoPath, baseDir, lastPullRev); + await syncRulesToLocal(teamConfig, localConfig, repoPath, baseDir, lastPullRev, placedRules); await syncSkillsToLocal(teamConfig, localConfig, repoPath, baseDir, lastPullRev); } @@ -63,6 +72,7 @@ async function syncRulesToLocal( repoPath: string, baseDir: string, lastPullRev: string, + placedRules: Record | undefined, ): Promise { const teamRulesDir = path.join(repoPath, 'rules'); if (!await pathExists(teamRulesDir)) return; @@ -89,8 +99,29 @@ async function syncRulesToLocal( const localFilePath = path.join(rulesDir, file); // The team repo always stores the tool-neutral `.md`. - const teamRelPath = `rules/${name}.md`; - const teamFilePath = path.join(teamRulesDir, `${name}.md`); + let teamRelPath = `rules/${name}.md`; + let teamFilePath = path.join(teamRulesDir, `${name}.md`); + + // Same redirect as RulesHandler.scanLocalForPush, through the same + // resolver: a root-level rule this machine pushed lives under rules// + // in the team repo, and both sides must compare against that file or the + // scan reverts a teammate's update. + const placed = placedResourcePath(placedRules, 'rules', name); + let viaRecord = false; + if (placed && await pathExists(path.join(repoPath, placed))) { + teamRelPath = placed; + teamFilePath = path.join(repoPath, placed); + viaRecord = true; + } + // A placement that landed after the last pull did not exist at + // `lastPullRev`, yet the author's root copy is exactly what landed. Its + // base is the version the file was added with; without it a teammate's + // edit before the author's next pull was skipped here, and the stale + // copy went back over it (#649 review). + const baseVersion = async (): Promise => ( + await getFileContentAtRev(repoPath, lastPullRev, teamRelPath) + ?? (viaRecord ? await getFileContentWhenAdded(repoPath, teamRelPath) : null) + ); // Only process files that exist in both places but differ if (!await pathExists(teamFilePath)) continue; @@ -100,7 +131,7 @@ async function syncRulesToLocal( if (localRaw === null || teamRaw === null) continue; if (cursorMdcBodyEqualsTeamMd(localRaw, teamRaw)) continue; - const oldContent = await getFileContentAtRev(repoPath, lastPullRev, teamRelPath); + const oldContent = await baseVersion(); if (oldContent === null) continue; // Didn't exist at lastPullRev — ambiguous, skip // Compare bodies: the local `.mdc` never matched the team `.md` byte for byte. @@ -114,7 +145,7 @@ async function syncRulesToLocal( if (await fileContentEqual(localFilePath, teamFilePath)) continue; // They differ — check if local matches the old team repo version - const oldContent = await getFileContentAtRev(repoPath, lastPullRev, teamRelPath); + const oldContent = await baseVersion(); if (oldContent === null) continue; // File didn't exist at lastPullRev — ambiguous, skip if (await fileContentEqualToBuffer(localFilePath, oldContent)) {