diff --git a/CHANGELOG.md b/CHANGELOG.md index 32dd805fe..fd5fc26f3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ All notable changes to this project will be documented in this file. See [standa ### ๐Ÿ’ฅ Breaking Changes +- **Upgrade every member together: team instructions leave the shared `AGENTS.md`.** `teamai pull` no longer writes the culture, `claudemd/` and recall blocks into the project's `AGENTS.md`, `~/AGENTS.md`, `~/.agents/AGENTS.md` or another tool's file, since members with different roles resolve different `claudemd/` selections and the shared file ended up holding whoever pulled last. Each installed tool gets them in its own file or session hook: Claude Code in `.claude/rules/teamai-context.md` (project) and `~/.claude/CLAUDE.md` (user); Cursor in `teamai-context.mdc` under `.cursor/rules` and `~/.cursor/rules`; CodeBuddy and WorkBuddy in one `.codebuddy/rules/teamai-context.md`, and WorkBuddy in `~/.workbuddy/rules/teamai-context.md`; OpenCode in `.opencode/teamai-context.md` and `~/.config/opencode/teamai-context.md`, listed in its `instructions`; Oh My Pi in `~/.omp/agent/RULES.md` and, in a project, through teamai's OMP extension; Pi through teamai's Pi extension in a project; Hermes in `$HERMES_HOME/SOUL.md` and, in a project, through a Hermes plugin teamai installs and enables. Copilot keeps `.github/copilot-instructions.md`. The first pull after updating removes the blocks earlier releases left in `AGENTS.md`, `~/AGENTS.md`, `.claude/CLAUDE.md`, `.codebuddy/CODEBUDDY.md`, `.omp/AGENTS.md`, `~/.omp/agent/AGENTS.md` and a moved tool's `claudemd` from the team's `toolPaths`, keeps everything else in those files, and names each file it changes; a block with a missing or repeated marker is left with a warning. A member still on an older release writes the blocks back into the shared files, so have everyone update. Pull writes only for installed tools, rewrites nothing that is current, removes the recall block when recall is disabled, and `--dry-run` lists the files it would change. Hermes skips project instructions over 4,000 characters, which pull and `teamai doctor` report. `teamai doctor` checks that each tool can load its instructions. A team `toolPaths` entry without `rules` keeps its configured `claudemd` for Claude Code, Cursor, CodeBuddy and WorkBuddy, and an entry with only `claudemd` is still delivered to. A team rule named `teamai-context` is not delivered, since it would land on teamai's own file, and a copy an earlier release delivered is removed unless the member changed it; neither a tombstone of that rule nor a namespaced placement of it reaches teamai's file. The HTTP local agent strips the old blocks of the tools a prompt reached, probes each tool as pull does, and its project prompts reach Pi, Oh My Pi and Hermes through their extension or plugin and the Codex family through its session hooks. It acks a prompt as failed, with the reason, when it reaches no tool: a target file teamai did not write, an extension or plugin that is missing, or text over Hermes' 4,000-character section. OpenCode counts `~/.claude/CLAUDE.md` as its fallback while that file holds teamai blocks, also from an excluded Claude Code, and pull warns when nothing keeps them current. A Hermes plugin named `teamai-instructions` or an Oh My Pi `teamai-hooks.ts` that teamai did not write is left alone, and a file CodeBuddy and WorkBuddy share gets the `teamai-recall` subagent block only when both have the subagent. teamai does not change `.gitignore`, `.git/info/exclude` or the index; a team that tracks a generated file such as `.github/copilot-instructions.md` still commits one member's selection. A project uninstall keeps the global Pi, Oh My Pi and Hermes adapters and Codex's user hooks, which other installs on the machine may use, and names them; `teamai hooks remove` removes them (for [#945](https://github.com/Tencent/teamai-cli/issues/945)). - An env variable, hook or MCP server with a key its schema does not know, such as a misspelled `role:` or a hand-added `notes:`, is no longer delivered to anyone: the key used to be dropped silently, so a misspelled restriction shipped the entry to every member. `teamai pull`, the list commands, `teamai status` and `teamai doctor` name the file, the entry and the key. `teamai env add` also warns when it updates a variable carrying the unknown key; it, `teamai env remove` and `teamai remove mcp` keep the key when they rewrite the file. A key that a later version adds to these entries is unknown to this one too, so an entry that uses it is not delivered to a member still on this version: upgrade every member before the team uses a new entry key, as for a new `resources:` key (for [#822](https://github.com/Tencent/teamai-cli/issues/822)). - `manifest/projects.yaml` and `manifest/roles.yaml` now reject a resource namespace that is not a single path segment, as a project id already had to be (the id keeps its own narrower ASCII rule). A namespace becomes a directory component (`skills//`, `agents//`, `learnings//`), so `../evil`, `a/b`, `C:evil`, a bare `..`, any name with a trailing `.` or space โ€” which Win32 strips, making `.. ` arrive as `..` and `frontend.` as `frontend` โ€” and a Windows device name such as `CON` or `COM1` under `resources:` no longer parse; the error names the offending entry. Nothing else is rejected: a namespace that is a plain directory name still parses, non-ASCII names and names with a space included. A manifest that fails to parse now reports the offending entry on one line (`Invalid projects manifest: projects.0.resources.skills.1: ...`) instead of dumping a raw validation object. Two namespaces of one resource type that differ only by case (`frontend`, `Frontend`) are rejected too, within a manifest and between the two, since they name one directory on Windows and macOS. A manifest that ships any of these โ€” a device name, a trailing `.`, a case-only pair โ€” parsed before and fails every pull now; rename the directory and the entry together. - **Upgrade every member before a team declares a new axis.** `resources:` in `manifest/roles.yaml` and `manifest/projects.yaml` accepts `env`, `hooks`, `mcp`, `models` and `docs`, but teamai 0.25.0 and the 0.26.0 betas reject a `resources:` key they do not know, so a team that declares one breaks pull for every member still on those versions. From this version on, an unknown `resources:` key prints one warning naming the role or project and the key, and the scope syncs as if the key were absent; `teamai roles` and `teamai projects` keep the key when they save the manifest. `teamai roles|projects add/update --namespaces` never write the new keys, so nothing declares them until an admin does by hand (for [#707](https://github.com/Tencent/teamai-cli/issues/707)). @@ -15,6 +16,7 @@ All notable changes to this project will be documented in this file. See [standa ### โœจ Features +- A tool without the `teamai-recall` subagent (Pi, Hermes, OpenClaw) now gets a recall block too, which tells the agent to run `teamai recall ""` itself before code, debugging or design work, with the same skip conditions as the subagent block. It shares the subagent block's markers, so `recall disable`, uninstall and doctor treat both alike. Pi's README row now shows โœ“ for learnings, codebase and teamwiki (for [#945](https://github.com/Tencent/teamai-cli/issues/945)). - A YAML agent can set `model: strong`, `model: fast`, or an alias the team defines in an optional `models/aliases.yaml`, which maps each alias per tool to that tool's own model value and an optional effort. `teamai pull` writes the mapped model into every tool's agent file, and the effort into that tool's own field: `effort` for Claude, claude-internal, tclaude, CodeBuddy, Qoder and Qoder CN, `model_reasoning_effort` for Codex, codex-internal and tcodex, `variant` for OpenCode. Cursor receives its model string as written, so its bracket form (`claude-opus-5[effort=high]`) carries the effort; Copilot receives the first entry as one model string. Copilot, Cursor, Kiro, WorkBuddy, JoyCode, ZCode and OMP get no effort field: an effort mapped for them is dropped, and pull warns once, naming the alias and the tool. The `-internal` and `t` variants use the `claude` or `codex` entry and Qoder CN the `qoder` entry unless the alias has their own key; no other tool inherits one. A tool the alias does not map, or any tool when the team has no aliases file, gets no `model` field and runs on its default, never a literal `strong`. `tool_extras..model` skips the alias for that tool, and an extras effort alone overrides the alias's effort. A `model` that is not a string now makes the agent fail to parse; a legacy `.md` agent whose `model` is an alias is copied as is with a warning. While `models/aliases.yaml` cannot be read, pull holds every agent that sets a `model`, since the file may define any name, and push skips them. Pull records the model each agent copy received, so an ordinary pull applies a changed resolution even when the team repo has not moved, which fixes an agent an older CLI wrote with `model: strong` as is, and delivers a copy that is missing; a copy the member edited is kept, and that pull names it with how to take the new model. Pull warns when an alias an agent resolved through is removed, also where the alias gave a tool no model field (for [#830](https://github.com/Tencent/teamai-cli/issues/830)). - A member overrides a team model alias on their own machine in `~/.teamai/models/aliases.yaml`, in the same `aliases:` shape. An entry replaces the team's whole entry for that tool, effort included, and `~` or `default` gives the tool no model field and no effort; only `tool_extras..model` wins over it. Keys are `strong`, `fast` or an alias the team defines, so a member can map `strong` before the team has an aliases file, and any other name has no effect. The file applies in every scope and to every team using that alias name, and an ordinary pull applies an edit to it. While it cannot be read, pull holds the agents whose `model` is an alias, naming the file, and push skips them; since the file can make no name an alias, agents with a concrete model are delivered and pushed as usual. In the team file, `default` is written as a model value and `~` is an error. When pull keeps an edited copy whose deployed version changed, it now says `the version teamai would deploy there ... has changed since` instead of blaming the team, since the change may be the member's override (for [#830](https://github.com/Tencent/teamai-cli/issues/830)). - On a tool switched with `teamai models switch`, an agent whose `model` is an alias no longer sends an account model to the gateway: Claude keeps a resolved `opus`, `sonnet` or `haiku`, which the switch routes to gateway models, and drops any other model; Codex, OpenCode, CodeBuddy and WorkBuddy get no `model` field, so they use their native inheritance (for Codex, `[agents].default_subagent_model` or the parent session's model), not the profile's model. No switched tool gets an effort, the alias's or one set in `tool_extras.`, unless `tool_extras.` also pins a model. `tool_extras..model`, a concrete `model` and a member's `~`/`default` are unchanged, and claude-internal, tclaude, codex-internal and tcodex are never treated as switched. A tool counts as switched only while its live settings path matches the recorded switch and still holds what TeamAI wrote, the checks `models restore` makes, so a stale record for another `CODEX_HOME` or `CLAUDE_CONFIG_DIR` has no effect. An ordinary pull after `models switch` or `models restore` rewrites the affected agents. While the switch records or a switched tool's settings cannot be read, pull holds alias agents in the tools concerned and says why (for [#830](https://github.com/Tencent/teamai-cli/issues/830)). @@ -43,7 +45,7 @@ All notable changes to this project will be documented in this file. See [standa ### ๐Ÿ› Bug Fixes -- Team rules reach Codex, `codex-internal` and `tcodex`. Pull used to copy them to `.codex/rules/.md`, which Codex does not read, and `teamai doctor` reported them delivered. In user scope they now go into a team-rules block of the tool's own `AGENTS.md` (`~/.codex/AGENTS.md`), beside the culture, shared-instructions and recall blocks. In a project the session-start hook adds the project's rules and those blocks to each session, and to a spawned subagent through a new `SubagentStart` entry, with `additionalContextLimit: 0` so Codex keeps them whole; pull leaves the project `AGENTS.md`, which other tools read too, unchanged. The hook omits blocks already present in the active project instructions file, reading `AGENTS.override.md` before `AGENTS.md`. Pull removes the `.md` copies earlier pulls left in the Codex rules directory at its recorded `toolRoots` location, including a publisher's bare local copy, keeping an edited one with a warning. Pull and uninstall keep a removed rule's legacy copy unless it matches its recorded delivery hash; a missing record proves nothing about local edits. A path-scoped rule is rendered without frontmatter after an `Applies to files matching: ` line, in Hermes' `SOUL.md` too. `teamai doctor` checks the user-scope block and, in a project, both hook entries' limit. The public Codex asks once to approve the changed teamai hooks (for [#938](https://github.com/Tencent/teamai-cli/issues/938)). +- Team rules reach Codex, `codex-internal` and `tcodex`. Pull used to copy them to `.codex/rules/.md`, which Codex does not read, and `teamai doctor` reported them delivered. In user scope they now go into a team-rules block of the tool's own `AGENTS.md` (`~/.codex/AGENTS.md`), beside the culture, shared-instructions and recall blocks. In a project the session-start hook adds the project's rules and those blocks to each session, and to a spawned subagent through a new `SubagentStart` entry, with `additionalContextLimit: 0` so Codex keeps them whole; pull leaves the project `AGENTS.md`, which other tools read too, unchanged. Pull removes the `.md` copies earlier pulls left in the Codex rules directory at its recorded `toolRoots` location, including a publisher's bare local copy, keeping an edited one with a warning. Pull and uninstall keep a removed rule's legacy copy unless it matches its recorded delivery hash; a missing record proves nothing about local edits. A path-scoped rule is rendered without frontmatter after an `Applies to files matching: ` line, in Hermes' `SOUL.md` too. `teamai doctor` checks the user-scope block and, in a project, both hook entries' limit. The public Codex asks once to approve the changed teamai hooks (for [#938](https://github.com/Tencent/teamai-cli/issues/938)). - `teamai pull` names the skills it removes because they are no longer delivered here, in one line, instead of a `debug` line calling them excluded and a summary that says `No resources to sync`. Picking a role or project, or an admin adding `manifest/projects.yaml`, takes root skills away, since the root `skills/` is then the tag catalog; the line then says that `teamai tags subscribe ` brings one back. A namespace skill removed because its namespace is no longer active is named too. The usage guide and the multi-project design no longer say a member with no project still gets `common` or every root item (for [#911](https://github.com/Tencent/teamai-cli/issues/911)). - `teamai pull` keeps the `teamai tags subscribe ` recovery line when the skill directory it removes is byte-identical to an inactive namespace copy: that copy made the namespace cleanup phase remove the directory first, and the hint was lost, because pull inferred whether a root skill had left from which phase did the removing. The hint now follows the repo โ€” it appears exactly when the team repo holds the removed skill at the root, the copy a tag delivers โ€” so a namespace-only skill removed on deactivation is still named without the hint. The usage guide no longer says a member with no role gets no skills at all, in English or Chinese: root skills still arrive through a tag (review of [#917](https://github.com/Tencent/teamai-cli/pull/917)). - `teamai pull` keeps a skill, rule or agent you changed since teamai delivered it instead of overwriting it, and names it: `Kept : you changed it since teamai delivered it`, with a warning when the team version has changed since, which `teamai push` repeats for that copy, since the SessionStart pull is silent. Pull records the sha256 of what it writes at each path in the checkout's record, and the pre-push sync records its writes too, so a copy counts as changed only against that record; a skill counts as one copy, and files only you added do not count. The copies of other tools still update. `--force` keeps these copies, `--dry-run` prints `Would keep `, and a copy of an item the team removed stays when you changed it. To take the team version, delete your copy and run `teamai pull --force`. There is no record before the first full pull on this version, or in a new worktree, so that pull overwrites as before. A file you added to a skill at a path another team version of it has is no longer named on every pull, and `teamai doctor` no longer fails a rules or agents check, or points at `teamai pull --force`, for a kept copy: it lists one as `changed by you (kept by pull)` beside any other problem (for [#822](https://github.com/Tencent/teamai-cli/issues/822)). diff --git a/README.ja.md b/README.ja.md index 8ea08a2cb..6f5e30863 100644 --- a/README.ja.md +++ b/README.ja.md @@ -129,7 +129,7 @@ Git ใ‚’ๅŸบ็›คใซใ€3 ๅฑคใฎ่ƒฝๅŠ›ใ‚’ๆง‹็ฏ‰ใ—ใพใ™๏ผš CodeBuddyโœ“โœ“โœ“โœ“โœ“โœ“โœ“โœ“โœ“โœ“โœ“โœ“โœ“โœ“ WorkBuddyโœ“โœ“โœ“โœ“โ€”โœ“โœ“โœ“โœ“โœ“โœ“โœ“โœ“โœ“ OpenCodeโœ“โœ“โœ“โœ“โœ“โœ“โœ“โœ“โœ“โœ“โœ“โ€”โ€”โ€” - Pi Coding Agentโœ“โœ“โœ“โ€”โ€”โœ“โœ“โœ“โ€”โ€”โ€”โ€”โ€”โ€” + Pi Coding Agentโœ“โœ“โœ“โ€”โ€”โœ“โœ“โœ“โœ“โœ“โœ“โ€”โ€”โ€” OpenClawโœ“โœ“โœ“โœ“โ€”โ€”โ€”โ€”โœ“โœ“โœ“โ€”โ€”โ€” Hermesโœ“โ€”โœ“โœ“โ€”โ€”โ€”โ€”โœ“โœ“โœ“โ€”โ€”โ€” DeepSeek Harnessโœ“โ€”โœ“โ€”โ€”โ€”โ€”โ€”โœ“โœ“โœ“โ€”โ€”โ€” diff --git a/README.ko.md b/README.ko.md index f6c95032f..adffb37a3 100644 --- a/README.ko.md +++ b/README.ko.md @@ -129,7 +129,7 @@ Git์„ ๊ธฐ๋ฐ˜์œผ๋กœ ์„ธ ์ธต์˜ ์—ญ๋Ÿ‰์„ ๊ตฌ์ถ•ํ•ฉ๋‹ˆ๋‹ค: CodeBuddyโœ“โœ“โœ“โœ“โœ“โœ“โœ“โœ“โœ“โœ“โœ“โœ“โœ“โœ“ WorkBuddyโœ“โœ“โœ“โœ“โ€”โœ“โœ“โœ“โœ“โœ“โœ“โœ“โœ“โœ“ OpenCodeโœ“โœ“โœ“โœ“โœ“โœ“โœ“โœ“โœ“โœ“โœ“โ€”โ€”โ€” - Pi Coding Agentโœ“โœ“โœ“โ€”โ€”โœ“โœ“โœ“โ€”โ€”โ€”โ€”โ€”โ€” + Pi Coding Agentโœ“โœ“โœ“โ€”โ€”โœ“โœ“โœ“โœ“โœ“โœ“โ€”โ€”โ€” OpenClawโœ“โœ“โœ“โœ“โ€”โ€”โ€”โ€”โœ“โœ“โœ“โ€”โ€”โ€” Hermesโœ“โ€”โœ“โœ“โ€”โ€”โ€”โ€”โœ“โœ“โœ“โ€”โ€”โ€” DeepSeek Harnessโœ“โ€”โœ“โ€”โ€”โ€”โ€”โ€”โœ“โœ“โœ“โ€”โ€”โ€” diff --git a/README.md b/README.md index a0eba87a2..343a45c73 100644 --- a/README.md +++ b/README.md @@ -129,7 +129,7 @@ Three layers of capability, built on Git: CodeBuddyโœ“โœ“โœ“โœ“โœ“โœ“โœ“โœ“โœ“โœ“โœ“โœ“โœ“โœ“ WorkBuddyโœ“โœ“โœ“โœ“โ€”โœ“โœ“โœ“โœ“โœ“โœ“โœ“โœ“โœ“ OpenCodeโœ“โœ“โœ“โœ“โœ“โœ“โœ“โœ“โœ“โœ“โœ“โ€”โ€”โ€” - Pi Coding Agentโœ“โœ“โœ“โ€”โ€”โœ“โœ“โœ“โ€”โ€”โ€”โ€”โ€”โ€” + Pi Coding Agentโœ“โœ“โœ“โ€”โ€”โœ“โœ“โœ“โœ“โœ“โœ“โ€”โ€”โ€” OpenClawโœ“โœ“โœ“โœ“โ€”โ€”โ€”โ€”โœ“โœ“โœ“โ€”โ€”โ€” Hermesโœ“โ€”โœ“โœ“โ€”โ€”โ€”โ€”โœ“โœ“โœ“โ€”โ€”โ€” DeepSeek Harnessโœ“โ€”โœ“โ€”โ€”โ€”โ€”โ€”โœ“โœ“โœ“โ€”โ€”โ€” diff --git a/README.th.md b/README.th.md index 72c95840b..05838c6f8 100644 --- a/README.th.md +++ b/README.th.md @@ -129,7 +129,7 @@ teamai init https://github.com/your-org/your-repo --scope user CodeBuddyโœ“โœ“โœ“โœ“โœ“โœ“โœ“โœ“โœ“โœ“โœ“โœ“โœ“โœ“ WorkBuddyโœ“โœ“โœ“โœ“โ€”โœ“โœ“โœ“โœ“โœ“โœ“โœ“โœ“โœ“ OpenCodeโœ“โœ“โœ“โœ“โœ“โœ“โœ“โœ“โœ“โœ“โœ“โ€”โ€”โ€” - Pi Coding Agentโœ“โœ“โœ“โ€”โ€”โœ“โœ“โœ“โ€”โ€”โ€”โ€”โ€”โ€” + Pi Coding Agentโœ“โœ“โœ“โ€”โ€”โœ“โœ“โœ“โœ“โœ“โœ“โ€”โ€”โ€” OpenClawโœ“โœ“โœ“โœ“โ€”โ€”โ€”โ€”โœ“โœ“โœ“โ€”โ€”โ€” Hermesโœ“โ€”โœ“โœ“โ€”โ€”โ€”โ€”โœ“โœ“โœ“โ€”โ€”โ€” DeepSeek Harnessโœ“โ€”โœ“โ€”โ€”โ€”โ€”โ€”โœ“โœ“โœ“โ€”โ€”โ€” diff --git a/README.zh-CN.md b/README.zh-CN.md index 12a005bb0..2af3f6d25 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -135,7 +135,7 @@ teamai init https://github.com/your-org/your-repo --scope user CodeBuddyโœ“โœ“โœ“โœ“โœ“โœ“โœ“โœ“โœ“โœ“โœ“โœ“โœ“โœ“ WorkBuddyโœ“โœ“โœ“โœ“โ€”โœ“โœ“โœ“โœ“โœ“โœ“โœ“โœ“โœ“ OpenCodeโœ“โœ“โœ“โœ“โœ“โœ“โœ“โœ“โœ“โœ“โœ“โ€”โ€”โ€” - Pi Coding Agentโœ“โœ“โœ“โ€”โ€”โœ“โœ“โœ“โ€”โ€”โ€”โ€”โ€”โ€” + Pi Coding Agentโœ“โœ“โœ“โ€”โ€”โœ“โœ“โœ“โœ“โœ“โœ“โ€”โ€”โ€” OpenClawโœ“โœ“โœ“โœ“โ€”โ€”โ€”โ€”โœ“โœ“โœ“โ€”โ€”โ€” Hermesโœ“โ€”โœ“โœ“โ€”โ€”โ€”โ€”โœ“โœ“โœ“โ€”โ€”โ€” DeepSeek Harnessโœ“โ€”โœ“โ€”โ€”โ€”โ€”โ€”โœ“โœ“โœ“โ€”โ€”โ€” diff --git a/docs/product-overview.md b/docs/product-overview.md index 66b2b2fef..619ab3978 100644 --- a/docs/product-overview.md +++ b/docs/product-overview.md @@ -59,7 +59,7 @@ Each resource is delivered to every agent: | **Rules** | `rules/*.md` | | | **Docs** | `docs/`, `docs//` | Foundational project docs; not all loaded by default (progressive disclosure). A `docs//` that no role or project declares stays shared | | **Agents** | `agents/.yaml`, `agents//.yaml` | | -| **Culture** | `culture.md` | Team mission, values, and working principles โ€” injected into each agent's CLAUDE.md / AGENTS.md so every session inherits them | +| **Culture** | `culture.md` | Team mission, values, and working principles โ€” delivered to each agent's own instruction file or session hook, never the project's shared AGENTS.md, so every session inherits them | | **CLAUDE.md** | `claudemd/*.md` | | | **Env** | `env/env.yaml`, `env//env.yaml` | Shared team-level environment variables and switches; do not put secret values here: declare a secret without its value in `env/secrets.yaml` | | **Hooks** | `hooks/hooks.yaml`, `hooks//hooks.yaml` | | diff --git a/docs/product-overview.zh-CN.md b/docs/product-overview.zh-CN.md index 38c39c93a..e29cb0fad 100644 --- a/docs/product-overview.zh-CN.md +++ b/docs/product-overview.zh-CN.md @@ -59,7 +59,7 @@ teamai push โ†’ ๅˆ›ๅปบๅˆ†ๆ”ฏ + MR โ†’ reviewer ๅฎกๆ‰นๅˆๅนถ | **Rules** | `rules/*.md` | | | **Docs** | `docs/`ใ€`docs//` | ้กน็›ฎๅŸบ็ก€ๆ–‡ๆกฃ๏ผŒ้ป˜่ฎคไธๅ…จ้‡ๅŠ ่ฝฝ๏ผˆๆธ่ฟ›ๅผๆŠซ้œฒ๏ผ‰ใ€‚ๆฒกๆœ‰ไปปไฝ•่ง’่‰ฒๆˆ–้กน็›ฎๅฃฐๆ˜Ž็š„ `docs//` ๅฏนๆ‰€ๆœ‰ไบบๅ…ฑไบซ | | **Agents** | `agents/.yaml`ใ€`agents//.yaml` | | -| **Culture** | `culture.md` | ๅ›ข้˜Ÿไฝฟๅ‘ฝใ€ไปทๅ€ผ่ง‚ไธŽๅไฝœๅ‡†ๅˆ™โ€”โ€”ๆณจๅ…ฅๅ„ Agent ็š„ CLAUDE.md / AGENTS.md๏ผŒๆˆไธบๆฏๆฌกไผš่ฏ็š„่กŒไบ‹ๅบ•่‰ฒ | +| **Culture** | `culture.md` | ๅ›ข้˜Ÿไฝฟๅ‘ฝใ€ไปทๅ€ผ่ง‚ไธŽๅไฝœๅ‡†ๅˆ™โ€”โ€”ๅ†™ๅ…ฅๅ„ Agent ่‡ชๅทฑ็š„ๆŒ‡ไปคๆ–‡ไปถๆˆ–ไผš่ฏ้’ฉๅญ๏ผˆไธๅ†™ๅ…ฅ้กน็›ฎๅ…ฑไบซ็š„ AGENTS.md๏ผ‰๏ผŒๆˆไธบๆฏๆฌกไผš่ฏ็š„่กŒไบ‹ๅบ•่‰ฒ | | **CLAUDE.md** | `claudemd/*.md` | | | **Env** | `env/env.yaml`ใ€`env//env.yaml` | ้€š็”จ็Žฏๅขƒๅ˜้‡ใ€ๅ›ข้˜Ÿ็บงๅผ€ๅ…ณ๏ผ›ไธ่ฆ็›ดๆŽฅๆ”พๅฏ†้’ฅ็š„ๅ€ผ๏ผšๅฏ†้’ฅๅœจ `env/secrets.yaml` ไธญๅชๅฃฐๆ˜Žใ€ไธๅ†™ๅ€ผ | | **Hooks** | `hooks/hooks.yaml`ใ€`hooks//hooks.yaml` | | diff --git a/docs/usage-guide.md b/docs/usage-guide.md index 871991b75..2ca3e5edf 100644 --- a/docs/usage-guide.md +++ b/docs/usage-guide.md @@ -962,7 +962,7 @@ teamai push Most tools get one file per rule in their rules directory. Codex, `codex-internal` and `tcodex` read no rules directory (`.codex/rules/` holds Codex's own `*.rules` command policies), so `pull` writes no rule file for them. In user scope the team rules go into a `` block of the tool's own `AGENTS.md` (`~/.codex/AGENTS.md`, `~/.codex-internal/AGENTS.md`, `~/.tcodex/AGENTS.md`; a `toolRoots` entry moves it), which only that tool reads. In a project their session-start hook adds the project's team rules to each session instead: the project `AGENTS.md` is the owners' file, and other tools with a rules format of their own read it too. Hermes gets the same text in its `SOUL.md` block. Frontmatter is dropped, so a rule with `paths:` applies everywhere there, led by an `Applies to files matching: ` line. Codex runs the hook again after a compaction or a clear, and adds nothing when it resumes a session, which already holds the rules. A subagent Codex spawns gets them through the `SubagentStart` hook. The public Codex runs a new or changed hook only after you approve it in `/hooks`, and until then it gets no project rules. -The culture, shared-instructions and recall blocks follow the same split. In user scope they go to that same `AGENTS.md`, and your own content outside the markers is kept. In a project the session-start hook adds them with the rules, and `pull` leaves the project `AGENTS.md` unchanged. The hook leaves out a block already present in the active project instructions file. Codex reads `AGENTS.override.md` when it exists, otherwise `AGENTS.md`, so a block in a shadowed `AGENTS.md` still reaches Codex through the hook. +The culture, shared-instructions and recall blocks follow the same split. In user scope they go to that same `AGENTS.md`, and your own content outside the markers is kept. In a project the session-start hook adds them with the rules, and `pull` leaves the project `AGENTS.md` unchanged. > A `toolPaths` in the team `teamai.yaml` replaces the built-in defaults whole. A team that sets it should give each Codex-family entry `userScope.claudemd: .codex/AGENTS.md` (`.codex-internal/โ€ฆ`, `.tcodex/โ€ฆ`) for the user-scope rules and blocks, and drop its `rules` path, since Codex never reads that directory. A top-level `claudemd` would put the blocks back in the project `AGENTS.md`, so leave it out. In a project the hook needs only the entry's `settings` path, where it is installed. @@ -1425,7 +1425,7 @@ teamai recall status # View the current effective status (team default + use Append `--dry-run` to `enable` or `disable` to preview the config and managed-artifact changes without writing them. -When disabled, `teamai pull` skips deploying the recall subagent, the recall rules injection block, and the TodoWrite reminder hook. Manually running `teamai recall ` to search is not affected by this switch. +When disabled, `teamai pull` skips deploying the recall subagent and the TodoWrite reminder hook, and removes the recall block from the team instructions. Manually running `teamai recall ` to search is not affected by this switch. ### Knowledge Base Maintenance @@ -1615,7 +1615,7 @@ team: | `team.mission` | string | Team mission | | `team.goals` | string[] | Team goals | -The markdown body after the frontmatter becomes the body content of the team culture guidance, injected as a whole into `CLAUDE.md`. +The markdown body after the frontmatter becomes the body content of the team culture guidance, injected as a whole into each AI tool's instruction target (see [Where the blocks go](#where-the-blocks-go)). ### How it works @@ -1632,19 +1632,78 @@ teamai pull โ”‚ โ”œโ”€ frontmatter โ†’ structured company/team info โ”‚ โ””โ”€ body โ†’ team culture guidance body โ”‚ - โ–ผ Compile into a CLAUDE.md injection block + โ–ผ Compile into an injection block โ”‚ - โ–ผ Inject into each AI tool's CLAUDE.md - โ”œโ”€ ~/.claude/CLAUDE.md - โ”œโ”€ ~/.cursor/CLAUDE.md + โ–ผ Write it to each installed AI tool's instruction target + โ”œโ”€ ~/.claude/CLAUDE.md (user scope) + โ”œโ”€ /.claude/rules/teamai-context.md (project scope) โ””โ”€ ... ``` The injected content sits between the `` and `` markers, is automatically updated on every `pull`, and does not affect any other content in the file. +A pull writes the culture, shared-instructions and recall blocks only to the files of AI tools that are installed, and leaves a file alone when its blocks are already current. Earlier releases wrote these blocks to files that tools now share or that hide other instructions (listed under [Where the blocks go](#where-the-blocks-go)); while no installed tool reads such a file, the next pull removes the teamai blocks from it and names the file in its output. A tool's current file is never cleaned on its own: `teamai uninstall --agent ` removes those blocks. It deletes the file when nothing else is left, unless git tracks it. A block with a missing or repeated marker is left as it is, with a warning to fix it by hand. `teamai pull --dry-run` lists the files a pull would change without writing them. When recall is disabled, the pull removes the recall block. + +#### Where the blocks go + +Two members of the same project can have different roles, so their shared instructions (`claudemd/`) can differ. The project's root `AGENTS.md` holds the instructions the project writes for everyone, so teamai never writes these blocks into it, into `~/AGENTS.md` or `~/.agents/AGENTS.md`, or into a file another tool reads. Each tool gets them in a file of its own or through its session hook: + +| Tool | User scope | Project scope | +|---|---|---| +| Claude Code | `~/.claude/CLAUDE.md` | `.claude/rules/teamai-context.md` | +| claude-internal, tclaude | `.claude-internal/CLAUDE.md`, `.tclaude/CLAUDE.md` in their homes (unchanged) | The same paths under the project (unchanged, unverified) | +| Codex, codex-internal, tcodex | `$CODEX_HOME/AGENTS.md` (and the variants' homes), beside the team rules | Added by the session-start and subagent-start hooks, beside the project's team rules; nothing on resume | +| Copilot CLI | `$COPILOT_HOME/copilot-instructions.md` (unchanged) | `.github/copilot-instructions.md` (unchanged) | +| Cursor | `~/.cursor/rules/teamai-context.mdc` (unverified) | `.cursor/rules/teamai-context.mdc` (unverified) | +| CodeBuddy | `~/.codebuddy/CODEBUDDY.md` | `.codebuddy/rules/teamai-context.md`, one copy shared with WorkBuddy (unverified) | +| WorkBuddy | `~/.workbuddy/rules/teamai-context.md` (unverified) | `.codebuddy/rules/teamai-context.md`, one copy shared with CodeBuddy (unverified) | +| OpenCode | `~/.config/opencode/teamai-context.md`, listed by absolute path in `instructions` of `~/.config/opencode/opencode.json` | `.opencode/teamai-context.md`, listed in `instructions` of `.opencode/opencode.json` | +| Oh My Pi | `~/.omp/agent/RULES.md` | Added to each turn's system prompt by teamai's OMP extension | +| Pi | `~/.pi/agent/AGENTS.md` | Added to each run's system prompt by teamai's Pi extension | +| Hermes | A block in `$HERMES_HOME/SOUL.md`, beside the team rules block (unverified) | A system prompt section from teamai's Hermes plugin (unverified) | + +A team `toolPaths` entry without `rules` keeps its configured `claudemd` for Claude Code, Cursor, CodeBuddy and WorkBuddy, which have no rules directory to take a `teamai-context` file. An entry with only `claudemd` counts as installed when that file's directory exists, and always for a bare file such as `AGENTS.md`. + +*Unverified*: built from the tool's documented or source-read loader, not yet checked in a live session. Claude Code, Oh My Pi, OpenCode and Pi (project scope) were checked in live sessions, from the project root and a subdirectory. A tool with the `teamai-recall` subagent gets a recall block that calls it; a tool without one (Pi, Hermes, OpenClaw) gets a recall block that tells the agent to run `teamai recall` directly. A file several tools share gets the subagent block only when every one of them has the subagent. + +Claude Code loads `.claude/rules/teamai-context.md` from the project root and any subdirectory, and still reads the project's `AGENTS.md` or authored `CLAUDE.md` the way it chose to. Copilot CLI 1.0.89 and later also reads a project's `.claude/rules`, so with both tools installed Copilot can get the blocks twice. + +Both `teamai-context.mdc` files carry `alwaysApply: true`, which Cursor's rule loader reads as always applied. Cursor CLI reads `~/.cursor/rules` when the session starts under your home directory; the Cursor IDE was not checked. + +The CodeBuddy and WorkBuddy rule files carry `alwaysApply: true`, which CodeBuddy's rule parser reads as always applied. Uninstalling one of the two keeps the shared project copy while the other is still installed. + +In a project, teamai installs the Hermes plugin `$HERMES_HOME/plugins/teamai-instructions/` and adds it to `plugins.enabled` in `$HERMES_HOME/config.yaml` (a name you list under `plugins.disabled` stays off). A plugin of that name teamai did not write is left alone, also on uninstall, and `teamai pull` and `teamai doctor` say so. According to Hermes' documentation it builds the section once for each new session from the session's directory and keeps it through compression and resume. A section holds at most 4,000 characters, and all plugin sections together at most 8,000. When this member's instructions for the project are longer, Hermes skips them and `teamai pull` says so: teamai does not cut them or write them to `AGENTS.md`. Outside a project the section is empty, and Hermes may log that it skipped an empty section. + +OpenCode loads a file only when its config lists it in `instructions`. teamai adds that entry only when the target already matches the desired blocks or its update succeeds. A malformed target or a failed write does not activate stale blocks. A failed edit keeps an existing instructions entry, including when malformed recall markers prevent a recall toggle. TeamAI saves ownership before adding a new config entry; a failed state write prevents activation, and a failed config write can be retried. Entries you already listed remain yours. It keeps your other entries and keys; the root `opencode.json` and OpenCode's own `AGENTS.md` files are left alone. While `~/.config/opencode/AGENTS.md` does not exist, OpenCode reads `~/.claude/CLAUDE.md` instead; when Claude Code gets the user blocks there, OpenCode already has them, so teamai writes no second user copy for OpenCode and says so in the pull output. Blocks left there by a Claude Code you excluded count too, since OpenCode reads them all the same; the pull then warns that nothing keeps them current. A config file teamai cannot parse as JSON (for example one with comments) is left unchanged with a warning; add the entry by hand. + +Oh My Pi reads `RULES.md` as an always-applied rule beside its single user context file. In project scope teamai's OMP extension asks `teamai` for the blocks when the session starts and adds them to each turn's system prompt, from the project root and any subdirectory. Without that extension (for example with hooks removed), an Oh My Pi project session gets no team blocks. Prompts the HTTP local agent delivers for a project reach Pi, Oh My Pi and Hermes the same way, through their extension or plugin, and the Codex family through its session-start and subagent-start hooks. Pi and Oh My Pi wait for foreground session-start dispatch, including HTTP prompt sync, before caching the project instructions for the first prompt. Codex reads its HTTP prompt cache after the same sync, before returning SessionStart context. + +A pull from an earlier release may have left these blocks in a file listed below. A pull removes each block only after its replacement was resolved and delivered to every installed tool that wrote that file. An unreadable or invalid culture source keeps the old culture block even if shared instructions and recall sync successfully. Failed target writes, foreign files, missing extensions or disabled plugins keep the old blocks for a retry. Excluded tools' current and retired files stay unchanged and are excluded from doctor's stale-instruction check. + +HTTP prompt commands verify the current destinations of all installed former writers, including delivery from previous commands, before removing the retired shared-instructions block. A destination holding an older prompt does not count as delivered. HTTP cleanup preserves culture and recall blocks, which those commands do not replace. + +While a native project instruction file still contains a TeamAI block, the session hook skips that block, including a cached HTTP prompt, to avoid adding a second member selection. Other blocks still reach the hook. Delivery resumes after the retained block is cleaned. Codex respects `AGENTS.override.md` precedence, and Oh My Pi respects `.omp/AGENTS.md`. Doctor reports incomplete or repeated markers in retired files; repair those markers before retrying pull. + +The pull names each file it changes: + +- Claude Code, project scope: `.claude/CLAUDE.md` +- CodeBuddy, project scope: `.codebuddy/CODEBUDDY.md` +- WorkBuddy: `~/AGENTS.md` and the project `AGENTS.md` +- Hermes: `~/AGENTS.md` +- Oh My Pi: `~/.omp/agent/AGENTS.md` and `.omp/AGENTS.md`. Oh My Pi reads one context file per level, so these hid `~/.agents/AGENTS.md` and the project's `AGENTS.md`. +- Pi: the project `AGENTS.md` +- Codex family: the project `AGENTS.md`, when a team's `toolPaths` or an earlier build pointed Codex there +- Any tool whose file changed: the `claudemd` path the team's `toolPaths` sets for it, unless another tool's blocks go there now + +`teamai doctor` checks that each installed tool can load these blocks: that each file holds the current blocks, that OpenCode's config lists its file, that the Pi or Oh My Pi extension and the Hermes plugin are installed and enabled, that the Hermes section fits its limit, and that no file an earlier release wrote still holds blocks. + +If a requested block has incomplete or duplicated markers, the entire file stays unchanged, including its other managed blocks. Fix the named markers, then run `teamai pull` again. + +A file named like a teamai target that teamai did not write is left alone and not listed in OpenCode's `instructions` (an entry you listed for it stays), and the pull warns about it. A team rule named `teamai-context` is not delivered, since it would land on that file; the pull names it, and removes a copy an earlier release delivered unless you changed it. teamai does not change `.gitignore`, `.git/info/exclude` or the git index. A team that wants to keep these files out of commits excludes them itself. + ### Viewing the result -After pulling, you can view the AI tool's CLAUDE.md directly: +After pulling, you can view an AI tool's instruction file directly, for example Claude Code's user file: ```bash teamai pull @@ -1699,6 +1758,8 @@ When using `teamai init --http `, the endpoint must implement the follo } ``` +Removing the final HTTP prompt is acknowledged as `failed` when its target cannot be updated. The cached prompt and manifest record remain available for a retry after repairing the markers or file permissions. + The backend may push an **`apply_model_config`** task whose `cmd` is JSON. Both the documented candidate-set shape and the legacy single-model shape are accepted. `{"models":[...]}` is a full snapshot; a direct model object is an incremental upsert. @@ -2154,8 +2215,8 @@ Team hooks still come from the team's `hooks/hooks.yaml`: edit that source in th [Pi](https://github.com/badlogic/pi-mono/tree/main/packages/coding-agent) is supported through its documented skills, instruction, and extension surfaces: - **Scopes.** Project skills and TeamAI-managed rules are written to `.pi/skills/` and `.pi/rules/`. User-scope copies use `~/.pi/agent/skills/` and `~/.pi/agent/rules/`. -- **Instructions.** Project instructions use `AGENTS.md`; user instructions use `~/.pi/agent/AGENTS.md`. Pi also accepts `CLAUDE.md` as a project instruction file, but TeamAI keeps the canonical TeamAI block in `AGENTS.md`. -- **Hooks.** TeamAI generates one user-scoped `teamai-hooks.ts` under `~/.pi/agent/extensions/`. It maps `session_start` โ†’ session-start, `before_agent_start` โ†’ prompt-submit, and `agent_settled` โ†’ stop; `tool_execution_start` caches the tool's input, and `tool_execution_end` dispatches post-tool-use forwarding that cached input as `tool_input`, plus the result's text as `tool_response` and a `tool_status` from its error flag. Every event carries the Pi session id (`ctx.sessionManager.getSessionId()`), the same id Pi's bash tool exports as `PI_SESSION_ID`, so a `teamai recall` run there joins the session its hooks carry and upvote **adoption** runs for Pi. Pi loads both user and project extension roots, so TeamAI never creates a project copy โ€” a second copy would double-dispatch every event, the same single-copy policy as the OMP adapter. An older TeamAI-managed project copy is removed during the next sync, and injection never overwrites a same-named file that lacks the TeamAI marker. Pi has no settings file for self mode to commit, so a fresh clone still needs one `teamai init`/`pull` on that machine before Pi hooks are active there. Any targeted removal โ€” the explicit `teamai hooks remove` command, or a scoped `teamai uninstall --agent pi` โ€” deletes this shared extension outright, the same single-file removal semantics as the OMP adapter: Pi has no way to scope one shared file to a single project, so it doesn't pretend to preserve it for other projects while the extension keeps firing for this one anyway; files without the TeamAI marker are never removed. `teamai hooks list` always reports this global path. Pi profile overrides (`PI_CODING_AGENT_DIR` / `PI_CONFIG_DIR`), which relocate the agent directory, are not supported for hooks โ€” same as the OMP adapter โ€” and the default `~/.pi/agent/` layout is used. Model profiles are separate and do read `PI_CODING_AGENT_DIR`. Because the extension is one shared file rather than a per-project one, a scoped removal is not durable in a multi-project setup: the next `teamai init`/`pull` in any other scope where Pi is still enabled re-creates it, and hook dispatch has no per-project exclusion check, so hooks can resume firing in the project that was just uninstalled from. This is the same trade-off the OMP adapter already ships with. +- **Instructions.** Pi reads the project's own `AGENTS.md` (or `CLAUDE.md`); TeamAI leaves it unchanged. User-scope team instructions go to `~/.pi/agent/AGENTS.md`. In a project, the TeamAI Pi extension asks `teamai` for the member's team instructions when the session starts and adds them to the system prompt of each run. +- **Hooks.** TeamAI generates one user-scoped `teamai-hooks.ts` under `~/.pi/agent/extensions/`. It maps `session_start` โ†’ session-start, `before_agent_start` โ†’ prompt-submit, and `agent_settled` โ†’ stop; `tool_execution_start` caches the tool's input, and `tool_execution_end` dispatches post-tool-use forwarding that cached input as `tool_input`, plus the result's text as `tool_response` and a `tool_status` from its error flag. Every event carries the Pi session id (`ctx.sessionManager.getSessionId()`), the same id Pi's bash tool exports as `PI_SESSION_ID`, so a `teamai recall` run there joins the session its hooks carry and upvote **adoption** runs for Pi. Pi loads both user and project extension roots, so TeamAI never creates a project copy โ€” a second copy would double-dispatch every event, the same single-copy policy as the OMP adapter. An older TeamAI-managed project copy is removed during the next sync, and injection never overwrites a same-named file that lacks the TeamAI marker. Pi has no settings file for self mode to commit, so a fresh clone still needs one `teamai init`/`pull` on that machine before Pi hooks are active there. The explicit `teamai hooks remove` command and user-scope `teamai uninstall --agent pi` delete this shared extension. Project uninstall preserves it for other projects and removes any legacy project copy; files without the TeamAI marker are never removed. `teamai hooks list` always reports this global path. Pi profile overrides (`PI_CODING_AGENT_DIR` / `PI_CONFIG_DIR`), which relocate the agent directory, are not supported for hooks โ€” same as the OMP adapter โ€” and the default `~/.pi/agent/` layout is used. Model profiles are separate and do read `PI_CODING_AGENT_DIR`. The shared extension remains installed after project uninstall; instruction dispatch checks the project's tool exclusion before adding its instructions. - **Team hooks boundary.** The Pi adapter installs only the built-in lifecycle bridge. Custom team hooks and built-in hook overrides declared in `hooks/hooks.yaml` are skipped with a warning. Full team-hook and per-project ownership semantics require a separate cross-adapter design and are deferred to a follow-up PR. - **Server-pushed agent hooks.** HTTP-source hooks are installed as `teamai-agent-.ts` extensions in the same global extension directory. Unsupported lifecycle events are skipped with a warning. - **MCP (Pi 0.99.0+).** Supports stdio and streamable HTTP; SSE is skipped. User configuration goes to `~/.pi/agent/mcp.json`, project configuration to `.pi/mcp.json`; Pi loads project configuration only after trusting the project. The native `codemode` default is retained, without forcing direct exposure; timeout values in `mcp.yaml` are converted from milliseconds to seconds. Local exposure/enabled changes to managed entries survive unchanged team definitions but are replaced when the team definition changes; doctor compares complete entries and reports these local differences. An extension taking over `/mcp` can disable built-in MCP; remove that extension to use the built-in support. @@ -2183,7 +2244,7 @@ These paths are verified against the ZCode desktop app: profiles created in its ### Oh My Pi -Oh My Pi (OMP) is available as a built-in target. TeamAI deploys skills, rules, and subagents to OMP's native directories โ€” `.omp/skills/`, `.omp/rules/`, and `.omp/agents/` at project scope, and `~/.omp/agent/skills/`, `~/.omp/agent/rules/`, and `~/.omp/agent/agents/` at user scope (user-scope resources live under the agent directory `~/.omp/agent/`, a different prefix from the project one, so TeamAI switches prefixes with the scope). Instructions (`claudemd`) deploy to the matching `AGENTS.md`, and MCP servers merge into `~/.omp/agent/mcp.json` / `/.omp/mcp.json` (Claude `mcpServers` shape โ€” see the MCP section above). Skills are one-level `/SKILL.md` bundles and TeamAI fills in a `description` on sync, which OMP's native skill provider requires to discover a skill. These paths follow OMP's documented discovery layout (verified against OMP 18.2.5). Hooks ride OMP's extension runner: `teamai pull` writes a single generated extension to `~/.omp/agent/extensions/teamai-hooks.ts` (never a project copy โ€” OMP auto-loads both roots and would double-dispatch every event), which forwards OMP's `session_start` / `session_stop` / `before_agent_start` / `tool_result` events to the same `teamai hook-dispatch` entry point every other agent uses, gated on the session `cwd`. Every event carries the OMP session id (`ctx.sessionManager.getSessionId()`; a subagent has its own), and `tool_result` also the tool's text output and a status from `isError`, so upvote **adoption** runs for OMP's main agent: OMP sets no session variable in its shell, so a recall joins the session of the `bash` call that ran it, and a `read` with a line selector (`x.md:50-200`, `x.md:raw`) counts as a read of the file. From OMP 18.3.2 a subagent's events also carry its `ctx.agent` id and name, so the `teamai-recall` subagent's own reads never count. A subagent's session file sits under its parent's, whose header names the parent session, so the extension links the two on the subagent's tool calls, and a doc the main agent opens after a subagent's recall is upvoted (verified against OMP 18.4.8). The `session_stop` handler returns nothing, so a dispatch can never force a session continuation, and there is no matcher-scoped post-tool-use pass because OMP's tool ids are lowercase (`bash`, `read`, โ€ฆ) and it has no `Skill` / `TodoWrite` tool. `teamai uninstall` removes the extension. OMP profiles (`OMP_PROFILE` / `PI_CODING_AGENT_DIR` / `PI_CONFIG_DIR`), which relocate the agent directory, are not supported; the default `~/.omp/agent/` layout is used. +Oh My Pi (OMP) is available as a built-in target. TeamAI deploys skills, rules, and subagents to OMP's native directories โ€” `.omp/skills/`, `.omp/rules/`, and `.omp/agents/` at project scope, and `~/.omp/agent/skills/`, `~/.omp/agent/rules/`, and `~/.omp/agent/agents/` at user scope (user-scope resources live under the agent directory `~/.omp/agent/`, a different prefix from the project one, so TeamAI switches prefixes with the scope). Team instructions go to `~/.omp/agent/RULES.md` in user scope and, in project scope, into each turn's system prompt through the extension below (see [Where the blocks go](#where-the-blocks-go)), and MCP servers merge into `~/.omp/agent/mcp.json` / `/.omp/mcp.json` (Claude `mcpServers` shape โ€” see the MCP section above). Skills are one-level `/SKILL.md` bundles and TeamAI fills in a `description` on sync, which OMP's native skill provider requires to discover a skill. These paths follow OMP's documented discovery layout (verified against OMP 18.2.5). Hooks ride OMP's extension runner: `teamai pull` writes a single generated extension to `~/.omp/agent/extensions/teamai-hooks.ts` (never a project copy โ€” OMP auto-loads both roots and would double-dispatch every event), which forwards OMP's `session_start` / `session_stop` / `before_agent_start` / `tool_result` events to the same `teamai hook-dispatch` entry point every other agent uses, gated on the session `cwd`. In a project session it also asks for the member's team instructions at `session_start` and appends them to the system prompt in `before_agent_start`. Every event carries the OMP session id (`ctx.sessionManager.getSessionId()`; a subagent has its own), and `tool_result` also the tool's text output and a status from `isError`, so upvote **adoption** runs for OMP's main agent: OMP sets no session variable in its shell, so a recall joins the session of the `bash` call that ran it, and a `read` with a line selector (`x.md:50-200`, `x.md:raw`) counts as a read of the file. From OMP 18.3.2 a subagent's events also carry its `ctx.agent` id and name, so the `teamai-recall` subagent's own reads never count. A subagent's session file sits under its parent's, whose header names the parent session, so the extension links the two on the subagent's tool calls, and a doc the main agent opens after a subagent's recall is upvoted (verified against OMP 18.4.8). The `session_stop` handler returns nothing, so a dispatch can never force a session continuation, and there is no matcher-scoped post-tool-use pass because OMP's tool ids are lowercase (`bash`, `read`, โ€ฆ) and it has no `Skill` / `TodoWrite` tool. User-scope `teamai uninstall` removes the extension; project uninstall preserves it for other projects. A same-named file without the TeamAI marker is never overwritten or removed, as with Pi. OMP profiles (`OMP_PROFILE` / `PI_CODING_AGENT_DIR` / `PI_CONFIG_DIR`), which relocate the agent directory, are not supported; the default `~/.omp/agent/` layout is used. ### DeepSeek Harness @@ -2777,6 +2838,8 @@ A full user-scope `teamai uninstall` restores managed model settings first and s `teamai uninstall` intelligently cleans up all teamai-managed resources, **preserving anything you created yourself**. +A targeted project exclusion also requires confirmation or `--force`, even when there are no local files to remove. `--dry-run` and a declined confirmation leave the project config unchanged. + ```bash # Preview every managed path that will be removed (no actual changes) teamai uninstall --dry-run @@ -2794,7 +2857,7 @@ teamai uninstall --agent claude What gets removed: - TeamAI-managed model settings are restored first when ownership is still intact - teamai hooks in AI tool settings -- The teamai blocks in CLAUDE.md and AGENTS.md (your own content is preserved) +- The teamai blocks (culture, shared instructions, recall, and Codex's team rules) in each tool's instruction file, and the files an earlier release wrote them to (your own content is preserved; a `teamai-context` file teamai wrote is removed whole, and OpenCode's `instructions` entry for it goes too when teamai added it, even when your own text keeps the file or the file is gone; an entry you listed yourself stays) - Team-synced skills, including OpenClaw workspace skills (your own skills are preserved) - Team-synced rules, including the copies older releases left in `.codex/rules/`, also of rules the team has since removed. Cleanup follows the recorded `toolRoots` location and the publisher's local filenames. A copy there you edited is kept and named in a warning. A removed rule's copy is deleted only if it matches its recorded delivery hash; without that record, it is kept and named too. Codex's `*.rules` files are kept - Team-synced custom agents and CLI built-in agents (your own agents are preserved) @@ -2803,15 +2866,21 @@ What gets removed: ### Uninstall a single tool (`--agent `) -`--agent ` removes only that tool's teamai resources (hooks, CLAUDE.md block, skills, rules, team-synced custom agents, and built-in agents). The tool name is a key of `toolPaths` (e.g. `claude`, `codex`, `codebuddy`) and is matched case-insensitively. An unknown tool name aborts without deleting anything, lists the available tools, and exits with a non-zero status. +`--agent ` removes only that tool's teamai resources (hooks, team instruction blocks, skills, rules, team-synced custom agents, and built-in agents). The tool name is a key of `toolPaths` (e.g. `claude`, `codex`, `codebuddy`) and is matched case-insensitively. An unknown tool name aborts without deleting anything, lists the available tools, and exits with a non-zero status. + +An instructions file several tools map is cleaned per block: a teamai block stays while a remaining tool on that file still writes it. The common case is `.codebuddy/rules/teamai-context.md`, which CodeBuddy and WorkBuddy share: `--agent workbuddy` keeps it while CodeBuddy is installed. A file an earlier release wrote the blocks to, such as the project `AGENTS.md`, is read by no tool now, so its teamai blocks go and your own text stays. A file teamai created goes with its last block; an instructions file you had before stays, even an empty one. A configured `claudemd` remains a member file even when its basename is `teamai-context.md`. + +Shared resources (the env block, docs directory, and `~/.teamai/`) are removed **only when the target itself has teamai resources AND is the last tool still using teamai** โ€” otherwise they are kept for the remaining tools. Targeting a tool with no local resources leaves shared resources in place, even if it is the only tool. Project uninstall still records the exclusion for Pi, Oh My Pi, Hermes and the Codex family, whose instruction channels are global. + +An enabled, installed Pi, Oh My Pi, Hermes or project Codex keeps the project state in use through its global delivery channel, even without a project-local tool directory. Uninstalling another tool preserves that state so the remaining tool can still deliver this project's instructions. -An instructions file several tools map is cleaned per block: a teamai block stays while a remaining tool on that file still writes it. The project `AGENTS.md` is the common case: with Pi still enabled, `--agent workbuddy` removes the recall block and keeps the culture and shared-instructions blocks Pi writes. The Codex family writes nothing there. A file teamai created goes with its last block; an instructions file you had before stays, even an empty one. +If removing an OpenCode entry added by teamai fails, uninstall exits with an error and keeps the shared data directory and ownership record, even when OpenCode is the last tool. Repair the config or its permissions, then retry the same uninstall command. -Shared resources (the env block, docs directory, and `~/.teamai/`) are removed **only when the target itself has teamai resources AND is the last tool still using teamai** โ€” otherwise they are kept for the remaining tools. (So targeting a tool that has no teamai resources of its own is a no-op and leaves shared resources in place, even if it happens to be the only tool.) +Project uninstall keeps Pi's and Oh My Pi's global extensions, Hermes' global plugin and configuration, the Codex family's user-level hooks and server-pushed agent hooks, which the user scope, the HTTP agent or another project on this machine may use, and names them in its summary. When none uses them, run `teamai hooks remove` in the project before uninstalling: it removes them. Targeted project Codex uninstall keeps the project config to record its exclusion and removes only project-owned resources and legacy hook copies. A targeted uninstall excludes the tool in this project's config when that config survives. User-scope uninstall removes these global delivery channels. -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 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, team instruction blocks, or hooks. Retained global adapters also skip HTTP sync and cached HTTP prompt injection for that excluded tool. 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, `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. +The same `enabledAgents` whitelist (from `init --agent`) also gates CLI built-in skills/rules/agents and team instruction blocks: 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 2d3f47adf..829a9e48a 100644 --- a/docs/usage-guide.zh-CN.md +++ b/docs/usage-guide.zh-CN.md @@ -883,7 +883,7 @@ teamai push ๅคงๅคšๆ•ฐๅทฅๅ…ทๅœจ่‡ชๅทฑ็š„ rules ็›ฎๅฝ•ไธญไธบๆฏๆก rule ๅพ—ๅˆฐไธ€ไธชๆ–‡ไปถใ€‚Codexใ€`codex-internal` ๅ’Œ `tcodex` ไธ่ฏปๅ– rules ็›ฎๅฝ•๏ผˆ`.codex/rules/` ๅญ˜ๆ”พ็š„ๆ˜ฏ Codex ่‡ชๅทฑ็š„ `*.rules` ๅ‘ฝไปค็ญ–็•ฅๆ–‡ไปถ๏ผ‰๏ผŒๅ› ๆญค `pull` ไธไธบๅฎƒไปฌๅ†™ไปปไฝ• rule ๆ–‡ไปถใ€‚user scope ไธ‹๏ผŒๅ›ข้˜Ÿ rule ๅ†™ๅ…ฅ่ฏฅๅทฅๅ…ท่‡ชๅทฑ็š„ `AGENTS.md`๏ผˆ`~/.codex/AGENTS.md`ใ€`~/.codex-internal/AGENTS.md`ใ€`~/.tcodex/AGENTS.md`๏ผ›`toolRoots` ๆก็›ฎๅฏๆ”นๅ˜ๅ…ถไฝ็ฝฎ๏ผ‰ไธญ็š„ `` ๅŒบๅ—๏ผŒๅชๆœ‰่ฏฅๅทฅๅ…ท่ฏปๅ–่ฟ™ไธชๆ–‡ไปถใ€‚ๅœจ้กน็›ฎไธญ๏ผŒๆ”น็”ฑๅฎƒไปฌ็š„ session-start hook ๆŠŠ้กน็›ฎ็š„ๅ›ข้˜Ÿ rule ๅŠ ๅ…ฅๆฏไธชไผš่ฏ๏ผš้กน็›ฎ `AGENTS.md` ๅฑžไบŽ้กน็›ฎ็ปดๆŠค่€…๏ผŒๅ…ถไป–ๆ‹ฅๆœ‰่‡ชๅทฑ rules ๆ ผๅผ็š„ๅทฅๅ…ทไนŸไผš่ฏปๅ–ๅฎƒใ€‚Hermes ็š„ `SOUL.md` ๅŒบๅ—ๅพ—ๅˆฐๅŒๆ ท็š„ๅ†…ๅฎนใ€‚frontmatter ไผš่ขซๅŽปๆމ๏ผŒๆ‰€ไปฅๅธฆ `paths:` ็š„ rule ๅœจ่ฟ™้‡Œๅฏนๆ‰€ๆœ‰ๆ–‡ไปถ็”Ÿๆ•ˆ๏ผŒๅนถไปฅไธ€่กŒ `Applies to files matching: ` ๅผ€ๅคดใ€‚Codex ๅœจๅŽ‹็ผฉไธŠไธ‹ๆ–‡ๆˆ– clear ไน‹ๅŽไผšๅ†ๆฌก่ฟ่กŒ่ฏฅ hook๏ผ›ๆขๅคไผš่ฏๆ—ถไธๆทปๅŠ ไปปไฝ•ๅ†…ๅฎน๏ผŒๅ› ไธบไผš่ฏไธญๅทฒๅŒ…ๅซ่ฟ™ไบ› ruleใ€‚Codex ๅฏๅŠจ็š„ๅญ agent ้€š่ฟ‡ `SubagentStart` hook ่Žทๅพ—ๅฎƒไปฌใ€‚ๅ…ฌๅผ€็‰ˆ Codex ๅชๅœจไฝ ไบŽ `/hooks` ไธญๆ‰นๅ‡†ๆ–ฐๅขžๆˆ–ๆ”นๅŠจ็š„ hook ๅŽๆ‰่ฟ่กŒๅฎƒ๏ผŒๅœจๆญคไน‹ๅ‰ไธไผšๅพ—ๅˆฐ้กน็›ฎ็š„ ruleใ€‚ -cultureใ€ๅ…ฑไบซๆŒ‡ไปคๅ’Œ recall ๅŒบๅ—้‡‡็”จๅŒๆ ท็š„ๅˆ’ๅˆ†ใ€‚user scope ไธ‹ๅฎƒไปฌๅ†™ๅ…ฅๅŒไธ€ไธช `AGENTS.md`๏ผŒๆ ‡่ฎฐไน‹ๅค–ไฝ ่‡ชๅทฑ็š„ๅ†…ๅฎนไฟๆŒไธๅ˜ใ€‚ๅœจ้กน็›ฎไธญ๏ผŒsession-start hook ๆŠŠๅฎƒไปฌไธŽ rule ไธ€่ตทๅŠ ๅ…ฅไผš่ฏ๏ผŒ`pull` ไธๆ”นๅŠจ้กน็›ฎ `AGENTS.md`ใ€‚hook ไผš็œ็•ฅ้กน็›ฎๅฝ“ๅ‰็”Ÿๆ•ˆ็š„ๆŒ‡ไปคๆ–‡ไปถไธญๅทฒๆœ‰็š„ๅŒบๅ—ใ€‚Codex ๅœจ `AGENTS.override.md` ๅญ˜ๅœจๆ—ถ่ฏปๅ–ๅฎƒ๏ผŒๅฆๅˆ™่ฏปๅ– `AGENTS.md`๏ผŒๅ› ๆญค่ขซ้ฎ่”ฝ็š„ `AGENTS.md` ไธญ็š„ๅŒบๅ—ไปไผš้€š่ฟ‡ hook ้€่พพใ€‚ +cultureใ€ๅ…ฑไบซๆŒ‡ไปคๅ’Œ recall ๅŒบๅ—้‡‡็”จๅŒๆ ท็š„ๅˆ’ๅˆ†ใ€‚user scope ไธ‹ๅฎƒไปฌๅ†™ๅ…ฅๅŒไธ€ไธช `AGENTS.md`๏ผŒๆ ‡่ฎฐไน‹ๅค–ไฝ ่‡ชๅทฑ็š„ๅ†…ๅฎนไฟๆŒไธๅ˜ใ€‚ๅœจ้กน็›ฎไธญ๏ผŒsession-start hook ๆŠŠๅฎƒไปฌไธŽ rule ไธ€่ตทๅŠ ๅ…ฅไผš่ฏ๏ผŒ`pull` ไธๆ”นๅŠจ้กน็›ฎ `AGENTS.md`ใ€‚ > ๅ›ข้˜Ÿ `teamai.yaml` ไธญ็š„ `toolPaths` ไผšๆ•ดไฝ“ๆ›ฟๆขๅ†…็ฝฎ้ป˜่ฎคๅ€ผใ€‚่ฎพ็ฝฎไบ†ๅฎƒ็š„ๅ›ข้˜Ÿๅบ”ไธบๆฏไธช Codex ็ณปๆก็›ฎๅŠ ไธŠ `userScope.claudemd: .codex/AGENTS.md`๏ผˆ`.codex-internal/โ€ฆ`ใ€`.tcodex/โ€ฆ`๏ผ‰๏ผŒ็”จไบŽ user scope ็š„ rule ๅ’ŒๅŒบๅ—๏ผŒๅนถๅŽปๆމๅ…ถ `rules` ่ทฏๅพ„๏ผŒๅ› ไธบ Codex ไปŽไธ่ฏปๅ–่ฏฅ็›ฎๅฝ•ใ€‚้กถๅฑ‚็š„ `claudemd` ไผšๆŠŠๅŒบๅ—้‡ๆ–ฐๅ†™่ฟ›้กน็›ฎ `AGENTS.md`๏ผŒๆ‰€ไปฅไธ่ฆ่ฎพ็ฝฎใ€‚ๅœจ้กน็›ฎไธญ๏ผŒhook ๅช้œ€่ฆ่ฏฅๆก็›ฎ็š„ `settings` ่ทฏๅพ„๏ผŒๅฎƒๅฎ‰่ฃ…ๅœจ้‚ฃ้‡Œใ€‚ @@ -1303,7 +1303,7 @@ teamai recall status # ๆŸฅ็œ‹ๅฝ“ๅ‰็”Ÿๆ•ˆ็Šถๆ€๏ผˆๅ›ข้˜Ÿ้ป˜่ฎค + ็”จๆˆท่ฆ† ๅœจ `enable` ๆˆ– `disable` ๅŽๆทปๅŠ  `--dry-run`๏ผŒๅฏ้ข„่งˆ้…็ฝฎๅ’Œๆ‰˜็ฎกๆ–‡ไปถ็š„ๅ˜ๅŒ–๏ผŒไธไผšๅ†™ๅ…ฅ็ฃ็›˜ใ€‚ -ๅ…ณ้—ญๅŽ๏ผŒ`teamai pull` ๅฐ†่ทณ่ฟ‡้ƒจ็ฝฒ recall subagentใ€recall rules ๆณจๅ…ฅๅ—ๅ’Œ TodoWrite ๆ้†’ hookใ€‚ๆ‰‹ๅŠจๆ‰ง่กŒ `teamai recall ` ๆœ็ดขไธๅ—ๆญคๅผ€ๅ…ณๅฝฑๅ“ใ€‚ +ๅ…ณ้—ญๅŽ๏ผŒ`teamai pull` ๅฐ†่ทณ่ฟ‡้ƒจ็ฝฒ recall subagent ๅ’Œ TodoWrite ๆ้†’ hook๏ผŒๅนถไปŽๅ›ข้˜ŸๆŒ‡ไปคไธญ็งป้™ค recall ๅ—ใ€‚ๆ‰‹ๅŠจๆ‰ง่กŒ `teamai recall ` ๆœ็ดขไธๅ—ๆญคๅผ€ๅ…ณๅฝฑๅ“ใ€‚ ### ็Ÿฅ่ฏ†ๅบ“็ปดๆŠค @@ -1493,7 +1493,7 @@ team: | `team.mission` | string | ๅ›ข้˜Ÿไฝฟๅ‘ฝ | | `team.goals` | string[] | ๅ›ข้˜Ÿ็›ฎๆ ‡ | -frontmatter ไน‹ๅŽ็š„ markdown body ้ƒจๅˆ†ไผšไฝœไธบๅ›ข้˜Ÿๆ–‡ๅŒ–ๆŒ‡ๅผ•็š„ๆญฃๆ–‡ๅ†…ๅฎน๏ผŒๆ•ดไฝ“ๆณจๅ…ฅๅˆฐ CLAUDE.md ไธญใ€‚ +frontmatter ไน‹ๅŽ็š„ markdown body ้ƒจๅˆ†ไผšไฝœไธบๅ›ข้˜Ÿๆ–‡ๅŒ–ๆŒ‡ๅผ•็š„ๆญฃๆ–‡ๅ†…ๅฎน๏ผŒๆ•ดไฝ“ๆณจๅ…ฅๅˆฐๅ„ AI ๅทฅๅ…ท็š„ๆŒ‡ไปค็›ฎๆ ‡ๆ–‡ไปถ๏ผˆ่ง[่ฟ™ไบ›ๅ—ๅ†™ๅˆฐๅ“ช้‡Œ](#่ฟ™ไบ›ๅ—ๅ†™ๅˆฐๅ“ช้‡Œ)๏ผ‰ใ€‚ ### ๅทฅไฝœๅŽŸ็† @@ -1510,19 +1510,78 @@ teamai pull โ”‚ โ”œโ”€ frontmatter โ†’ ็ป“ๆž„ๅŒ–ๅ…ฌๅธ/ๅ›ข้˜Ÿไฟกๆฏ โ”‚ โ””โ”€ body โ†’ ๅ›ข้˜Ÿๆ–‡ๅŒ–ๆŒ‡ๅผ•ๆญฃๆ–‡ โ”‚ - โ–ผ ็ผ–่ฏ‘ไธบ CLAUDE.md ๆณจๅ…ฅๅ— + โ–ผ ็ผ–่ฏ‘ไธบๆณจๅ…ฅๅ— โ”‚ - โ–ผ ๆณจๅ…ฅๅˆฐๅ„ AI ๅทฅๅ…ท็š„ CLAUDE.md - โ”œโ”€ ~/.claude/CLAUDE.md - โ”œโ”€ ~/.cursor/CLAUDE.md + โ–ผ ๅ†™ๅ…ฅๆฏไธชๅทฒๅฎ‰่ฃ… AI ๅทฅๅ…ท็š„ๆŒ‡ไปค็›ฎๆ ‡ + โ”œโ”€ ~/.claude/CLAUDE.md ๏ผˆ็”จๆˆท่Œƒๅ›ด๏ผ‰ + โ”œโ”€ /.claude/rules/teamai-context.md ๏ผˆ้กน็›ฎ่Œƒๅ›ด๏ผ‰ โ””โ”€ ... ``` ๆณจๅ…ฅ็š„ๅ†…ๅฎนไฝไบŽ `` ๅ’Œ `` ๆ ‡่ฎฐไน‹้—ด๏ผŒๆฏๆฌก pull ๆ—ถ่‡ชๅŠจๆ›ดๆ–ฐ๏ผŒไธไผšๅฝฑๅ“ๆ–‡ไปถไธญ็š„ๅ…ถไป–ๅ†…ๅฎนใ€‚ +pull ๅชๆŠŠๅ›ข้˜Ÿๆ–‡ๅŒ–ใ€ๅ…ฑไบซๆŒ‡ไปคๅ’Œ recall ๅ—ๅ†™ๅ…ฅๅทฒๅฎ‰่ฃ… AI ๅทฅๅ…ท็š„ๆ–‡ไปถ๏ผ›ๅ—ๅ†…ๅฎนๆœชๅ˜ๅŒ–ๆ—ถไธๆ”นๅ†™ๆ–‡ไปถใ€‚ๆ—ฉๆœŸ็‰ˆๆœฌๆŠŠ่ฟ™ไบ›ๅ—ๅ†™่ฟ›ไบ†ๅฆ‚ไปŠ็”ฑๅคšไธชๅทฅๅ…ทๅ…ฑ็”จๆˆ–ไผš้ฎ่”ฝๅ…ถไป–ๆŒ‡ไปค็š„ๆ–‡ไปถ๏ผˆ่ง[่ฟ™ไบ›ๅ—ๅ†™ๅˆฐๅ“ช้‡Œ](#่ฟ™ไบ›ๅ—ๅ†™ๅˆฐๅ“ช้‡Œ)๏ผ‰๏ผ›ๅช่ฆๆฒกๆœ‰ๅทฒๅฎ‰่ฃ…็š„ๅทฅๅ…ท่ฏปๅ–่ฟ™็ฑปๆ–‡ไปถ๏ผŒไธ‹ไธ€ๆฌก pull ๅฐฑไผš็งป้™คๅ…ถไธญ็š„ teamai ๅ—๏ผŒๅนถๅœจ่พ“ๅ‡บไธญๅˆ—ๅ‡บ่ฏฅๆ–‡ไปถใ€‚ๅทฅๅ…ทๅฝ“ๅ‰็š„็›ฎๆ ‡ๆ–‡ไปถไธไผš่ขซๅ•็‹ฌๆธ…็†๏ผš`teamai uninstall --agent ` ไผš็งป้™คๅ…ถไธญ็š„ๅ—ใ€‚ๆ–‡ไปถไธญๆฒกๆœ‰ๅ…ถไป–ๅ†…ๅฎนๆ—ถไธ€ๅนถๅˆ ้™ค่ฏฅๆ–‡ไปถ๏ผŒไฝ† git ่ทŸ่ธช็š„ๆ–‡ไปถไธไผš่ขซๅˆ ้™คใ€‚ๆ ‡่ฎฐ็ผบๅคฑๆˆ–้‡ๅค็š„ๅ—ไฟๆŒๅŽŸๆ ท๏ผŒๅนถๆ็คบๆ‰‹ๅŠจไฟฎๅคใ€‚`teamai pull --dry-run` ๅชๅˆ—ๅ‡บๅฐ†่ฆไฟฎๆ”น็š„ๆ–‡ไปถ๏ผŒไธๅ†™ๅ…ฅใ€‚recall ๅ…ณ้—ญๆ—ถ๏ผŒpull ไผš็งป้™ค recall ๅ—ใ€‚ + +#### ่ฟ™ไบ›ๅ—ๅ†™ๅˆฐๅ“ช้‡Œ + +ๅŒไธ€้กน็›ฎ็š„ไธคๅๆˆๅ‘˜ๅฏ่ƒฝ่ง’่‰ฒไธๅŒ๏ผŒๅ› ๆญคไป–ไปฌ็š„ๅ…ฑไบซๆŒ‡ไปค๏ผˆ`claudemd/`๏ผ‰ๅฏ่ƒฝไธๅŒใ€‚้กน็›ฎๆ น็›ฎๅฝ•็š„ `AGENTS.md` ๅญ˜ๆ”พ้กน็›ฎไธบๆ‰€ๆœ‰ไบบ็ผ–ๅ†™็š„ๆŒ‡ไปค๏ผŒๆ‰€ไปฅ teamai ไธไผšๆŠŠ่ฟ™ไบ›ๅ—ๅ†™ๅ…ฅๅฎƒ๏ผŒไนŸไธไผšๅ†™ๅ…ฅ `~/AGENTS.md`ใ€`~/.agents/AGENTS.md` ๆˆ–ๅ…ถไป–ๅทฅๅ…ท่ฏปๅ–็š„ๆ–‡ไปถใ€‚ๆฏไธชๅทฅๅ…ท้€š่ฟ‡่‡ชๅทฑ็š„ๆ–‡ไปถๆˆ–ไผš่ฏ้’ฉๅญ่Žทๅพ—่ฟ™ไบ›ๅ—๏ผš + +| ๅทฅๅ…ท | ็”จๆˆท่Œƒๅ›ด | ้กน็›ฎ่Œƒๅ›ด | +|---|---|---| +| Claude Code | `~/.claude/CLAUDE.md` | `.claude/rules/teamai-context.md` | +| claude-internalใ€tclaude | ๅ„่‡ชไธป็›ฎๅฝ•ไธ‹็š„ `.claude-internal/CLAUDE.md`ใ€`.tclaude/CLAUDE.md`๏ผˆๆœชๆ”นๅ˜๏ผ‰ | ้กน็›ฎไธ‹็š„็›ธๅŒ่ทฏๅพ„๏ผˆๆœชๆ”นๅ˜๏ผŒๆœช้ชŒ่ฏ๏ผ‰ | +| Codexใ€codex-internalใ€tcodex | `$CODEX_HOME/AGENTS.md`๏ผˆไปฅๅŠๅ„ๅ˜ไฝ“็š„ไธป็›ฎๅฝ•๏ผ‰๏ผŒไฝไบŽๅ›ข้˜Ÿ่ง„ๅˆ™ๆ— | ็”ฑ session-start ๅ’Œ subagent-start hook ๅŠ ๅ…ฅ๏ผŒไฝไบŽ้กน็›ฎๅ›ข้˜Ÿ่ง„ๅˆ™ๆ—๏ผ›ๆขๅคไผš่ฏๆ—ถไธ้‡ๅคๅŠ ๅ…ฅ | +| Copilot CLI | `$COPILOT_HOME/copilot-instructions.md`๏ผˆๆœชๆ”นๅ˜๏ผ‰ | `.github/copilot-instructions.md`๏ผˆๆœชๆ”นๅ˜๏ผ‰ | +| Cursor | `~/.cursor/rules/teamai-context.mdc`๏ผˆๆœช้ชŒ่ฏ๏ผ‰ | `.cursor/rules/teamai-context.mdc`๏ผˆๆœช้ชŒ่ฏ๏ผ‰ | +| CodeBuddy | `~/.codebuddy/CODEBUDDY.md` | `.codebuddy/rules/teamai-context.md`๏ผŒไธŽ WorkBuddy ๅ…ฑ็”จไธ€ไปฝ๏ผˆๆœช้ชŒ่ฏ๏ผ‰ | +| WorkBuddy | `~/.workbuddy/rules/teamai-context.md`๏ผˆๆœช้ชŒ่ฏ๏ผ‰ | `.codebuddy/rules/teamai-context.md`๏ผŒไธŽ CodeBuddy ๅ…ฑ็”จไธ€ไปฝ๏ผˆๆœช้ชŒ่ฏ๏ผ‰ | +| OpenCode | `~/.config/opencode/teamai-context.md`๏ผŒไปฅ็ปๅฏน่ทฏๅพ„ๅˆ—ๅœจ `~/.config/opencode/opencode.json` ็š„ `instructions` ไธญ | `.opencode/teamai-context.md`๏ผŒๅˆ—ๅœจ `.opencode/opencode.json` ็š„ `instructions` ไธญ | +| Oh My Pi | `~/.omp/agent/RULES.md` | ็”ฑ teamai ็š„ OMP ๆ‰ฉๅฑ•ๅŠ ๅ…ฅๆฏ่ฝฎ็š„็ณป็ปŸๆ็คบ | +| Pi | `~/.pi/agent/AGENTS.md` | ็”ฑ teamai ็š„ Pi ๆ‰ฉๅฑ•ๅŠ ๅ…ฅๆฏๆฌก่ฟ่กŒ็š„็ณป็ปŸๆ็คบ | +| Hermes | `$HERMES_HOME/SOUL.md` ไธญ็š„ไธ€ไธชๅ—๏ผŒไฝไบŽๅ›ข้˜Ÿ่ง„ๅˆ™ๅ—ๆ—๏ผˆๆœช้ชŒ่ฏ๏ผ‰ | teamai ็š„ Hermes ๆ’ไปถๆไพ›็š„็ณป็ปŸๆ็คบๆฎต่ฝ๏ผˆๆœช้ชŒ่ฏ๏ผ‰ | + +ๅ›ข้˜Ÿ `toolPaths` ไธญๆฒกๆœ‰ `rules` ็š„ๆก็›ฎ๏ผŒClaude Codeใ€Cursorใ€CodeBuddy ๅ’Œ WorkBuddy ็ปง็ปญไฝฟ็”จๅ…ถ้…็ฝฎ็š„ `claudemd`๏ผŒๅ› ไธบๅฎƒไปฌๆฒกๆœ‰ๅฏๆ”พ็ฝฎ `teamai-context` ๆ–‡ไปถ็š„ rules ็›ฎๅฝ•ใ€‚ๅชๆœ‰ `claudemd` ็š„ๆก็›ฎๅœจ่ฏฅๆ–‡ไปถๆ‰€ๅœจ็›ฎๅฝ•ๅญ˜ๅœจๆ—ถ่ง†ไธบๅทฒๅฎ‰่ฃ…๏ผ›ๅฏน `AGENTS.md` ่ฟ™็ฑปไธๅœจ็›ฎๅฝ•ไธญ็š„ๆ–‡ไปถๅˆ™ๅง‹็ปˆ่ง†ไธบๅทฒๅฎ‰่ฃ…ใ€‚ + +*ๆœช้ชŒ่ฏ*๏ผšไพๆฎๅทฅๅ…ท็š„ๆ–‡ๆกฃๆˆ–ๆบ็ ไธญ็š„ๅŠ ่ฝฝ้€ป่พ‘ๅฎž็Žฐ๏ผŒๅฐšๆœชๅœจๅฎž้™…ไผš่ฏไธญๆฃ€ๆŸฅใ€‚Claude Codeใ€Oh My Piใ€OpenCode ๅ’Œ Pi๏ผˆ้กน็›ฎ่Œƒๅ›ด๏ผ‰ๅทฒๅœจๅฎž้™…ไผš่ฏไธญไปŽ้กน็›ฎๆ น็›ฎๅฝ•ๅ’Œๅญ็›ฎๅฝ•ๆฃ€ๆŸฅ่ฟ‡ใ€‚ๆœ‰ `teamai-recall` subagent ็š„ๅทฅๅ…ท่Žทๅพ—่ฐƒ็”จ่ฏฅ subagent ็š„ recall ๅ—๏ผ›ๆฒกๆœ‰็š„ๅทฅๅ…ท๏ผˆPiใ€Hermesใ€OpenClaw๏ผ‰่Žทๅพ—ๆ็คบ agent ็›ดๆŽฅ่ฟ่กŒ `teamai recall` ็š„ recall ๅ—ใ€‚ๅคšไธชๅทฅๅ…ทๅ…ฑ็”จ็š„ๆ–‡ไปถๅชๆœ‰ๅœจๆฏไธชๅทฅๅ…ท้ƒฝๆœ‰่ฏฅ subagent ๆ—ถๆ‰่Žทๅพ— subagent ๅ—ใ€‚ + +Claude Code ไผšไปŽ้กน็›ฎๆ น็›ฎๅฝ•ๅ’Œไปปๆ„ๅญ็›ฎๅฝ•ๅŠ ่ฝฝ `.claude/rules/teamai-context.md`๏ผŒๅนถ็…งๅธธ่ฏปๅ–้กน็›ฎ็š„ `AGENTS.md` ๆˆ–้กน็›ฎ่‡ชๅทฑ็ผ–ๅ†™็š„ `CLAUDE.md`ใ€‚Copilot CLI 1.0.89 ๅŠๆ›ด้ซ˜็‰ˆๆœฌไนŸไผš่ฏปๅ–้กน็›ฎ็š„ `.claude/rules`๏ผŒๅ› ๆญคๅŒๆ—ถๅฎ‰่ฃ…่ฟ™ไธคไธชๅทฅๅ…ทๆ—ถ๏ผŒCopilot ๅฏ่ƒฝไผš่ฏปๅˆฐไธคไปฝใ€‚ + +่ฟ™ไธคไธช `teamai-context.mdc` ๆ–‡ไปถ้ƒฝๅธฆๆœ‰ `alwaysApply: true`๏ผŒCursor ็š„่ง„ๅˆ™ๅŠ ่ฝฝๅ™จๅฐ†ๅ…ถ่ง†ไธบๅง‹็ปˆๅบ”็”จใ€‚Cursor CLI ไป…ๅœจไผš่ฏไปŽไธป็›ฎๅฝ•ไธ‹ๅฏๅŠจๆ—ถ่ฏปๅ– `~/.cursor/rules`๏ผ›Cursor IDE ๆœช็ป้ชŒ่ฏใ€‚ + +CodeBuddy ๅ’Œ WorkBuddy ็š„่ง„ๅˆ™ๆ–‡ไปถๅธฆๆœ‰ `alwaysApply: true`๏ผŒCodeBuddy ็š„่ง„ๅˆ™่งฃๆžๅ™จๅฐ†ๅ…ถ่ง†ไธบๅง‹็ปˆๅบ”็”จใ€‚ๅธ่ฝฝๅ…ถไธญไธ€ไธชๅทฅๅ…ทๆ—ถ๏ผŒๅช่ฆๅฆไธ€ไธชไปๅทฒๅฎ‰่ฃ…๏ผŒ้กน็›ฎไธญๅ…ฑ็”จ็š„้‚ฃไปฝๆ–‡ไปถๅฐฑไผšไฟ็•™ใ€‚ + +ๅœจ้กน็›ฎไธญ๏ผŒteamai ๅฎ‰่ฃ… Hermes ๆ’ไปถ `$HERMES_HOME/plugins/teamai-instructions/`๏ผŒๅนถๆŠŠๅฎƒๅŠ ๅ…ฅ `$HERMES_HOME/config.yaml` ็š„ `plugins.enabled`๏ผˆๅˆ—ๅœจ `plugins.disabled` ไธญ็š„ๅ็งฐไฟๆŒๅ…ณ้—ญ๏ผ‰ใ€‚ๅŒๅไฝ†ๅนถ้ž teamai ๅ†™ๅ…ฅ็š„ๆ’ไปถไฟๆŒไธๅ˜๏ผŒๅธ่ฝฝๆ—ถไนŸไธ€ๆ ท๏ผŒ`teamai pull` ๅ’Œ `teamai doctor` ไผšๆŒ‡ๅ‡บ่ฟ™ไธ€็‚นใ€‚ๆ นๆฎ Hermes ็š„ๆ–‡ๆกฃ๏ผŒๅฎƒๅœจๆฏไธชๆ–ฐไผš่ฏๅผ€ๅง‹ๆ—ถๆ นๆฎไผš่ฏ็›ฎๅฝ•็”Ÿๆˆ่ฏฅๆฎต่ฝ๏ผŒๅนถๅœจๅŽ‹็ผฉๅ’ŒๆขๅคๅŽไฟ็•™ใ€‚ไธ€ไธชๆฎต่ฝๆœ€ๅคš 4,000 ไธชๅญ—็ฌฆ๏ผŒๆ‰€ๆœ‰ๆ’ไปถๆฎต่ฝๅˆ่ฎกๆœ€ๅคš 8,000 ไธชๅญ—็ฌฆใ€‚ๅฝ“่ฏฅๆˆๅ‘˜ๅœจๆญค้กน็›ฎ็š„ๆŒ‡ไปคๆ›ด้•ฟๆ—ถ๏ผŒHermes ไผš่ทณ่ฟ‡ๅฎƒไปฌ๏ผŒ`teamai pull` ไผš็ป™ๅ‡บๆ็คบ๏ผšteamai ไธไผšๆˆชๆ–ญๅฎƒไปฌ๏ผŒไนŸไธไผšๅ†™ๅ…ฅ `AGENTS.md`ใ€‚ๅœจ้กน็›ฎไน‹ๅค–ๆฎต่ฝไธบ็ฉบ๏ผŒHermes ๅฏ่ƒฝ่ฎฐๅฝ•ๅฎƒ่ทณ่ฟ‡ไบ†ไธ€ไธช็ฉบๆฎต่ฝใ€‚ + +OpenCode ๅชๅŠ ่ฝฝ้…็ฝฎไธญ `instructions` ๅˆ—ๅ‡บ็š„ๆ–‡ไปถใ€‚ไป…ๅฝ“็›ฎๆ ‡ๅทฒๅŒ…ๅซๆ‰€้œ€็š„ๅ—ๆˆ–ๆ›ดๆ–ฐๆˆๅŠŸๆ—ถ๏ผŒteamai ๆ‰ๆทปๅŠ ่ฏฅๆก็›ฎ๏ผ›ๆ ‡่ฎฐไธๅฎŒๆ•ดๆˆ–ๅ†™ๅ…ฅๅคฑ่ดฅๆ—ถ๏ผŒไธไผšๆฟ€ๆดปๆ—งๅ—ใ€‚็ผ–่พ‘ๅคฑ่ดฅไผšไฟ็•™ๅทฒๆœ‰็š„ instructions ๆก็›ฎ๏ผŒrecall ๆ ‡่ฎฐไธๅฎŒๆ•ดๅฏผ่‡ดๅˆ‡ๆขๅคฑ่ดฅๆ—ถไนŸไธ€ๆ ทใ€‚TeamAI ๅœจๆทปๅŠ ้…็ฝฎๆก็›ฎไน‹ๅ‰ไฟๅญ˜ๆ‰€ๆœ‰ๆƒ๏ผ›็Šถๆ€ๅ†™ๅ…ฅๅคฑ่ดฅๆ—ถไธไผšๆฟ€ๆดปๆก็›ฎ๏ผŒ้…็ฝฎๅ†™ๅ…ฅๅคฑ่ดฅๆ—ถๅฏไปฅ้‡่ฏ•ใ€‚ไฝ ๅทฒๅˆ—ๅ‡บ็š„ๆก็›ฎไปๅฑžไบŽไฝ ใ€‚teamai ๅชๆทปๅŠ ่ฟ™ไธ€้กน๏ผŒๅนถไฟ็•™ไฝ ็š„ๅ…ถไป–ๆก็›ฎๅ’Œ้”ฎ๏ผ›ๆ น็›ฎๅฝ•็š„ `opencode.json` ไปฅๅŠ OpenCode ่‡ชๅทฑ็š„ `AGENTS.md` ๆ–‡ไปถไฟๆŒไธๅ˜ใ€‚ๅฝ“ `~/.config/opencode/AGENTS.md` ไธๅญ˜ๅœจๆ—ถ๏ผŒOpenCode ไผšๆ”นไธบ่ฏปๅ– `~/.claude/CLAUDE.md`๏ผ›่‹ฅ Claude Code ็š„็”จๆˆทๅ—ๅทฒๅœจ้‚ฃ้‡Œ๏ผŒOpenCode ๅทฒ็ป่Žทๅพ—ๅฎƒไปฌ๏ผŒๅ› ๆญค teamai ไธไผšไธบ OpenCode ๅ†ๅ†™ไธ€ไปฝ็”จๆˆทๅ‰ฏๆœฌ๏ผŒๅนถๅœจ pull ่พ“ๅ‡บไธญ่ฏดๆ˜Žใ€‚่ขซๆŽ’้™ค็š„ Claude Code ็•™ๅœจ้‚ฃ้‡Œ็š„ๅ—ๅŒๆ ท็ฎ—ๆ•ฐ๏ผŒๅ› ไธบ OpenCode ็…งๆ ท่ฏปๅ–ๅฎƒไปฌ๏ผ›ๆญคๆ—ถ pull ไผš่ญฆๅ‘Šๆฒกๆœ‰ไปปไฝ•ๅทฅๅ…ทๅ†ๆ›ดๆ–ฐๅฎƒไปฌใ€‚teamai ๆ— ๆณ•ๆŒ‰ JSON ่งฃๆž็š„้…็ฝฎๆ–‡ไปถ๏ผˆไพ‹ๅฆ‚ๅธฆๆณจ้‡Š็š„ๆ–‡ไปถ๏ผ‰ไฟๆŒไธๅ˜ๅนถ็ป™ๅ‡บ่ญฆๅ‘Š๏ผŒ่ฏทๆ‰‹ๅŠจๆทปๅŠ ่ฏฅๆก็›ฎใ€‚ + +Oh My Pi ๆŠŠ `RULES.md` ไฝœไธบๅง‹็ปˆๅบ”็”จ็š„่ง„ๅˆ™่ฏปๅ–๏ผŒไธŽๅ…ถๅ”ฏไธ€็š„็”จๆˆทไธŠไธ‹ๆ–‡ๆ–‡ไปถๅนถๅญ˜ใ€‚ๅœจ้กน็›ฎ่Œƒๅ›ดๅ†…๏ผŒteamai ็š„ OMP ๆ‰ฉๅฑ•ๅœจไผš่ฏๅผ€ๅง‹ๆ—ถๅ‘ `teamai` ่Žทๅ–่ฟ™ไบ›ๅ—๏ผŒๅนถๅŠ ๅ…ฅๆฏ่ฝฎ็š„็ณป็ปŸๆ็คบ๏ผŒๆ— ่ฎบไผš่ฏไปŽ้กน็›ฎๆ น็›ฎๅฝ•่ฟ˜ๆ˜ฏๅญ็›ฎๅฝ•ๅฏๅŠจใ€‚ๆฒกๆœ‰่ฏฅๆ‰ฉๅฑ•ๆ—ถ๏ผˆไพ‹ๅฆ‚็งป้™คไบ† hooks๏ผ‰๏ผŒOh My Pi ็š„้กน็›ฎไผš่ฏไธไผš่Žทๅพ—ๅ›ข้˜Ÿๅ—ใ€‚HTTP local agent ไธบ้กน็›ฎไธ‹ๅ‘็š„ prompt ไนŸไปฅๅŒๆ ทๆ–นๅผ๏ผŒ้€š่ฟ‡ๆ‰ฉๅฑ•ๆˆ–ๆ’ไปถๅˆฐ่พพ Piใ€Oh My Pi ๅ’Œ Hermes๏ผŒๅนถ้€š่ฟ‡ session-start ๅ’Œ subagent-start hook ๅˆฐ่พพ Codex ็ณปๅˆ—ใ€‚Pi ๅ’Œ Oh My Pi ไผš็ญ‰ๅพ…ๅ‰ๅฐ session-start ๆดพๅ‘๏ผˆๅŒ…ๆ‹ฌ HTTP prompt ๅŒๆญฅ๏ผ‰ๅฎŒๆˆ๏ผŒๅ†ไธบ้ฆ–ไธช prompt ็ผ“ๅญ˜้กน็›ฎๆŒ‡ไปคใ€‚Codex ไนŸไผšๅœจ่ฏฅๅŒๆญฅๅฎŒๆˆๅŽ่ฏปๅ– HTTP prompt ็ผ“ๅญ˜๏ผŒๅ†่ฟ”ๅ›ž SessionStart ไธŠไธ‹ๆ–‡ใ€‚ + +ๆ—ฉๆœŸ็‰ˆๆœฌ็š„ pull ๅฏ่ƒฝๆŠŠ่ฟ™ไบ›ๅ—็•™ๅœจไธ‹ๅˆ—ๆ–‡ไปถไธญใ€‚ๅชๆœ‰ๆŸไธชๅŒบๅ—็š„ๆ›ฟไปฃๅ†…ๅฎนๅทฒๆˆๅŠŸ่งฃๆžๅนถๆŠ•้€’็ป™ๆ›พๅ†™ๅ…ฅ่ฏฅๆ–‡ไปถ็š„ๆฏไธชๅทฒๅฎ‰่ฃ…ๅทฅๅ…ทๅŽ๏ผŒpull ๆ‰็งป้™ค่ฏฅๆ—งๅŒบๅ—ใ€‚ๆ–‡ๅŒ–ๆบๆ–‡ไปถไธๅฏ่ฏปๆˆ–ๆ— ๆ•ˆๆ—ถ๏ผŒๅณไฝฟๅ…ฑไบซๆŒ‡ไปคๅ’Œ recall ๅทฒๆˆๅŠŸๅŒๆญฅ๏ผŒๆ—งๆ–‡ๅŒ–ๅŒบๅ—ไปไผšไฟ็•™ใ€‚็›ฎๆ ‡ๅ†™ๅ…ฅๅคฑ่ดฅใ€ๆ–‡ไปถๅนถ้ž teamai ๆ‰€ๆœ‰ใ€ๆ‰ฉๅฑ•็ผบๅคฑๆˆ–ๆ’ไปถ่ขซ็ฆ็”จๆ—ถ๏ผŒๆ—งๅ—ไฟ็•™ไปฅไพฟ้‡่ฏ•ใ€‚่ขซๆŽ’้™คๅทฅๅ…ท็š„ๅฝ“ๅ‰ๅ’Œๆ—งๆŒ‡ไปคๆ–‡ไปถไฟๆŒไธๅ˜๏ผŒๅนถไธ”ไธ็บณๅ…ฅ doctor ็š„ๆ—งๆŒ‡ไปคๆฃ€ๆŸฅใ€‚ + +HTTP prompt ๅ‘ฝไปคไผšๆฃ€ๆŸฅๆ‰€ๆœ‰ๅทฒๅฎ‰่ฃ…็š„ๆ—งๅ†™ๅ…ฅๅทฅๅ…ท็š„ๅฝ“ๅ‰็›ฎๆ ‡๏ผŒๅŒ…ๆ‹ฌๅ…ˆๅ‰ๅ‘ฝไปค็š„ๆŠ•้€’็ป“ๆžœ๏ผŒ็กฎ่ฎคๅŽๆ‰็งป้™คๆ—งๅ…ฑไบซๆŒ‡ไปคๅŒบๅ—ใ€‚็›ฎๆ ‡ไปๅซๆ—ง prompt ๆ—ถไธ็ฎ—ๆŠ•้€’ๆˆๅŠŸใ€‚HTTP ๆธ…็†ไฟ็•™ๆ–‡ๅŒ–ๅ’Œ recall ๅŒบๅ—๏ผŒๅ› ไธบ่ฟ™ไบ›ๅ‘ฝไปคไธๆ›ฟๆขๅฎƒไปฌใ€‚ + +ๅฝ“ๅŽŸ็”Ÿ้กน็›ฎๆŒ‡ไปคๆ–‡ไปถไปๅซ TeamAI ๅŒบๅ—ๆ—ถ๏ผŒไผš่ฏ hook ่ทณ่ฟ‡่ฏฅๅŒบๅ—๏ผŒๅŒ…ๆ‹ฌ็ผ“ๅญ˜็š„ HTTP prompt๏ผŒ้ฟๅ…ๅŒๆ—ถๅŠ ๅ…ฅๅฆไธ€ไธชๆˆๅ‘˜็š„้€‰ๆ‹ฉใ€‚ๅ…ถไป–ๅŒบๅ—ไป้€š่ฟ‡ hook ๆŠ•้€’๏ผŒไฟ็•™็š„ๅŒบๅ—ๆธ…็†ๅŽๆขๅคๆŠ•้€’ใ€‚Codex ้ตๅพช `AGENTS.override.md` ็š„ไผ˜ๅ…ˆ็บง๏ผŒOh My Pi ้ตๅพช `.omp/AGENTS.md` ็š„ไผ˜ๅ…ˆ็บงใ€‚Doctor ไผšๆŠฅๅ‘Šๆ—งๆ–‡ไปถไธญไธๅฎŒๆ•ดๆˆ–้‡ๅค็š„ๆ ‡่ฎฐ๏ผ›ไฟฎๅคๆ ‡่ฎฐๅŽๅ†่ฟ่กŒ pullใ€‚ + +pull ไผšๅˆ—ๅ‡บๆ‰€ไฟฎๆ”น็š„ๆฏไธชๆ–‡ไปถ๏ผš + +- Claude Code๏ผŒ้กน็›ฎ่Œƒๅ›ด๏ผš`.claude/CLAUDE.md` +- CodeBuddy๏ผŒ้กน็›ฎ่Œƒๅ›ด๏ผš`.codebuddy/CODEBUDDY.md` +- WorkBuddy๏ผš`~/AGENTS.md` ๅ’Œ้กน็›ฎ `AGENTS.md` +- Hermes๏ผš`~/AGENTS.md` +- Oh My Pi๏ผš`~/.omp/agent/AGENTS.md` ๅ’Œ `.omp/AGENTS.md`ใ€‚Oh My Pi ๆฏไธ€ๅฑ‚ๅช่ฏปๅ–ไธ€ไธชไธŠไธ‹ๆ–‡ๆ–‡ไปถ๏ผŒๅ› ๆญคๅฎƒไปฌไผš้ฎ่”ฝ `~/.agents/AGENTS.md` ๅ’Œ้กน็›ฎ็š„ `AGENTS.md`ใ€‚ +- Pi๏ผš้กน็›ฎ `AGENTS.md` +- Codex ็ณปๅˆ—๏ผš้กน็›ฎ `AGENTS.md`๏ผˆๅฝ“ๅ›ข้˜Ÿ็š„ `toolPaths` ๆˆ–ๆ—ฉๆœŸๆž„ๅปบๆŠŠ Codex ๆŒ‡ๅ‘้‚ฃ้‡Œๆ—ถ๏ผ‰ +- ็›ฎๆ ‡ๆ–‡ไปถๅทฒๆ”นๅ˜็š„ไปปไธ€ๅทฅๅ…ท๏ผšๅ›ข้˜Ÿ `toolPaths` ไธบๅฎƒ่ฎพ็ฝฎ็š„ `claudemd` ่ทฏๅพ„๏ผŒ้™ค้ž็Žฐๅœจๅฆไธ€ไธชๅทฅๅ…ท็š„ๅ—ๅ†™ๅœจ้‚ฃ้‡Œ + +`teamai doctor` ไผšๆฃ€ๆŸฅๆฏไธชๅทฒๅฎ‰่ฃ…ๅทฅๅ…ท่ƒฝๅฆๅŠ ่ฝฝ่ฟ™ไบ›ๅ—๏ผšๆฏไธชๆ–‡ไปถๆ˜ฏๅฆๅŒ…ๅซๅฝ“ๅ‰็š„ๅ—๏ผŒOpenCode ้…็ฝฎๆ˜ฏๅฆๅˆ—ๅ‡บๅ…ถๆ–‡ไปถ๏ผŒPi ๆˆ– Oh My Pi ๆ‰ฉๅฑ•ๅ’Œ Hermes ๆ’ไปถๆ˜ฏๅฆๅทฒๅฎ‰่ฃ…ๅนถๅฏ็”จ๏ผŒHermes ๆฎต่ฝๆ˜ฏๅฆๅœจ้™ๅˆถๅ†…๏ผŒไปฅๅŠๆ—ฉๆœŸ็‰ˆๆœฌๅ†™่ฟ‡็š„ๆ–‡ไปถไธญๆ˜ฏๅฆไปๆฎ‹็•™ๅ—ใ€‚ + +่‹ฅ่ฏทๆฑ‚ๆ›ดๆ–ฐ็š„ๅ—ๅญ˜ๅœจไธๅฎŒๆ•ดๆˆ–้‡ๅค็š„ๆ ‡่ฎฐ๏ผŒๆ•ดไธชๆ–‡ไปถไฟๆŒไธๅ˜๏ผŒๅŒ…ๆ‹ฌๅ…ถไป–ๆ‰˜็ฎกๅ—ใ€‚ไฟฎๅคๆ็คบไธญ็š„ๆ ‡่ฎฐๅŽ๏ผŒๅ†่ฟ่กŒ `teamai pull`ใ€‚ + +ไธŽ teamai ็›ฎๆ ‡ๅŒๅไฝ†ๅนถ้ž teamai ๅ†™ๅ…ฅ็š„ๆ–‡ไปถไฟๆŒไธๅ˜๏ผŒไนŸไธไผš่ขซๅˆ—ๅ…ฅ OpenCode ็š„ `instructions`๏ผˆไฝ ่‡ชๅทฑไธบๅฎƒๅˆ—็š„ๆก็›ฎไฟ็•™ไธๅŠจ๏ผ‰๏ผŒpull ไผš็ป™ๅ‡บ่ญฆๅ‘Šใ€‚ๅไธบ `teamai-context` ็š„ๅ›ข้˜Ÿ rule ไธไผš่ขซๅˆ†ๅ‘๏ผŒๅ› ไธบๅฎƒไผš่ฝๅœจ่ฏฅๆ–‡ไปถไธŠ๏ผ›pull ไผšๆŒ‡ๅ‡บๅฎƒ๏ผŒๅนถๅˆ ้™คๆ—ฉๆœŸ็‰ˆๆœฌๅˆ†ๅ‘็š„ๅ‰ฏๆœฌ๏ผˆ้™ค้žไฝ ๆ”น่ฟ‡ๅฎƒ๏ผ‰ใ€‚teamai ไธไฟฎๆ”น `.gitignore`ใ€`.git/info/exclude` ๆˆ– git ็ดขๅผ•ใ€‚่‹ฅๅ›ข้˜ŸๅธŒๆœ›่ฟ™ไบ›ๆ–‡ไปถไธ่ฟ›ๅ…ฅๆไบค๏ผŒ้œ€่ฆ่‡ช่กŒๆŽ’้™คใ€‚ + ### ๆŸฅ็œ‹ๆ•ˆๆžœ -pull ๅŽๅฏไปฅ็›ดๆŽฅๆŸฅ็œ‹ AI ๅทฅๅ…ท็š„ CLAUDE.md๏ผš +pull ๅŽๅฏไปฅ็›ดๆŽฅๆŸฅ็œ‹ AI ๅทฅๅ…ท็š„ๆŒ‡ไปคๆ–‡ไปถ๏ผŒไพ‹ๅฆ‚ Claude Code ็š„็”จๆˆทๆ–‡ไปถ๏ผš ```bash teamai pull @@ -1577,6 +1636,8 @@ cat ~/.claude/CLAUDE.md } ``` +ๅˆ ้™คๆœ€ๅŽไธ€ไธช HTTP prompt ๆ—ถ๏ผŒ่‹ฅ็›ฎๆ ‡ๆ— ๆณ•ๆ›ดๆ–ฐ๏ผŒๅ›žๆ‰งไธบ `failed`ใ€‚็ผ“ๅญ˜ไธญ็š„ prompt ๅ’Œ manifest ่ฎฐๅฝ•ๅ‡ไบˆไปฅไฟ็•™๏ผŒไฟฎๅคๆ ‡่ฎฐๆˆ–ๆ–‡ไปถๆƒ้™ๅŽๅฏ้‡่ฏ•ใ€‚ + ๅŽ็ซฏๅฏไธ‹ๅ‘ **`apply_model_config`** ไปปๅŠก๏ผŒๅ…ถ `cmd` ไธบ JSONใ€‚ๅฎขๆˆท็ซฏๅŒๆ—ถๅ…ผๅฎน่ฎพ่ฎกๆ–‡ๆกฃไธญ็š„ๅ€™้€‰้›†็ป“ๆž„ๅ’Œ ๆ—ง็‰ˆๅ•ๆจกๅž‹็ป“ๆž„๏ผš`{"models":[...]}` ๆŒ‰ๅฎŒๆ•ดๅฟซ็…งๅค„็†๏ผŒ็›ดๆŽฅๆจกๅž‹ๅฏน่ฑกๆŒ‰ๅขž้‡ upsert ๅค„็†ใ€‚ `max_tokens` ๅฏ้€‰๏ผˆๅฏนๅบ” CodeBuddy / WorkBuddy ็š„ `maxOutputTokens`๏ผ‰๏ผ›็ผบ็œๆˆ– `0` ๆ—ถ้ป˜่ฎค `4096`ใ€‚Claude ไธไฝฟ็”จ่ฏฅๅญ—ๆฎตใ€‚ @@ -2017,8 +2078,8 @@ GitHub Copilot CLI ๅทฒๆ”ฏๆŒๅ…ถๅฎ˜ๆ–น่‡ชๅฎšไน‰ๆŒ‡ไปคใ€Rulesใ€Skillsใ€่‡ชๅฎš [Pi](https://github.com/badlogic/pi-mono/tree/main/packages/coding-agent) ้€š่ฟ‡ๅ…ถๅ…ฌๅผ€็š„ Skillsใ€ๆŒ‡ไปคๆ–‡ไปถๅ’Œๆ‰ฉๅฑ•ๆœบๅˆถๆŽฅๅ…ฅ๏ผš - **ไฝœ็”จๅŸŸใ€‚** ้กน็›ฎ็บง Skills ๅ’Œ TeamAI ็ฎก็†็š„ Rules ๅ†™ๅ…ฅ `.pi/skills/`ใ€`.pi/rules/`๏ผ›็”จๆˆท็บงๅ‰ฏๆœฌๅ†™ๅ…ฅ `~/.pi/agent/skills/`ใ€`~/.pi/agent/rules/`ใ€‚ -- **ๆŒ‡ไปคๆ–‡ไปถใ€‚** ้กน็›ฎ็บงไฝฟ็”จ `AGENTS.md`๏ผŒ็”จๆˆท็บงไฝฟ็”จ `~/.pi/agent/AGENTS.md`ใ€‚Pi ไนŸๆŽฅๅ—้กน็›ฎ็บง `CLAUDE.md`๏ผŒไฝ† TeamAI ๅฐ†่ง„่Œƒ็š„ TeamAI ๅŒบๅ—ไฟ็•™ๅœจ `AGENTS.md`ใ€‚ -- **Hooksใ€‚** TeamAI ๅชๅœจ็”จๆˆท็บง `~/.pi/agent/extensions/` ็”Ÿๆˆไธ€ไปฝ `teamai-hooks.ts`๏ผŒๆŠŠ `session_start` ๆ˜ ๅฐ„ไธบ session-startใ€`before_agent_start` ๆ˜ ๅฐ„ไธบ prompt-submitใ€`agent_settled` ๆ˜ ๅฐ„ไธบ stop๏ผ›`tool_execution_start` ็ผ“ๅญ˜ๅทฅๅ…ท่พ“ๅ…ฅ๏ผŒ`tool_execution_end` ๆดพๅ‘ post-tool-use ๆ—ถๆŠŠ็ผ“ๅญ˜็š„่พ“ๅ…ฅ่ฝฌๅ‘ไธบ `tool_input`๏ผŒๅนถ้™„ไธŠ็ป“ๆžœๆ–‡ๆœฌ `tool_response` ๅ’Œๆ นๆฎ้”™่ฏฏๆ ‡ๅฟ—ๅพ—ๅ‡บ็š„ `tool_status`ใ€‚ๆฏไธชไบ‹ไปถ้ƒฝๆบๅธฆ Pi ไผš่ฏ id๏ผˆ`ctx.sessionManager.getSessionId()`๏ผ‰๏ผŒไธŽ Pi ็š„ bash ๅทฅๅ…ทๅฏผๅ‡บ็š„ `PI_SESSION_ID` ็›ธๅŒ๏ผŒๅ› ๆญคๅœจๅ…ถไธญ่ฟ่กŒ็š„ `teamai recall` ไผšๅฝ’ๅ…ฅๅ…ถ hooks ๆบๅธฆ็š„ๅŒไธ€ไผš่ฏ๏ผŒupvote **้‡‡็บณ๏ผˆadoption๏ผ‰**ๅœจ Pi ไธŠๅŒๆ ท็”Ÿๆ•ˆใ€‚Pi ไผšๅŒๆ—ถๅŠ ่ฝฝ็”จๆˆท็บงไธŽ้กน็›ฎ็บงๆ‰ฉๅฑ•็›ฎๅฝ•๏ผŒๅ› ๆญค TeamAI ไธๅˆ›ๅปบ้กน็›ฎๅ‰ฏๆœฌโ€”โ€”็ฌฌไบŒไปฝๅ‰ฏๆœฌไผšๅฏผ่‡ดๆฏไธชไบ‹ไปถ่ขซๆดพๅ‘ไธคๆฌก๏ผŒ่ฟ™ไธŽ OMP ้€‚้…ๅ™จ็š„ๅ•ๅ‰ฏๆœฌ็ญ–็•ฅไธ€่‡ดใ€‚ๆ—ฉๆœŸ็‰ˆๆœฌ้—็•™ไธ”ๅธฆ TeamAI ๆ ‡่ฎฐ็š„้กน็›ฎๅ‰ฏๆœฌไผšๅœจไธ‹ๆฌกๅŒๆญฅๆ—ถ็งป้™ค๏ผŒๆณจๅ…ฅ้€ป่พ‘ไนŸไธไผš่ฆ†็›–ๆฒกๆœ‰ TeamAI ๆ ‡่ฎฐ็š„ๅŒๅๆ–‡ไปถใ€‚Pi ๆฒกๆœ‰ๅฏไพ› self mode ๆไบค็š„่ฎพ็ฝฎๆ–‡ไปถ๏ผŒๆ‰€ไปฅ fresh clone ไป้œ€ๅœจ่ฏฅๆœบๅ™จไธŠๆ‰‹ๅŠจ่ท‘ไธ€ๆฌก `teamai init`/`pull` ๆ‰่ƒฝๆฟ€ๆดป Pi hooksใ€‚ไปปไฝ•ไธ€ๆฌกๆ˜พๅผ็งป้™คโ€”โ€”`teamai hooks remove`๏ผŒๆˆ–่€…ๆŸไธช scope ไธ‹็š„ `teamai uninstall --agent pi`โ€”โ€”้ƒฝไผš็›ดๆŽฅๅˆ ้™ค่ฟ™ไปฝๅ…ฑไบซๆ‰ฉๅฑ•๏ผŒๅ’Œ OMP ้€‚้…ๅ™จ็š„ๅ•ๆ–‡ไปถๅˆ ้™ค่ฏญไน‰ๅฎŒๅ…จไธ€่‡ด๏ผšPi ๆฒกๆœ‰ๅŠžๆณ•ๆŠŠไธ€ไปฝๅ…ฑไบซๆ–‡ไปถ้™ๅฎšๅœจๆŸไธ€ไธช้กน็›ฎ้‡Œ๏ผŒๆ‰€ไปฅไธไผšๅ‡่ฃ…"ไธบๅ…ถไป–้กน็›ฎไฟ็•™"ๅด่ฎฉ่ฟ™ไปฝๆ‰ฉๅฑ•็ปง็ปญๅฏนๅฝ“ๅ‰้กน็›ฎ่งฆๅ‘๏ผ›ๆฒกๆœ‰ TeamAI ๆ ‡่ฎฐ็š„ๅŒๅๆ–‡ไปถไธไผš่ขซๅˆ ้™คใ€‚`teamai hooks list` ๅง‹็ปˆๆ˜พ็คบ่ฟ™ไธชๅ…จๅฑ€่ทฏๅพ„ใ€‚Pi ็š„ profile ่ฆ†็›–้กน๏ผˆ`PI_CODING_AGENT_DIR` / `PI_CONFIG_DIR`๏ผŒไผš่ฟ็งป agent ็›ฎๅฝ•๏ผ‰ๅœจ hooks ไธญๆš‚ไธๆ”ฏๆŒ๏ผŒไธŽ OMP ้€‚้…ๅ™จไธ€่‡ด๏ผŒไฝฟ็”จ้ป˜่ฎค็š„ `~/.pi/agent/` ๅธƒๅฑ€ใ€‚ๆจกๅž‹้…็ฝฎๆ˜ฏๅฆไธ€ๅ›žไบ‹๏ผŒไผš่ฏปๅ– `PI_CODING_AGENT_DIR`ใ€‚็”ฑไบŽ่ฟ™ไปฝๆ‰ฉๅฑ•ๆ˜ฏๆœบๅ™จ็บงๅ…ฑไบซ็š„ๅ•ไธชๆ–‡ไปถ่€Œ้žๆŒ‰้กน็›ฎ้š”็ฆป๏ผŒๆŸไธช scope ไธ‹็š„็งป้™คๅœจๅคš้กน็›ฎๅœบๆ™ฏไธญๅนถไธๆŒไน…๏ผšๅช่ฆ Pi ๅœจๅ…ถไป–ไปปๆ„ scope ไปๅค„ไบŽๅฏ็”จ็Šถๆ€๏ผŒไธ‹ไธ€ๆฌกๅœจ้‚ฃ้‡Œๆ‰ง่กŒ `teamai init`/`pull` ๅฐฑไผšๆŠŠๅฎƒ้‡ๆ–ฐ็”Ÿๆˆ๏ผŒ่€Œ hook ๆดพๅ‘ๆœฌ่บซๆฒกๆœ‰ๆŒ‰้กน็›ฎๆŽ’้™ค็š„ๆฃ€ๆŸฅ๏ผŒๅ› ๆญคๅˆš่ขซๅธ่ฝฝ็š„้กน็›ฎ้‡Œ hooks ไปๅฏ่ƒฝ้‡ๆ–ฐ่งฆๅ‘ใ€‚่ฟ™ไธŽ OMP ้€‚้…ๅ™จๆ—ฉๅทฒไธŠ็บฟ็š„ๅ–่ˆๅฎŒๅ…จไธ€่‡ดใ€‚ +- **ๆŒ‡ไปคๆ–‡ไปถใ€‚** Pi ่ฏปๅ–้กน็›ฎ่‡ชๅทฑ็š„ `AGENTS.md`๏ผˆๆˆ– `CLAUDE.md`๏ผ‰๏ผŒTeamAI ไธไฟฎๆ”นๅฎƒใ€‚็”จๆˆท่Œƒๅ›ด็š„ๅ›ข้˜ŸๆŒ‡ไปคๅ†™ๅ…ฅ `~/.pi/agent/AGENTS.md`ใ€‚ๅœจ้กน็›ฎไธญ๏ผŒTeamAI ็š„ Pi ๆ‰ฉๅฑ•ๅœจไผš่ฏๅผ€ๅง‹ๆ—ถๅ‘ `teamai` ่Žทๅ–ๆˆๅ‘˜็š„ๅ›ข้˜ŸๆŒ‡ไปค๏ผŒๅนถๅŠ ๅ…ฅๆฏๆฌก่ฟ่กŒ็š„็ณป็ปŸๆ็คบใ€‚ +- **Hooksใ€‚** TeamAI ๅชๅœจ็”จๆˆท็บง `~/.pi/agent/extensions/` ็”Ÿๆˆไธ€ไปฝ `teamai-hooks.ts`๏ผŒๆŠŠ `session_start` ๆ˜ ๅฐ„ไธบ session-startใ€`before_agent_start` ๆ˜ ๅฐ„ไธบ prompt-submitใ€`agent_settled` ๆ˜ ๅฐ„ไธบ stop๏ผ›`tool_execution_start` ็ผ“ๅญ˜ๅทฅๅ…ท่พ“ๅ…ฅ๏ผŒ`tool_execution_end` ๆดพๅ‘ post-tool-use ๆ—ถๆŠŠ็ผ“ๅญ˜็š„่พ“ๅ…ฅ่ฝฌๅ‘ไธบ `tool_input`๏ผŒๅนถ้™„ไธŠ็ป“ๆžœๆ–‡ๆœฌ `tool_response` ๅ’Œๆ นๆฎ้”™่ฏฏๆ ‡ๅฟ—ๅพ—ๅ‡บ็š„ `tool_status`ใ€‚ๆฏไธชไบ‹ไปถ้ƒฝๆบๅธฆ Pi ไผš่ฏ id๏ผˆ`ctx.sessionManager.getSessionId()`๏ผ‰๏ผŒไธŽ Pi ็š„ bash ๅทฅๅ…ทๅฏผๅ‡บ็š„ `PI_SESSION_ID` ็›ธๅŒ๏ผŒๅ› ๆญคๅœจๅ…ถไธญ่ฟ่กŒ็š„ `teamai recall` ไผšๅฝ’ๅ…ฅๅ…ถ hooks ๆบๅธฆ็š„ๅŒไธ€ไผš่ฏ๏ผŒupvote **้‡‡็บณ๏ผˆadoption๏ผ‰**ๅœจ Pi ไธŠๅŒๆ ท็”Ÿๆ•ˆใ€‚Pi ไผšๅŒๆ—ถๅŠ ่ฝฝ็”จๆˆท็บงไธŽ้กน็›ฎ็บงๆ‰ฉๅฑ•็›ฎๅฝ•๏ผŒๅ› ๆญค TeamAI ไธๅˆ›ๅปบ้กน็›ฎๅ‰ฏๆœฌโ€”โ€”็ฌฌไบŒไปฝๅ‰ฏๆœฌไผšๅฏผ่‡ดๆฏไธชไบ‹ไปถ่ขซๆดพๅ‘ไธคๆฌก๏ผŒ่ฟ™ไธŽ OMP ้€‚้…ๅ™จ็š„ๅ•ๅ‰ฏๆœฌ็ญ–็•ฅไธ€่‡ดใ€‚ๆ—ฉๆœŸ็‰ˆๆœฌ้—็•™ไธ”ๅธฆ TeamAI ๆ ‡่ฎฐ็š„้กน็›ฎๅ‰ฏๆœฌไผšๅœจไธ‹ๆฌกๅŒๆญฅๆ—ถ็งป้™ค๏ผŒๆณจๅ…ฅ้€ป่พ‘ไนŸไธไผš่ฆ†็›–ๆฒกๆœ‰ TeamAI ๆ ‡่ฎฐ็š„ๅŒๅๆ–‡ไปถใ€‚Pi ๆฒกๆœ‰ๅฏไพ› self mode ๆไบค็š„่ฎพ็ฝฎๆ–‡ไปถ๏ผŒๆ‰€ไปฅ fresh clone ไป้œ€ๅœจ่ฏฅๆœบๅ™จไธŠๆ‰‹ๅŠจ่ท‘ไธ€ๆฌก `teamai init`/`pull` ๆ‰่ƒฝๆฟ€ๆดป Pi hooksใ€‚ๆ˜พๅผๆ‰ง่กŒ `teamai hooks remove` ๆˆ–็”จๆˆท็บง `teamai uninstall --agent pi` ไผšๅˆ ้™ค่ฟ™ไปฝๅ…ฑไบซๆ‰ฉๅฑ•ใ€‚้กน็›ฎ็บงๅธ่ฝฝไธบๅ…ถไป–้กน็›ฎไฟ็•™ๅฎƒ๏ผŒๅนถ็งป้™คๆ—ง็š„้กน็›ฎๅ‰ฏๆœฌ๏ผ›ๆฒกๆœ‰ TeamAI ๆ ‡่ฎฐ็š„ๅŒๅๆ–‡ไปถไธไผš่ขซๅˆ ้™คใ€‚`teamai hooks list` ๅง‹็ปˆๆ˜พ็คบ่ฟ™ไธชๅ…จๅฑ€่ทฏๅพ„ใ€‚Pi ็š„ profile ่ฆ†็›–้กน๏ผˆ`PI_CODING_AGENT_DIR` / `PI_CONFIG_DIR`๏ผŒไผš่ฟ็งป agent ็›ฎๅฝ•๏ผ‰ๅœจ hooks ไธญๆš‚ไธๆ”ฏๆŒ๏ผŒไธŽ OMP ้€‚้…ๅ™จไธ€่‡ด๏ผŒไฝฟ็”จ้ป˜่ฎค็š„ `~/.pi/agent/` ๅธƒๅฑ€ใ€‚ๆจกๅž‹้…็ฝฎๆ˜ฏๅฆไธ€ๅ›žไบ‹๏ผŒไผš่ฏปๅ– `PI_CODING_AGENT_DIR`ใ€‚้กน็›ฎ็บงๅธ่ฝฝๅŽๅ…ฑไบซๆ‰ฉๅฑ•ไปๅทฒๅฎ‰่ฃ…๏ผ›ๆŒ‡ไปคๆดพๅ‘ไผšๅ…ˆๆฃ€ๆŸฅๆญค้กน็›ฎๅฏน่ฏฅๅทฅๅ…ท็š„ๆŽ’้™ค่ฎพ็ฝฎใ€‚ - **ๅ›ข้˜Ÿ Hooks ่พน็•Œใ€‚** Pi ้€‚้…ๅ™จๅชๅฎ‰่ฃ…ๅ†…็ฝฎ็”Ÿๅ‘ฝๅ‘จๆœŸๆกฅๆŽฅใ€‚`hooks/hooks.yaml` ๅฃฐๆ˜Ž็š„่‡ชๅฎšไน‰ๅ›ข้˜Ÿ Hooks ๅ’Œๅ†…็ฝฎ Hook ่ฆ†็›–ไผš่ขซ่ทณ่ฟ‡ๅนถ็ป™ๅ‡บ่ญฆๅ‘Šใ€‚ๅฎŒๆ•ดๅ›ข้˜Ÿ Hooks ไธŽ้€้กน็›ฎๅฝ’ๅฑž่ฏญไน‰้œ€่ฆๅ•็‹ฌ็š„่ทจ้€‚้…ๅ™จ่ฎพ่ฎก๏ผŒ็•™ๅพ…ๅŽ็ปญ PRใ€‚ - **ๆœๅŠก็ซฏไธ‹ๅ‘็š„ Agent Hooksใ€‚** HTTP source hooks ไผšไปฅๅŒไธ€็”จๆˆท็บงๆ‰ฉๅฑ•็›ฎๅฝ•ไธญ็š„ `teamai-agent-.ts` ๅฝขๅผๅฎ‰่ฃ…ใ€‚ไธๆ”ฏๆŒ็š„็”Ÿๅ‘ฝๅ‘จๆœŸไบ‹ไปถไผš่ญฆๅ‘Šๅนถ่ทณ่ฟ‡ใ€‚ - **MCP๏ผˆPi 0.99.0+๏ผ‰ใ€‚** ๆ”ฏๆŒ stdio ๅ’Œ streamable HTTP๏ผ›SSE ไผš่ทณ่ฟ‡ใ€‚็”จๆˆท็บงๅ†™ๅ…ฅ `~/.pi/agent/mcp.json`๏ผŒ้กน็›ฎ็บงๅ†™ๅ…ฅ `.pi/mcp.json`๏ผ›้กน็›ฎ้…็ฝฎ้œ€่ฆ Pi ไฟกไปป้กน็›ฎๅŽๆ‰ๅŠ ่ฝฝใ€‚ไฟ็•™ๅŽŸ็”Ÿ `codemode` ้ป˜่ฎคๅ€ผ๏ผŒไธๅผบๅˆถ direct๏ผ›`mcp.yaml` ็š„ timeout ไปŽๆฏซ็ง’่ฝฌๆขๆˆ็ง’ใ€‚ๅ—็ฎกๆก็›ฎ็š„ๆœฌๅœฐ exposure/ๅฏ็”จ็Šถๆ€ๅœจๅ›ข้˜Ÿๅฎšไน‰ไธๅ˜ๆ—ถไฟ็•™๏ผŒๅ›ข้˜Ÿๅฎšไน‰ๆ›ดๆ–ฐๆ—ถไผš่ขซๆ›ฟๆข๏ผ›doctor ๆŒ‰ๅฎŒๆ•ดๆก็›ฎๆฏ”่พƒ๏ผŒไผšๆŠฅๅ‘Š่ฟ™ไบ›ๆœฌๅœฐๅทฎๅผ‚ใ€‚ๆŽฅ็ฎก `/mcp` ็š„ๆ‰ฉๅฑ•ๅฏ่ƒฝ็ฆ็”จๅ†…็ฝฎ MCP๏ผ›ไฝฟ็”จๅ†…็ฝฎๆ”ฏๆŒ้œ€็งป้™คๆญค็ฑปๆ‰ฉๅฑ•ใ€‚ @@ -2046,7 +2107,7 @@ ZCode ๅทฒไฝœไธบๅ†…็ฝฎ็›ฎๆ ‡ๆ”ฏๆŒใ€‚Skills ไธ‹ๅ‘ๅˆฐ `.zcode/skills/`๏ผˆZCode ### Oh My Pi -Oh My Pi๏ผˆOMP๏ผ‰ๅทฒไฝœไธบๅ†…็ฝฎ็›ฎๆ ‡ๆ”ฏๆŒใ€‚TeamAI ๅฐ† Skillsใ€Rules ๅ’Œ Subagents ไธ‹ๅ‘ๅˆฐ OMP ็š„ๅŽŸ็”Ÿ็›ฎๅฝ•โ€”โ€”้กน็›ฎ็บงไธบ `.omp/skills/`ใ€`.omp/rules/` ๅ’Œ `.omp/agents/`๏ผŒ็”จๆˆท็บงไธบ `~/.omp/agent/skills/`ใ€`~/.omp/agent/rules/` ๅ’Œ `~/.omp/agent/agents/`๏ผˆ็”จๆˆท็บง่ต„ๆบไฝไบŽ agent ็›ฎๅฝ• `~/.omp/agent/` ไธ‹๏ผŒไธŽ้กน็›ฎ็บงๅ‰็ผ€ไธๅŒ๏ผŒTeamAI ไผš้šไฝœ็”จๅŸŸ่‡ชๅŠจๅˆ‡ๆข๏ผ‰ใ€‚ๆŒ‡ไปค๏ผˆ`claudemd`๏ผ‰ไธ‹ๅ‘ๅˆฐๅฏนๅบ”็š„ `AGENTS.md`๏ผ›MCP Server ๅˆๅนถ่ฟ› `~/.omp/agent/mcp.json` / `/.omp/mcp.json`๏ผˆClaude `mcpServers` ็ป“ๆž„๏ผŒ่งไธŠๆ–‡ MCP ็ซ ่Š‚๏ผ‰ใ€‚Skills ้‡‡็”จไธ€ๅฑ‚ `/SKILL.md` ็›ฎๅฝ•็ป“ๆž„๏ผŒTeamAI ๅœจๅŒๆญฅๆ—ถ่กฅๅ…จ `description`โ€”โ€”OMP ๅŽŸ็”Ÿ skill ๅ‘็Žฐ่ฆๆฑ‚่ฏฅๅญ—ๆฎตใ€‚ไปฅไธŠ่ทฏๅพ„้ตๅพช OMP ๅฎ˜ๆ–นๆ–‡ๆกฃ็š„ๅ‘็Žฐๅธƒๅฑ€๏ผˆๅฏน็…ง OMP 18.2.5 ้ชŒ่ฏ๏ผ‰ใ€‚Hooks ่ตฐ OMP ็š„ extension runner๏ผš`teamai pull` ไผš็”Ÿๆˆๅ”ฏไธ€็š„ extension ๅ†™ๅ…ฅ `~/.omp/agent/extensions/teamai-hooks.ts`๏ผˆ็ปไธๅ†™้กน็›ฎๅ‰ฏๆœฌโ€”โ€”OMP ไผšๅŒๆ—ถๅŠ ่ฝฝไธคไธชๆ นๅนถๅฏผ่‡ดๆฏไธชไบ‹ไปถๅŒๆดพๅ‘๏ผ‰๏ผŒๅฎƒๆŠŠ OMP ็š„ `session_start` / `session_stop` / `before_agent_start` / `tool_result` ไบ‹ไปถ่ฝฌๅ‘็ป™ๆ‰€ๆœ‰ agent ๅ…ฑ็”จ็š„ `teamai hook-dispatch` ๅ…ฅๅฃ๏ผŒๅนถๆŒ‰ไผš่ฏ `cwd` ๅš้กน็›ฎ้—จๆŽงใ€‚ๆฏไธชไบ‹ไปถ้ƒฝๆบๅธฆ OMP ไผš่ฏ id๏ผˆ`ctx.sessionManager.getSessionId()`๏ผ›subagent ๆœ‰่‡ชๅทฑ็š„ไผš่ฏ๏ผ‰๏ผŒ`tool_result` ่ฟ˜ๅธฆไธŠๅทฅๅ…ท็š„ๆ–‡ๆœฌ่พ“ๅ‡บๅ’Œๆ นๆฎ `isError` ๅพ—ๅ‡บ็š„็Šถๆ€๏ผŒๅ› ๆญค upvote **้‡‡็บณ๏ผˆadoption๏ผ‰**ๅœจ OMP ไธป agent ไธŠ็”Ÿๆ•ˆ๏ผšOMP ไธๅœจๅ…ถ shell ไธญ่ฎพ็ฝฎไผš่ฏๅ˜้‡๏ผŒๆ‰€ไปฅ recall ๅฝ’ๅ…ฅ่ฟ่กŒๅฎƒ็š„้‚ฃๆฌก `bash` ่ฐƒ็”จๆ‰€ๅœจ็š„ไผš่ฏ๏ผ›ๅธฆ่กŒ้€‰ๆ‹ฉๅ™จ็š„ `read`๏ผˆ`x.md:50-200`ใ€`x.md:raw`๏ผ‰่ฎกไธบๅฏน่ฏฅๆ–‡ไปถ็š„่ฏปๅ–ใ€‚ไปŽ OMP 18.3.2 ่ตท๏ผŒsubagent ็š„ไบ‹ไปถ่ฟ˜ไผšๆบๅธฆๅ…ถ `ctx.agent` ็š„ id ๅ’Œๅ็งฐ๏ผŒๅ› ๆญค `teamai-recall` subagent ่‡ช่บซ็š„่ฏปๅ–ไปŽไธ่ฎกๅ…ฅใ€‚subagent ็š„ไผš่ฏๆ–‡ไปถไฝไบŽ็ˆถไผš่ฏๆ–‡ไปถไน‹ไธ‹๏ผŒ็ˆถไผš่ฏๆ–‡ไปถ็š„ๅคด้ƒจๅ†™ๆ˜Ž็ˆถไผš่ฏ id๏ผŒๅ› ๆญค extension ไผšๅœจ subagent ็š„ๅทฅๅ…ท่ฐƒ็”จไธญๅ…ณ่”่ฟ™ไธคไธชไผš่ฏ๏ผŒไธป agent ๅœจ subagent recall ไน‹ๅŽๆ‰“ๅผ€็š„ๆ–‡ๆกฃไผš่ขซ upvote๏ผˆๅฏน็…ง OMP 18.4.8 ้ชŒ่ฏ๏ผ‰ใ€‚`session_stop` ๅค„็†ๅ™จไธ่ฟ”ๅ›žไปปไฝ•ๅ€ผ๏ผŒๅˆ†ๅ‘็ปไธไผšๅผบๅˆถไผš่ฏ็ปง็ปญ๏ผ›็”ฑไบŽ OMP ็š„ๅทฅๅ…ทๅๆ˜ฏๅฐๅ†™๏ผˆ`bash`ใ€`read` ็ญ‰๏ผ‰ไธ”ๆฒกๆœ‰ `Skill` / `TodoWrite` ๅทฅๅ…ท๏ผŒpost-tool-use ไธๅš matcher ๅฎšๅ‘ๅˆ†ๅ‘ใ€‚`teamai uninstall` ไผš็งป้™ค่ฏฅ extensionใ€‚OMP ็š„ profile๏ผˆ`OMP_PROFILE` / `PI_CODING_AGENT_DIR` / `PI_CONFIG_DIR`๏ผŒไผš่ฟ็งป agent ็›ฎๅฝ•๏ผ‰ๆš‚ไธๆ”ฏๆŒ๏ผŒไฝฟ็”จ้ป˜่ฎค็š„ `~/.omp/agent/` ๅธƒๅฑ€ใ€‚ +Oh My Pi๏ผˆOMP๏ผ‰ๅทฒไฝœไธบๅ†…็ฝฎ็›ฎๆ ‡ๆ”ฏๆŒใ€‚TeamAI ๅฐ† Skillsใ€Rules ๅ’Œ Subagents ไธ‹ๅ‘ๅˆฐ OMP ็š„ๅŽŸ็”Ÿ็›ฎๅฝ•โ€”โ€”้กน็›ฎ็บงไธบ `.omp/skills/`ใ€`.omp/rules/` ๅ’Œ `.omp/agents/`๏ผŒ็”จๆˆท็บงไธบ `~/.omp/agent/skills/`ใ€`~/.omp/agent/rules/` ๅ’Œ `~/.omp/agent/agents/`๏ผˆ็”จๆˆท็บง่ต„ๆบไฝไบŽ agent ็›ฎๅฝ• `~/.omp/agent/` ไธ‹๏ผŒไธŽ้กน็›ฎ็บงๅ‰็ผ€ไธๅŒ๏ผŒTeamAI ไผš้šไฝœ็”จๅŸŸ่‡ชๅŠจๅˆ‡ๆข๏ผ‰ใ€‚ๅ›ข้˜ŸๆŒ‡ไปคๅœจ็”จๆˆท่Œƒๅ›ดๅ†™ๅ…ฅ `~/.omp/agent/RULES.md`๏ผŒๅœจ้กน็›ฎ่Œƒๅ›ด็”ฑไธ‹ๆ–‡็š„ extension ๅŠ ๅ…ฅๆฏ่ฝฎ็š„็ณป็ปŸๆ็คบ๏ผˆ่ง[่ฟ™ไบ›ๅ—ๅ†™ๅˆฐๅ“ช้‡Œ](#่ฟ™ไบ›ๅ—ๅ†™ๅˆฐๅ“ช้‡Œ)๏ผ‰๏ผ›MCP Server ๅˆๅนถ่ฟ› `~/.omp/agent/mcp.json` / `/.omp/mcp.json`๏ผˆClaude `mcpServers` ็ป“ๆž„๏ผŒ่งไธŠๆ–‡ MCP ็ซ ่Š‚๏ผ‰ใ€‚Skills ้‡‡็”จไธ€ๅฑ‚ `/SKILL.md` ็›ฎๅฝ•็ป“ๆž„๏ผŒTeamAI ๅœจๅŒๆญฅๆ—ถ่กฅๅ…จ `description`โ€”โ€”OMP ๅŽŸ็”Ÿ skill ๅ‘็Žฐ่ฆๆฑ‚่ฏฅๅญ—ๆฎตใ€‚ไปฅไธŠ่ทฏๅพ„้ตๅพช OMP ๅฎ˜ๆ–นๆ–‡ๆกฃ็š„ๅ‘็Žฐๅธƒๅฑ€๏ผˆๅฏน็…ง OMP 18.2.5 ้ชŒ่ฏ๏ผ‰ใ€‚Hooks ่ตฐ OMP ็š„ extension runner๏ผš`teamai pull` ไผš็”Ÿๆˆๅ”ฏไธ€็š„ extension ๅ†™ๅ…ฅ `~/.omp/agent/extensions/teamai-hooks.ts`๏ผˆ็ปไธๅ†™้กน็›ฎๅ‰ฏๆœฌโ€”โ€”OMP ไผšๅŒๆ—ถๅŠ ่ฝฝไธคไธชๆ นๅนถๅฏผ่‡ดๆฏไธชไบ‹ไปถๅŒๆดพๅ‘๏ผ‰๏ผŒๅฎƒๆŠŠ OMP ็š„ `session_start` / `session_stop` / `before_agent_start` / `tool_result` ไบ‹ไปถ่ฝฌๅ‘็ป™ๆ‰€ๆœ‰ agent ๅ…ฑ็”จ็š„ `teamai hook-dispatch` ๅ…ฅๅฃ๏ผŒๅนถๆŒ‰ไผš่ฏ `cwd` ๅš้กน็›ฎ้—จๆŽงใ€‚ๅœจ้กน็›ฎไผš่ฏไธญ๏ผŒๅฎƒ่ฟ˜ไผšๅœจ `session_start` ๆ—ถ่Žทๅ–ๆˆๅ‘˜็š„ๅ›ข้˜ŸๆŒ‡ไปค๏ผŒๅนถๅœจ `before_agent_start` ไธญ่ฟฝๅŠ ๅˆฐ็ณป็ปŸๆ็คบใ€‚ๆฏไธชไบ‹ไปถ้ƒฝๆบๅธฆ OMP ไผš่ฏ id๏ผˆ`ctx.sessionManager.getSessionId()`๏ผ›subagent ๆœ‰่‡ชๅทฑ็š„ไผš่ฏ๏ผ‰๏ผŒ`tool_result` ่ฟ˜ๅธฆไธŠๅทฅๅ…ท็š„ๆ–‡ๆœฌ่พ“ๅ‡บๅ’Œๆ นๆฎ `isError` ๅพ—ๅ‡บ็š„็Šถๆ€๏ผŒๅ› ๆญค upvote **้‡‡็บณ๏ผˆadoption๏ผ‰**ๅœจ OMP ไธป agent ไธŠ็”Ÿๆ•ˆ๏ผšOMP ไธๅœจๅ…ถ shell ไธญ่ฎพ็ฝฎไผš่ฏๅ˜้‡๏ผŒๆ‰€ไปฅ recall ๅฝ’ๅ…ฅ่ฟ่กŒๅฎƒ็š„้‚ฃๆฌก `bash` ่ฐƒ็”จๆ‰€ๅœจ็š„ไผš่ฏ๏ผ›ๅธฆ่กŒ้€‰ๆ‹ฉๅ™จ็š„ `read`๏ผˆ`x.md:50-200`ใ€`x.md:raw`๏ผ‰่ฎกไธบๅฏน่ฏฅๆ–‡ไปถ็š„่ฏปๅ–ใ€‚ไปŽ OMP 18.3.2 ่ตท๏ผŒsubagent ็š„ไบ‹ไปถ่ฟ˜ไผšๆบๅธฆๅ…ถ `ctx.agent` ็š„ id ๅ’Œๅ็งฐ๏ผŒๅ› ๆญค `teamai-recall` subagent ่‡ช่บซ็š„่ฏปๅ–ไปŽไธ่ฎกๅ…ฅใ€‚subagent ็š„ไผš่ฏๆ–‡ไปถไฝไบŽ็ˆถไผš่ฏๆ–‡ไปถไน‹ไธ‹๏ผŒ็ˆถไผš่ฏๆ–‡ไปถ็š„ๅคด้ƒจๅ†™ๆ˜Ž็ˆถไผš่ฏ id๏ผŒๅ› ๆญค extension ไผšๅœจ subagent ็š„ๅทฅๅ…ท่ฐƒ็”จไธญๅ…ณ่”่ฟ™ไธคไธชไผš่ฏ๏ผŒไธป agent ๅœจ subagent recall ไน‹ๅŽๆ‰“ๅผ€็š„ๆ–‡ๆกฃไผš่ขซ upvote๏ผˆๅฏน็…ง OMP 18.4.8 ้ชŒ่ฏ๏ผ‰ใ€‚`session_stop` ๅค„็†ๅ™จไธ่ฟ”ๅ›žไปปไฝ•ๅ€ผ๏ผŒๅˆ†ๅ‘็ปไธไผšๅผบๅˆถไผš่ฏ็ปง็ปญ๏ผ›็”ฑไบŽ OMP ็š„ๅทฅๅ…ทๅๆ˜ฏๅฐๅ†™๏ผˆ`bash`ใ€`read` ็ญ‰๏ผ‰ไธ”ๆฒกๆœ‰ `Skill` / `TodoWrite` ๅทฅๅ…ท๏ผŒpost-tool-use ไธๅš matcher ๅฎšๅ‘ๅˆ†ๅ‘ใ€‚็”จๆˆท็บง `teamai uninstall` ไผš็งป้™ค่ฏฅ extension๏ผ›้กน็›ฎ็บงๅธ่ฝฝไธบๅ…ถไป–้กน็›ฎไฟ็•™ๅฎƒใ€‚ไธŽ Pi ไธ€ๆ ท๏ผŒไธๅธฆ TeamAI ๆ ‡่ฎฐ็š„ๅŒๅๆ–‡ไปถ็ปไธไผš่ขซ่ฆ†็›–ๆˆ–ๅˆ ้™คใ€‚OMP ็š„ profile๏ผˆ`OMP_PROFILE` / `PI_CODING_AGENT_DIR` / `PI_CONFIG_DIR`๏ผŒไผš่ฟ็งป agent ็›ฎๅฝ•๏ผ‰ๆš‚ไธๆ”ฏๆŒ๏ผŒไฝฟ็”จ้ป˜่ฎค็š„ `~/.omp/agent/` ๅธƒๅฑ€ใ€‚ ### DeepSeek Harness @@ -2588,6 +2649,8 @@ teamai models remove local:my-gateway # Agent ไฟ็•™ๅฝ“ๅ‰้…็ฝฎ๏ผŒrestore ไป `teamai uninstall` ไผšๆ™บ่ƒฝๆธ…็†ๆ‰€ๆœ‰ teamai ็ฎก็†็š„่ต„ๆบ๏ผŒ**ไฟ็•™็”จๆˆท่‡ชๅปบๅ†…ๅฎน**ใ€‚ +ๅณไฝฟๆฒกๆœ‰ๆœฌๅœฐๆ–‡ไปถ้œ€่ฆๅˆ ้™ค๏ผŒๆŽ’้™ค้กน็›ฎไธญ็š„ๆŒ‡ๅฎšๅทฅๅ…ทไนŸ้œ€่ฆ็กฎ่ฎคๆˆ– `--force`ใ€‚`--dry-run` ๆˆ–ๆ‹’็ป็กฎ่ฎคไธไผšไฟฎๆ”น้กน็›ฎ้…็ฝฎใ€‚ + ```bash # ้ข„่งˆๅฐ†่ฆ็งป้™ค็š„ๆฏไธชๅ—็ฎก่ทฏๅพ„๏ผˆไธๅšๅฎž้™…ๅ˜ๆ›ด๏ผ‰ teamai uninstall --dry-run @@ -2605,7 +2668,7 @@ teamai uninstall --agent claude ็งป้™คๅ†…ๅฎน๏ผš - ๅฆ‚ๆžœ ownership ไปๆœ‰ๆ•ˆ๏ผŒๅ…ˆๆขๅค TeamAI ็ฎก็†็š„ๆจกๅž‹้…็ฝฎ - AI ๅทฅๅ…ท settings ไธญ็š„ teamai hooks -- CLAUDE.md ๅ’Œ AGENTS.md ไธญ็š„ teamai ๅ—๏ผˆไฟ็•™็”จๆˆท่‡ชๅ†™ๅ†…ๅฎน๏ผ‰ +- ๅ„ๅทฅๅ…ทๆŒ‡ไปคๆ–‡ไปถไธญ็š„ teamai ๅ—๏ผˆๆ–‡ๅŒ–ใ€ๅ…ฑไบซๆŒ‡ไปคใ€recall ไปฅๅŠ Codex ็š„ๅ›ข้˜Ÿ่ง„ๅˆ™๏ผ‰๏ผŒไปฅๅŠๆ—ฉๆœŸ็‰ˆๆœฌๅ†™่ฟ‡่ฟ™ไบ›ๅ—็š„ๆ–‡ไปถ๏ผˆไฟ็•™็”จๆˆท่‡ชๅ†™ๅ†…ๅฎน๏ผ›teamai ๅ†™ๅ…ฅ็š„ `teamai-context` ๆ–‡ไปถๆ•ดไฝ“ๅˆ ้™ค๏ผŒ่‹ฅ OpenCode ไธญๅฏนๅบ”็š„ `instructions` ๆก็›ฎ็”ฑ teamai ๆทปๅŠ ๏ผŒๅˆ™ไธ€ๅนถ็งป้™ค๏ผŒๅณไฝฟไฝ ่‡ชๅ†™็š„ๅ†…ๅฎน่ฎฉ่ฏฅๆ–‡ไปถไฟ็•™ไธ‹ๆฅ๏ผŒๆˆ–่ฏฅๆ–‡ไปถๅทฒไธๅญ˜ๅœจ๏ผ›ไฝ ่‡ชๅทฑๅˆ—ๅ…ฅ็š„ๆก็›ฎไบˆไปฅไฟ็•™๏ผ‰ - ๅ›ข้˜ŸๅŒๆญฅ็š„ skills๏ผŒๅŒ…ๆ‹ฌ OpenClaw workspace skills๏ผˆไฟ็•™็”จๆˆท่‡ชๅปบ skills๏ผ‰ - ๅ›ข้˜ŸๅŒๆญฅ็š„ rules๏ผŒๅŒ…ๆ‹ฌๆ—ง็‰ˆๆœฌ็•™ๅœจ `.codex/rules/` ไธญ็š„ๅ‰ฏๆœฌ๏ผŒๅ›ข้˜ŸๆญคๅŽๅทฒๅˆ ้™ค็š„ rule ็š„ๅ‰ฏๆœฌไนŸๅŒ…ๆ‹ฌๅœจๅ†…ใ€‚ๆธ…็†ไฝฟ็”จ่ฎฐๅฝ•็š„ `toolRoots` ไฝ็ฝฎๅ’Œๅ‘ๅธƒ่€…ๆœฌๅœฐ็š„ๆ–‡ไปถๅใ€‚ๅ…ถไธญไฝ ๆ”น่ฟ‡็š„ๅ‰ฏๆœฌไผšไฟ็•™๏ผŒๅนถๅœจ่ญฆๅ‘Šไธญ็‚นๅใ€‚ๅทฒๅˆ ้™ค rule ็š„ๅ‰ฏๆœฌๅชๆœ‰ไธŽ่ฎฐๅฝ•็š„ๆŠ•้€’ๅ“ˆๅธŒไธ€่‡ดๆ—ถๆ‰ไผšๅˆ ้™ค๏ผ›ๆฒกๆœ‰่ฏฅ่ฎฐๅฝ•ๆ—ถไนŸไผšไฟ็•™ๅนถ็‚นๅใ€‚Codex ็š„ `*.rules` ๆ–‡ไปถไฟ็•™ - ๅ›ข้˜ŸๅŒๆญฅ็š„่‡ชๅฎšไน‰ agents ๅ’Œ CLI ๅ†…็ฝฎ agents๏ผˆไฟ็•™็”จๆˆท่‡ชๅปบ agents๏ผ‰ @@ -2614,15 +2677,21 @@ teamai uninstall --agent claude ### ๅชๅธ่ฝฝๅ•ไธชๅทฅๅ…ท๏ผˆ`--agent `๏ผ‰ -`--agent ` ๅช็งป้™ค่ฏฅๅทฅๅ…ท็š„ teamai ่ต„ๆบ๏ผˆhooksใ€CLAUDE.md ๅ—ใ€skillsใ€rulesใ€ๅ›ข้˜ŸๅŒๆญฅ็š„่‡ชๅฎšไน‰ agentsใ€ๅ†…็ฝฎ agents๏ผ‰ใ€‚ๅทฅๅ…ทๅๅณ `toolPaths` ็š„้”ฎ๏ผˆๅฆ‚ `claude`ใ€`codex`ใ€`codebuddy`๏ผ‰๏ผŒๅŒน้…ๅคงๅฐๅ†™ไธๆ•ๆ„Ÿใ€‚ไผ ๅ…ฅๆœช็Ÿฅๅทฅๅ…ทๅไผš็›ดๆŽฅๆŠฅ้”™ๅนถๅˆ—ๅ‡บๅฏ็”จๅทฅๅ…ทใ€ไธๆ‰ง่กŒไปปไฝ•ๅˆ ้™ค๏ผŒๅนถไปฅ้ž้›ถ็Šถๆ€็ ้€€ๅ‡บใ€‚ +`--agent ` ๅช็งป้™ค่ฏฅๅทฅๅ…ท็š„ teamai ่ต„ๆบ๏ผˆhooksใ€ๅ›ข้˜ŸๆŒ‡ไปคๅ—ใ€skillsใ€rulesใ€ๅ›ข้˜ŸๅŒๆญฅ็š„่‡ชๅฎšไน‰ agentsใ€ๅ†…็ฝฎ agents๏ผ‰ใ€‚ๅทฅๅ…ทๅๅณ `toolPaths` ็š„้”ฎ๏ผˆๅฆ‚ `claude`ใ€`codex`ใ€`codebuddy`๏ผ‰๏ผŒๅŒน้…ๅคงๅฐๅ†™ไธๆ•ๆ„Ÿใ€‚ไผ ๅ…ฅๆœช็Ÿฅๅทฅๅ…ทๅไผš็›ดๆŽฅๆŠฅ้”™ๅนถๅˆ—ๅ‡บๅฏ็”จๅทฅๅ…ทใ€ไธๆ‰ง่กŒไปปไฝ•ๅˆ ้™ค๏ผŒๅนถไปฅ้ž้›ถ็Šถๆ€็ ้€€ๅ‡บใ€‚ + +ๅคšไธชๅทฅๅ…ทๅ…ฑๅŒๆ˜ ๅฐ„็š„ๆŒ‡ไปคๆ–‡ไปถๆŒ‰ๅŒบๅ—ๆธ…็†๏ผšๅช่ฆ่ฏฅๆ–‡ไปถไธŠไปๆœ‰ๅ‰ฉไฝ™ๅทฅๅ…ทไผšๅ†™ๅ…ฅๆŸไธช teamai ๅŒบๅ—๏ผŒ่ฏฅๅŒบๅ—ๅฐฑไฟ็•™ใ€‚ๆœ€ๅธธ่ง็š„ๆ˜ฏ CodeBuddy ไธŽ WorkBuddy ๅ…ฑ็”จ็š„ `.codebuddy/rules/teamai-context.md`๏ผšๅช่ฆ CodeBuddy ไปๅทฒๅฎ‰่ฃ…๏ผŒ`--agent workbuddy` ๅฐฑไผšไฟ็•™ๅฎƒใ€‚ๆ—ฉๆœŸ็‰ˆๆœฌๅ†™่ฟ‡่ฟ™ไบ›ๅ—็š„ๆ–‡ไปถ๏ผˆไพ‹ๅฆ‚้กน็›ฎ `AGENTS.md`๏ผ‰็Žฐๅœจๆฒกๆœ‰ไปปไฝ•ๅทฅๅ…ท่ฏปๅ–๏ผŒๅ› ๆญคๅ…ถไธญ็š„ teamai ๅŒบๅ—ไผš่ขซ็งป้™ค๏ผŒไฝ ่‡ชๅทฑ็š„ๅ†…ๅฎนไฟ็•™ใ€‚teamai ๅˆ›ๅปบ็š„ๆ–‡ไปถ้šๆœ€ๅŽไธ€ไธชๅŒบๅ—ไธ€่ตทๅˆ ้™ค๏ผ›ไฝ ๅŽŸๆœ‰็š„ๆŒ‡ไปคๆ–‡ไปถ๏ผˆๅณไฝฟๆ˜ฏ็ฉบๆ–‡ไปถ๏ผ‰ไผšไฟ็•™ใ€‚้…็ฝฎ็š„ `claudemd` ๅณไฝฟๅไธบ `teamai-context.md`๏ผŒไนŸไปๆ˜ฏๆˆๅ‘˜ๆ–‡ไปถใ€‚ + +่ทจๅทฅๅ…ทๅ…ฑไบซ่ต„ๆบ๏ผˆshell profile env ๅ—ใ€docs ็›ฎๅฝ•ใ€`~/.teamai/`๏ผ‰**ไป…ๅฝ“่ฏฅๅทฅๅ…ท่‡ช่บซๅญ˜ๅœจ teamai ่ต„ๆบใ€ไธ”ๅฎƒๆ˜ฏๆœ€ๅŽไธ€ไธชไปๅœจไฝฟ็”จ teamai ็š„ๅทฅๅ…ทๆ—ถ**ๆ‰ไธ€ๅนถ็งป้™ค๏ผŒๅฆๅˆ™ไผšไธบๅ…ถไฝ™ๅทฅๅ…ทไฟ็•™ใ€‚ๆฒกๆœ‰ๆœฌๅœฐ่ต„ๆบ็š„ๅทฅๅ…ทๅณไฝฟๆ˜ฏๅ”ฏไธ€็š„ๅทฅๅ…ท๏ผŒๅฎšๅ‘ๅธ่ฝฝไนŸไผšไฟ็•™ๅ…ฑไบซ่ต„ๆบใ€‚Piใ€Oh My Piใ€Hermes ๅ’Œ Codex ็ณปๅˆ—็š„ๆŒ‡ไปค้€š้“ไฝไบŽๅ…จๅฑ€๏ผŒๅ› ๆญค้กน็›ฎ็บงๅธ่ฝฝไปไผš่ฎฐๅฝ•ๅฏนๅฎƒไปฌ็š„ๆŽ’้™ค่ฎพ็ฝฎใ€‚ + +ๅทฒๅฏ็”จไธ”ๅทฒๅฎ‰่ฃ…็š„ Piใ€Oh My Piใ€Hermes ๆˆ–้กน็›ฎ็บง Codex ้€š่ฟ‡ๅ…จๅฑ€ๆŠ•้€’้€š้“็ปง็ปญไฝฟ็”จ้กน็›ฎ็Šถๆ€๏ผŒๅณไฝฟ้กน็›ฎไธญๆฒกๆœ‰่ฏฅๅทฅๅ…ท็š„ๆœฌๅœฐ็›ฎๅฝ•ใ€‚ๅธ่ฝฝๅ…ถไป–ๅทฅๅ…ทๆ—ถไผšไฟ็•™ๆญค็Šถๆ€๏ผŒไปฅไพฟๅ‰ฉไฝ™ๅทฅๅ…ท็ปง็ปญๆŠ•้€’่ฏฅ้กน็›ฎ็š„ๆŒ‡ไปคใ€‚ -ๅคšไธชๅทฅๅ…ทๅ…ฑๅŒๆ˜ ๅฐ„็š„ๆŒ‡ไปคๆ–‡ไปถๆŒ‰ๅŒบๅ—ๆธ…็†๏ผšๅช่ฆ่ฏฅๆ–‡ไปถไธŠไปๆœ‰ๅ‰ฉไฝ™ๅทฅๅ…ทไผšๅ†™ๅ…ฅๆŸไธช teamai ๅŒบๅ—๏ผŒ่ฏฅๅŒบๅ—ๅฐฑไฟ็•™ใ€‚ๆœ€ๅธธ่ง็š„ๆ˜ฏ้กน็›ฎ `AGENTS.md`๏ผšPi ไปๅฏ็”จๆ—ถ๏ผŒ`--agent workbuddy` ไผš็งป้™ค recall ๅŒบๅ—๏ผŒไฟ็•™ Pi ๅ†™ๅ…ฅ็š„ culture ๅ’Œๅ…ฑไบซๆŒ‡ไปคๅŒบๅ—ใ€‚Codex ็ณปๅทฅๅ…ทไธไผšๅœจ้‚ฃ้‡Œๅ†™ๅ…ฅไปปไฝ•ๅ†…ๅฎนใ€‚teamai ๅˆ›ๅปบ็š„ๆ–‡ไปถ้šๆœ€ๅŽไธ€ไธชๅŒบๅ—ไธ€่ตทๅˆ ้™ค๏ผ›ไฝ ๅŽŸๆœ‰็š„ๆŒ‡ไปคๆ–‡ไปถ๏ผˆๅณไฝฟๆ˜ฏ็ฉบๆ–‡ไปถ๏ผ‰ไผšไฟ็•™ใ€‚ +่‹ฅๅˆ ้™ค teamai ๆทปๅŠ ็š„ OpenCode ๆก็›ฎๅคฑ่ดฅ๏ผŒๅธ่ฝฝไปฅ้”™่ฏฏ็Šถๆ€้€€ๅ‡บ๏ผŒๅนถไฟ็•™ๅ…ฑไบซๆ•ฐๆฎ็›ฎๅฝ•ๅ’Œๆ‰€ๆœ‰ๆƒ่ฎฐๅฝ•๏ผŒๅณไฝฟ OpenCode ๆ˜ฏๆœ€ๅŽไธ€ไธชๅทฅๅ…ทใ€‚ไฟฎๅค้…็ฝฎๆˆ–ๆƒ้™ๅŽ๏ผŒ้‡่ฏ•ๅŒไธ€ๅธ่ฝฝๅ‘ฝไปคใ€‚ -่ทจๅทฅๅ…ทๅ…ฑไบซ่ต„ๆบ๏ผˆshell profile env ๅ—ใ€docs ็›ฎๅฝ•ใ€`~/.teamai/`๏ผ‰**ไป…ๅฝ“่ฏฅๅทฅๅ…ท่‡ช่บซๅญ˜ๅœจ teamai ่ต„ๆบใ€ไธ”ๅฎƒๆ˜ฏๆœ€ๅŽไธ€ไธชไปๅœจไฝฟ็”จ teamai ็š„ๅทฅๅ…ทๆ—ถ**ๆ‰ไธ€ๅนถ็งป้™ค๏ผŒๅฆๅˆ™ไผšไธบๅ…ถไฝ™ๅทฅๅ…ทไฟ็•™ใ€‚๏ผˆๅ› ๆญค๏ผŒๅฎšๅ‘ๅธ่ฝฝไธ€ไธช่‡ช่บซๆฒกๆœ‰ไปปไฝ• teamai ่ต„ๆบ็š„ๅทฅๅ…ทๆ˜ฏ no-op๏ผŒๅณไพฟๅฎƒๆฐๅฅฝๆ˜ฏๅ”ฏไธ€็š„ๅทฅๅ…ท๏ผŒไนŸไธไผšๅˆ ้™คๅ…ฑไบซ่ต„ๆบใ€‚๏ผ‰ +้กน็›ฎ็บงๅธ่ฝฝไฟ็•™ Pi ๅ’Œ Oh My Pi ็š„ๅ…จๅฑ€ๆ‰ฉๅฑ•ใ€Hermes ็š„ๅ…จๅฑ€ๆ’ไปถๅ’Œ้…็ฝฎใ€Codex ็ณปๅˆ—็š„็”จๆˆท็บง hooks ไปฅๅŠๆœๅŠก็ซฏไธ‹ๅ‘็š„ agent hooks๏ผŒๅ› ไธบๆœฌๆœบ็š„็”จๆˆท็บงๅฎ‰่ฃ…ใ€HTTP agent ๆˆ–ๅ…ถไป–้กน็›ฎๅฏ่ƒฝไปๅœจไฝฟ็”จๅฎƒไปฌ๏ผŒๅนถๅœจๆ‘˜่ฆไธญๅˆ—ๅ‡บใ€‚่‹ฅๆฒกๆœ‰ๅ…ถไป–ๅฎ‰่ฃ…ไฝฟ็”จๅฎƒไปฌ๏ผŒ่ฏทๅœจๅธ่ฝฝๅ‰ไบŽ่ฏฅ้กน็›ฎไธญ่ฟ่กŒ `teamai hooks remove`๏ผŒๅฎƒไผš็งป้™ค่ฟ™ไบ›ๅ†…ๅฎนใ€‚ๅฎšๅ‘้กน็›ฎ็บง Codex ๅธ่ฝฝไฟ็•™้กน็›ฎ้…็ฝฎไปฅ่ฎฐๅฝ•ๆŽ’้™ค่ฎพ็ฝฎ๏ผŒไป…ๆธ…็†้กน็›ฎๆ‹ฅๆœ‰็š„่ต„ๆบๅ’Œๆ—ง hook ๅ‰ฏๆœฌใ€‚ๅ•ๅทฅๅ…ทๅธ่ฝฝๅœจ้กน็›ฎ้…็ฝฎไปไฟ็•™ๆ—ถ๏ผŒๅฐ†่ฏฅๅทฅๅ…ทๅŠ ๅ…ฅๆญค้กน็›ฎ็š„ๆŽ’้™คๅˆ—่กจใ€‚็”จๆˆท็บงๅธ่ฝฝๆ‰็งป้™ค่ฟ™ไบ›ๅ…จๅฑ€ๆŠ•้€’้€š้“ใ€‚ -่ฏฅๆŽ’้™คๆ˜ฏๆŒไน…็š„๏ผš`uninstall --agent ` ไผšๆŠŠ่ฏฅๅทฅๅ…ทไปŽ `enabledAgents` ็งป้™คๅนถ่ฎฐๅ…ฅ `disabledAgents`๏ผŒๅ› ๆญคไน‹ๅŽ็š„ `pull`๏ผˆๆˆ–ๅ…ถไป–ๅทฅๅ…ท็š„ session-start hook๏ผ‰ไธไผšๅ†ๆŠŠๅฎƒ็š„ skillsใ€rulesใ€agentsใ€CLAUDE.md ๅ—ๆˆ– hooks ้‡ๆ–ฐ่ฃ…ๅ›žใ€‚้‡ๆ–ฐๆ‰ง่กŒ `init --agent ` ไผšๆธ…้™ค่ฏฅๆŽ’้™คใ€ๆขๅคๅฏน่ฏฅๅทฅๅ…ท็š„ๅŒๆญฅใ€‚ +่ฏฅๆŽ’้™คๆ˜ฏๆŒไน…็š„๏ผš`uninstall --agent ` ไผšๆŠŠ่ฏฅๅทฅๅ…ทไปŽ `enabledAgents` ็งป้™คๅนถ่ฎฐๅ…ฅ `disabledAgents`๏ผŒๅ› ๆญคไน‹ๅŽ็š„ `pull`๏ผˆๆˆ–ๅ…ถไป–ๅทฅๅ…ท็š„ session-start hook๏ผ‰ไธไผšๅ†ๆŠŠๅฎƒ็š„ skillsใ€rulesใ€agentsใ€ๅ›ข้˜ŸๆŒ‡ไปคๅ—ๆˆ– hooks ้‡ๆ–ฐ่ฃ…ๅ›žใ€‚ไฟ็•™็š„ๅ…จๅฑ€้€‚้…ๅ™จไนŸไผš่ทณ่ฟ‡่ขซๆŽ’้™คๅทฅๅ…ท็š„ HTTP ๅŒๆญฅๅ’Œ็ผ“ๅญ˜ HTTP prompt ๆณจๅ…ฅใ€‚้‡ๆ–ฐๆ‰ง่กŒ `init --agent ` ไผšๆธ…้™ค่ฏฅๆŽ’้™คใ€ๆขๅคๅฏน่ฏฅๅทฅๅ…ท็š„ๅŒๆญฅใ€‚ -ๅŒไธ€ๅฅ— `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 ่ทณ่ฟ‡็ผ“ๅญ˜ไผšๅฏนๆ–ฐๅŠ ๅ…ฅ็š„ๅทฅๅ…ทๅคฑๆ•ˆใ€‚ +ๅŒไธ€ๅฅ— `enabledAgents` ็™ฝๅๅ•๏ผˆๆฅ่‡ช `init --agent`๏ผ‰ไนŸ็บฆๆŸ CLI ๅ†…็ฝฎ skills/rules/agents ไปฅๅŠๅ›ข้˜ŸๆŒ‡ไปคๅ—๏ผšๅณไฝฟๅทฅๅ…ทๆ น็›ฎๅฝ•ๅทฒ็ปๅญ˜ๅœจ๏ผŒ็™ฝๅๅ•ๅค–็š„ๅทฒๅฎ‰่ฃ…ๅทฅๅ…ทไนŸไธไผš่ขซๅ†™ๅ…ฅๆˆ–ๅˆ ้™คใ€‚`teamai remove` ๅฏน agentsใ€rules ๅ’Œ skills ๅŒๆ ท้ตๅฎˆ่ฏฅ็™ฝๅๅ•๏ผŒ`teamai push` ไนŸไธไผšไปŽ็™ฝๅๅ•ๅค–็š„ๅทฅๅ…ท่ฏปๅ– rules ๅ’Œ agents๏ผŒ`teamai pull` / `teamai mcp inject` ๅฏน MCP servers ไนŸ้ตๅฎˆ่ฏฅ็™ฝๅๅ•ใ€‚ไธ็ป่ฟ‡ `init` ็›ดๆŽฅๆŠŠๅทฅๅ…ทๅŠ ่ฟ› `enabledAgents` ๆ—ถ๏ผŒlast-pull ่ทณ่ฟ‡็ผ“ๅญ˜ไผšๅฏนๆ–ฐๅŠ ๅ…ฅ็š„ๅทฅๅ…ทๅคฑๆ•ˆใ€‚ ๅธ่ฝฝๅŽๅฆ‚้œ€้‡ๆ–ฐๅŠ ๅ…ฅ๏ผš diff --git a/skill-data/core/references/troubleshooting.md b/skill-data/core/references/troubleshooting.md index 159c029bb..a1a37513d 100644 --- a/skill-data/core/references/troubleshooting.md +++ b/skill-data/core/references/troubleshooting.md @@ -226,3 +226,31 @@ SessionEnd, or at the next `teamai pull`. - `teamai status` shows exactly how local differs from the team repo. - Report unexpected behavior at https://github.com/Tencent/teamai-cli/issues with the agent name, platform, and the step that failed. + +## "Pull left an instruction file unchanged" + +If pull reports incomplete TeamAI markers, it keeps the entire file unchanged. +Fix the named block so it has exactly one start marker followed by one end +marker, then run `teamai pull` again. Other files can still sync successfully. + +Pull keeps retired instruction blocks until every installed tool that wrote the +file has a working replacement. Repair the named target, extension or plugin +and run `teamai pull` again. Excluded tools' current and retired files stay +unchanged and are excluded from doctor's stale-instruction check. +When a native project file retains a TeamAI block, the session hook skips that +block, including cached HTTP prompts, until cleanup succeeds. Other blocks +still reach the hook. Doctor reports malformed markers in retired files; +repair them before retrying pull. +If a block's source cannot be resolved, its old block stays even when other +blocks sync. Repair the source and pull again to complete its migration. +HTTP prompt commands verify earlier deliveries against the current prompt +before cleaning shared instructions. Older destination contents do not count; +culture and recall stay because HTTP prompt commands do not replace them. +An HTTP prompt sync that cannot clean retired blocks reports a failed ACK and +keeps its previous cache and manifest for the server's retry. + +OpenCode registration saves ownership before activating a new config entry. +If the state write fails, repair the state directory's permissions and retry +`teamai pull`; the entry is not activated without its removal ownership. +If the config write fails, ownership stays available for retry. Entries the +member already listed are never claimed. diff --git a/skill-data/setup/references/uninstall.md b/skill-data/setup/references/uninstall.md index 0495129fd..08f2f4a23 100644 --- a/skill-data/setup/references/uninstall.md +++ b/skill-data/setup/references/uninstall.md @@ -14,11 +14,13 @@ machine** (all tools)?"* - **Just this tool** โ†’ `--agent ` (use the tool this conversation runs in, e.g. `claude`). Shared resources are removed only if it is the last tool using - them. An instructions file several tools read (the project `AGENTS.md` of Pi, - Hermes and WorkBuddy) is cleaned block by block: a teamai block stays while - a remaining tool on that file still writes it, so `--agent workbuddy` with Pi - still enabled removes the recall block and keeps the rest. A file teamai - created goes with its last block; one the user had before stays, even if empty. + them. An instructions file several tools read (CodeBuddy and WorkBuddy share + `.codebuddy/rules/teamai-context.md`) is cleaned block by block: a teamai + block stays while a remaining tool on that file still writes it, so + `--agent workbuddy` keeps that file while CodeBuddy is installed. A file an + earlier release wrote the blocks to, such as the project `AGENTS.md`, loses + its teamai blocks, since no tool reads them there now. A file teamai created + goes with its last block; one the user had before stays, even if empty. - **Whole machine** โ†’ no `--agent` flag. Reassure them (in their language): *"This only removes things from your computer. @@ -27,6 +29,8 @@ Your team's repo on the website is untouched โ€” you can rejoin any time with ## Step 2 โ€” Run it (you run it) +A targeted project exclusion needs the same confirmation even when there are no local files to remove. `--dry-run` and declining confirmation leave the project config unchanged. + Whole machine: ```bash @@ -61,6 +65,22 @@ and give it your team repo URL."* removed, it deletes the copy only if its hash matches the recorded delivery. Without that record, it keeps the copy and names it in a warning. Save any changes you need, then delete the copy manually. +- If an OpenCode config entry cannot be removed, repair its config or permissions + and retry the same uninstall command. Uninstall reports failure and keeps + its ownership record and shared data directory, even for the last tool. +- Project uninstall keeps the global Pi and Oh My Pi extensions, Hermes + plugin and config, and the Codex family's user-level hooks, which the user + scope, the HTTP agent or another project may use, and names them. If none + does, run `teamai hooks remove` in the project first: it removes them. + Targeted project Codex uninstall keeps project config and records its + exclusion, even without local resources. Legacy project hook copies go. + User-scope uninstall removes these global channels. + The retained adapters respect project exclusions, including cached HTTP + prompt injection and HTTP sync. An excluded tool does not download its + resources again on the next session start. +- An enabled, installed Pi, Oh My Pi, Hermes or project Codex also keeps the + project's shared state in use without a local tool directory. Uninstalling + another tool preserves that state and the remaining tool's instructions. - Do **not** delete the team repo on the Git platform โ€” uninstall never touches it, and neither should you. - If the user only wants to stop auto-sync for one tool but keep TeamAI otherwise, diff --git a/src/__tests__/codex-hook-rules.test.ts b/src/__tests__/codex-hook-rules.test.ts index e1e0d53c8..254c5914f 100644 --- a/src/__tests__/codex-hook-rules.test.ts +++ b/src/__tests__/codex-hook-rules.test.ts @@ -127,18 +127,22 @@ projects: expect(text).not.toContain('Billing instructions.'); }); - it('skips a block another tool already wrote into the project AGENTS.md, which Codex reads', async () => { + it('skips shared instructions still read natively from a retained project AGENTS.md (#945)', async () => { await fse.outputFile(path.join(repoPath, 'culture.md'), '---\ncompany:\n name: Acme\n---\n\nBe kind to teammates.\n'); await fse.outputFile(path.join(repoPath, 'claudemd', 'shared.md'), 'Shared team instructions.\n'); await fse.outputFile( path.join(tmpDir, 'project', 'AGENTS.md'), - '# Notes\n\n\nBe kind to teammates.\n\n', + '# Notes\n\n\nAnother member\'s selection.\n\n', ); const text = (await context({ hook_event_name: 'SessionStart', source: 'startup' }))!; - expect(text).not.toContain('Be kind to teammates.'); - expect(text).toContain('Shared team instructions.'); + expect(text).toContain('Be kind to teammates.'); + expect(text).not.toContain('Shared team instructions.'); + expect(text).not.toContain('Another member'); + expect(text).not.toContain('[teamai:'); + await fse.writeFile(path.join(tmpDir, 'project', 'AGENTS.md'), '# Notes\n'); + expect(await context({ hook_event_name: 'SessionStart', source: 'startup' })).toContain('Shared team instructions.'); }); it.each(['# Owners\n', ''])('adds blocks from a shadowed AGENTS.md when AGENTS.override.md contains %j', async (override) => { @@ -162,12 +166,17 @@ projects: expect(await fse.readFile(path.join(tmpDir, 'project', 'AGENTS.override.md'), 'utf8')).toBe(override); }); - it('skips a block already present in the active AGENTS.override.md', async () => { + it('skips culture still read natively from AGENTS.override.md until cleanup (#945)', async () => { await fse.outputFile(path.join(repoPath, 'culture.md'), '---\ncompany:\n name: Acme\n---\n\nBe kind to teammates.\n'); await fse.outputFile(path.join(tmpDir, 'project', 'AGENTS.override.md'), - '\nBe kind to teammates.\n\n'); + '\nAnother member\'s culture.\n\n'); - expect(await context({ hook_event_name: 'SessionStart', source: 'startup' })).not.toContain('Be kind to teammates.'); + const text = await context({ hook_event_name: 'SessionStart', source: 'startup' }); + + expect(text).not.toContain('Be kind to teammates.'); + expect(text).not.toContain('Another member'); + await fse.writeFile(path.join(tmpDir, 'project', 'AGENTS.override.md'), '# Notes\n'); + expect(await context({ hook_event_name: 'SessionStart', source: 'startup' })).toContain('Be kind to teammates.'); }); it('adds no rules from a scope that does not enable the tool', async () => { diff --git a/src/__tests__/codex-instructions.test.ts b/src/__tests__/codex-instructions.test.ts index d0b17c2b3..2e3fd8512 100644 --- a/src/__tests__/codex-instructions.test.ts +++ b/src/__tests__/codex-instructions.test.ts @@ -126,6 +126,21 @@ describe('a project-scope rules sync writes no team rules for Codex (#938)', () expect(await fse.pathExists(path.join(projectRoot, `.${tool}`, 'rules'))).toBe(false); }); + it.each(CODEX_FAMILY)('writes no team rules for %s into /AGENTS.md even when the team points its claudemd there (#945)', async (tool) => { + await fse.ensureDir(path.join(projectRoot, `.${tool}`)); + teamConfig = TeamaiConfigSchema.parse({ + team: 'test', + repo: 'https://example.invalid/x/team.git', + toolPaths: { [tool]: { skills: `.${tool}/skills`, settings: `.${tool}/hooks.json`, claudemd: 'AGENTS.md' } }, + }); + localConfig = { ...localConfig, enabledAgents: [tool] } as LocalConfig; + await fse.writeFile(agentsMd(), '# Project notes\n'); + + await handler.pullAllRules(teamConfig, localConfig); + + expect(await fse.readFile(agentsMd(), 'utf8')).toBe('# Project notes\n'); + }); + it('keeps the rule files to the member\'s projects after `remove rules`', async () => { await fse.outputFile(path.join(repoPath, 'manifest', 'projects.yaml'), ` version: 1 @@ -535,7 +550,7 @@ describe('a project-scope pull at an unchanged team revision after a CLI upgrade }); }); -describe('Codex leaves the project AGENTS.md to the tools that write it (#938, #945)', () => { +describe('Codex and the other tools leave the project AGENTS.md alone (#938, #945)', () => { const BLOCK_START: Record = { culture: TEAMAI_CULTURE_START, claudemd: TEAMAI_CLAUDEMD_START, @@ -574,13 +589,13 @@ describe('Codex leaves the project AGENTS.md to the tools that write it (#938, # }); it.each([ - ['pi', 'no agents', { skills: '.pi/skills', rules: '.pi/rules', claudemd: 'AGENTS.md' }, ['culture', 'claudemd']], + ['pi', 'no agents', { skills: '.pi/skills', rules: '.pi/rules', claudemd: 'AGENTS.md' }, []], ['workbuddy', 'agents', { skills: '.workbuddy/skills', rules: '.workbuddy/rules', settings: '.workbuddy/settings.json', claudemd: 'AGENTS.md', agents: '.workbuddy/agents', - }, ['culture', 'claudemd', 'recall']], + }, []], ['tcodex', 'the default entry', defaults.tcodex, []], - ])('Codex adds nothing to the AGENTS.md %s (%s) writes, and uninstall --agent codex leaves it', async (tool, _label, entry, written) => { + ])('neither Codex nor %s (%s) writes the project AGENTS.md, and uninstall --agent codex leaves it', async (tool, _label, entry, written: string[]) => { const teamConfig = TeamaiConfigSchema.parse({ team: 'test', repo: 'https://example.invalid/x/team.git', toolPaths: { codex: defaults.codex, [tool]: entry }, }); diff --git a/src/__tests__/doctor-rules-delivery.test.ts b/src/__tests__/doctor-rules-delivery.test.ts index 5bacade63..58ddf97a5 100644 --- a/src/__tests__/doctor-rules-delivery.test.ts +++ b/src/__tests__/doctor-rules-delivery.test.ts @@ -573,8 +573,10 @@ describe('doctor โ€” rules delivered on disk', () => { expect(forDoctor.filter((n) => !forPull.includes(n)).sort()).toEqual([ 'Agents delivered to claude', + 'No team instruction blocks are left in files no tool loads them from', 'Rules delivered to claude', 'Rules delivered to cursor', + 'Team instructions are current for cursor', ]); // Everything the pull stage keeps is also in the doctor stage. expect(forPull.filter((n) => !forDoctor.includes(n))).toEqual([]); diff --git a/src/__tests__/e2e/instruction-targets.test.ts b/src/__tests__/e2e/instruction-targets.test.ts new file mode 100644 index 000000000..ff8880ce2 --- /dev/null +++ b/src/__tests__/e2e/instruction-targets.test.ts @@ -0,0 +1,1184 @@ +import { afterEach, beforeAll, describe, expect, it } from 'vitest'; +import { execFileSync, spawn } from 'node:child_process'; +import { createServer } from 'node:http'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +// Real-CLI coverage for #945: a pull writes the instruction blocks (culture, +// claudemd, recall) only where an installed tool reads them, and strips the +// blocks an earlier pull left in a target no installed tool uses anymore. + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const ROOT = path.resolve(__dirname, '..', '..', '..'); +const CLI = path.join(ROOT, 'dist', 'index.js'); + +const GIT_ENV = { + GIT_AUTHOR_NAME: 'TeamAI CI', + GIT_AUTHOR_EMAIL: 'ci@teamai.test', + GIT_COMMITTER_NAME: 'TeamAI CI', + GIT_COMMITTER_EMAIL: 'ci@teamai.test', +}; + +const CULTURE_START = ''; +const CULTURE_END = ''; +const CLAUDEMD_START = ''; +const CLAUDEMD_END = ''; +const RECALL_START = ''; + +interface RunResult { + code: number | null; + output: string; +} + +function runCLI(args: string[], env: Record, cwd: string, stdin = ''): Promise { + return new Promise((resolve) => { + const child = spawn('node', [CLI, ...args], { + env: { ...process.env, FORCE_COLOR: '0', ...env }, + stdio: ['pipe', 'pipe', 'pipe'], + cwd, + }); + let output = ''; + let stdout = ''; + child.stdout.on('data', (data: Buffer) => { output += data.toString(); stdout += data.toString(); }); + child.stderr.on('data', (data: Buffer) => { output += data.toString(); }); + child.stdin.end(stdin); + child.on('close', (code) => resolve({ code, output, stdout })); + }); +} + +/** The context `teamai hook-dispatch instructions` hands an extension for a session in `cwd`. */ +async function sessionInstructions(tool: string, home: string, cwd: string): Promise { + const result = await runCLI(['hook-dispatch', 'instructions', '--tool', tool], { HOME: home }, cwd, JSON.stringify({ cwd })); + expect(result.code, result.output).toBe(0); + if (!result.stdout.trim()) return ''; + return JSON.parse(result.stdout).hookSpecificOutput.additionalContext as string; +} + +function git(args: string[], cwd: string): void { + execFileSync('git', args, { cwd, stdio: 'pipe', env: { ...process.env, ...GIT_ENV } }); +} + +/** A user-scope sandbox HOME with the given tool directories installed. */ +function makeUserSandbox(toolDirs: string[], options: { rule?: boolean } = {}): { sandbox: string; home: string } { + const sandbox = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'teamai-945-e2e-'))); + const home = path.join(sandbox, 'home'); + const remote = path.join(sandbox, 'team-remote'); + const localRepo = path.join(home, '.teamai', 'team-repo'); + + fs.mkdirSync(path.join(remote, 'claudemd', 'common'), { recursive: true }); + fs.writeFileSync( + path.join(remote, 'teamai.yaml'), + ['team: issue-945-e2e', `repo: ${remote}`, 'provider: git', 'sharing:', ' recall:', ' enabled: true'].join('\n'), + ); + fs.writeFileSync(path.join(remote, 'culture.md'), '---\ncompany:\n name: Acme\n---\n\nBe kind.\n'); + fs.writeFileSync(path.join(remote, 'claudemd', 'common', 'note.md'), 'Shared team instructions.\n'); + if (options.rule) { + fs.mkdirSync(path.join(remote, 'rules'), { recursive: true }); + fs.writeFileSync(path.join(remote, 'rules', 'style.md'), 'RULE-SENTINEL: keep functions small.\n'); + } + git(['init', '-q'], remote); + git(['add', '-A'], remote); + git(['commit', '-q', '-m', 'fixture'], remote); + + fs.mkdirSync(home, { recursive: true }); + git(['clone', '-q', remote, localRepo], sandbox); + for (const dir of toolDirs) fs.mkdirSync(path.join(home, dir), { recursive: true }); + fs.writeFileSync( + path.join(home, '.teamai', 'config.yaml'), + [ + 'repo:', + ` localPath: ${localRepo}`, + ` remote: ${remote}`, + 'username: ci-user', + 'updatePolicy: auto', + 'scope: user', + 'recallEnabled: true', + ].join('\n'), + ); + return { sandbox, home }; +} + +const PROJECT_AGENTS_MD = '# Project\n\nAuthored project instructions.\n'; + +/** + * A team whose roles select different `claudemd/` namespaces, and a project + * repo with an authored, committed AGENTS.md (#945). + */ +function makeTeamAndProject( + sandbox: string, + options: { teamYaml?: string[]; files?: Record } = {}, +): { remote: string; projectOrigin: string } { + const seed = path.join(sandbox, 'team-seed'); + const remote = path.join(sandbox, 'team.git'); + const write = (rel: string, text: string): void => { + fs.mkdirSync(path.dirname(path.join(seed, rel)), { recursive: true }); + fs.writeFileSync(path.join(seed, rel), text); + }; + write('teamai.yaml', [ + 'team: issue-945-project-e2e', `repo: ${remote}`, 'provider: git', 'sharing:', ' recall:', ' enabled: true', + ...options.teamYaml ?? [], '', + ].join('\n')); + for (const [rel, text] of Object.entries(options.files ?? {})) write(rel, text); + write('culture.md', '---\ncompany:\n name: Acme\n---\n\nBe kind.\n'); + write('claudemd/common.md', 'COMMON-SENTINEL shared by every role.\n'); + write('claudemd/development/dev.md', 'DEVELOPMENT-SENTINEL for developers.\n'); + write('claudemd/product/product.md', 'PRODUCT-SENTINEL for product.\n'); + write('manifest/roles.yaml', [ + 'version: 1', + 'roles:', + ' - id: developer', + ' description: Developer', + ' resources:', + ' knowledge: [development]', + ' skills: []', + ' - id: product', + ' description: Product', + ' resources:', + ' knowledge: [product]', + ' skills: []', + '', + ].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); + + const projectSeed = path.join(sandbox, 'project-seed'); + const projectOrigin = path.join(sandbox, 'project.git'); + fs.mkdirSync(projectSeed, { recursive: true }); + fs.writeFileSync(path.join(projectSeed, 'AGENTS.md'), PROJECT_AGENTS_MD); + fs.writeFileSync(path.join(projectSeed, 'app.txt'), 'v1\n'); + git(['init', '-q', '-b', 'main'], projectSeed); + git(['add', '-A'], projectSeed); + git(['commit', '-q', '-m', 'project'], projectSeed); + git(['clone', '-q', '--bare', projectSeed, projectOrigin], sandbox); + return { remote, projectOrigin }; +} + +interface ProjectMember { + home: string; + projectRoot: string; +} + +/** + * One member's checkout of the project, in project scope, with their role and + * the given tool directories installed under the project root. + */ +function makeProjectMember( + sandbox: string, + fixture: { remote: string; projectOrigin: string }, + name: string, + role: string, + toolDirs: string[], +): ProjectMember { + const home = path.join(sandbox, `${name}-home`); + const projectRoot = path.join(sandbox, `${name}-project`); + fs.mkdirSync(home, { recursive: true }); + git(['clone', '-q', fixture.projectOrigin, projectRoot], sandbox); + const teamRepo = path.join(projectRoot, '.teamai', 'team-repo'); + git(['clone', '-q', fixture.remote, teamRepo], sandbox); + for (const dir of toolDirs) fs.mkdirSync(path.join(projectRoot, dir), { recursive: true }); + fs.writeFileSync(path.join(projectRoot, '.teamai', 'config.yaml'), [ + 'repo:', + ` localPath: ${teamRepo}`, + ` remote: ${fixture.remote}`, + `username: ${name}`, + 'updatePolicy: auto', + 'scope: project', + `projectRoot: ${projectRoot}`, + `primaryRole: ${role}`, + 'additionalRoles: []', + 'recallEnabled: true', + '', + ].join('\n')); + return { home, projectRoot }; +} + +/** + * Where a member's project config and team clone live once the first pull has + * moved them out of the project's `.teamai/` into the per-project data home. + */ +function memberData(member: ProjectMember): { config: string; teamRepo: string } { + const projects = path.join(member.home, '.teamai', 'projects'); + const [dir] = fs.readdirSync(projects); + return { config: path.join(projects, dir, 'config.yaml'), teamRepo: path.join(projects, dir, 'team-repo') }; +} + +const pullAs = (member: ProjectMember, args: string[] = [], env: Record = {}): Promise => + runCLI(['pull', '--force', ...args], { HOME: member.home, ...env }, member.projectRoot); + +describe('instruction block targets on real CLI pull (#945)', () => { + const sandboxes: string[] = []; + + beforeAll(() => { + if (!fs.existsSync(CLI)) { + throw new Error(`CLI binary not found at ${CLI}. Run "npm run build" first.`); + } + }); + + afterEach(() => { + // SessionStart's detached pull/plugin workers may finish writing after the + // foreground hook exits. Retry transient ENOTEMPTY, as other hook E2Es do. + for (const dir of sandboxes.splice(0)) { + fs.rmSync(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); + } + }); + + it('writes no ~/AGENTS.md when only Claude is installed', async () => { + const { sandbox, home } = makeUserSandbox(['.claude']); + sandboxes.push(sandbox); + + const result = await runCLI(['pull'], { HOME: home }, sandbox); + expect(result.code, result.output).toBe(0); + + const claudeMd = fs.readFileSync(path.join(home, '.claude', 'CLAUDE.md'), 'utf8'); + expect(claudeMd).toContain(CULTURE_START); + expect(claudeMd).toContain(CLAUDEMD_START); + expect(claudeMd).toContain(RECALL_START); + expect(fs.existsSync(path.join(home, 'AGENTS.md'))).toBe(false); + }); + + it('gives Hermes its user blocks in $HERMES_HOME/SOUL.md beside the rules block, and moves them out of ~/AGENTS.md', async () => { + const { sandbox, home } = makeUserSandbox(['.claude'], { rule: true }); + sandboxes.push(sandbox); + const hermesHome = path.join(sandbox, 'hermes-home'); + fs.mkdirSync(hermesHome, { recursive: true }); + const soul = path.join(hermesHome, 'SOUL.md'); + fs.writeFileSync(soul, '# Persona\n'); + const agentsMd = path.join(home, 'AGENTS.md'); + fs.writeFileSync(agentsMd, `${CULTURE_START}\nold culture\n${CULTURE_END}\n`); + + const result = await runCLI(['pull'], { HOME: home, HERMES_HOME: hermesHome }, sandbox); + expect(result.code, result.output).toBe(0); + + const content = fs.readFileSync(soul, 'utf8'); + expect(content).toContain('# Persona'); + expect(content).toContain(''); + expect(content).toContain('RULE-SENTINEL'); + expect(content).toContain(CULTURE_START); + expect(content).toContain(CLAUDEMD_START); + expect(fs.existsSync(agentsMd)).toBe(false); + + // A second pull keeps both blocks, and a project pull leaves the user blocks alone. + const again = await runCLI(['pull', '--force'], { HOME: home, HERMES_HOME: hermesHome }, sandbox); + expect(again.code, again.output).toBe(0); + const member = makeProjectMember(sandbox, makeTeamAndProject(sandbox), 'dev', 'developer', ['.claude/skills']); + const projectPull = await runCLI(['pull', '--force'], { HOME: member.home, HERMES_HOME: hermesHome }, member.projectRoot); + expect(projectPull.code, projectPull.output).toBe(0); + const after = fs.readFileSync(soul, 'utf8'); + expect(after).toContain(CULTURE_START); + expect(after).toContain('Shared team instructions.'); + expect(after).not.toContain('DEVELOPMENT-SENTINEL'); + }); + + it('keeps only the hand-written text of a ~/AGENTS.md no installed tool reads', async () => { + const { sandbox, home } = makeUserSandbox(['.claude']); + sandboxes.push(sandbox); + const agentsMd = path.join(home, 'AGENTS.md'); + fs.writeFileSync(agentsMd, [ + '# My notes', + '', + CULTURE_START, + 'old culture', + CULTURE_END, + '', + CLAUDEMD_START, + 'old shared instructions', + CLAUDEMD_END, + '', + ].join('\n')); + + const result = await runCLI(['pull'], { HOME: home }, sandbox); + expect(result.code, result.output).toBe(0); + + expect(fs.readFileSync(agentsMd, 'utf8')).toBe('# My notes\n'); + }); + + it('leaves the project AGENTS.md alone when Hermes is not installed', async () => { + const sandbox = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'teamai-945-e2e-'))); + sandboxes.push(sandbox); + const member = makeProjectMember(sandbox, makeTeamAndProject(sandbox), 'dev', 'developer', ['.claude/skills']); + + const result = await pullAs(member); + expect(result.code, result.output).toBe(0); + + expect(fs.readFileSync(path.join(member.projectRoot, 'AGENTS.md'), 'utf8')).toBe(PROJECT_AGENTS_MD); + }); + + it('reports the cleanup of an old AGENTS.md block in a dry run, then removes it and keeps the authored text', async () => { + const sandbox = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'teamai-945-e2e-'))); + sandboxes.push(sandbox); + const member = makeProjectMember(sandbox, makeTeamAndProject(sandbox), 'dev', 'developer', ['.claude/skills']); + const agentsMd = path.join(member.projectRoot, 'AGENTS.md'); + const leftover = `${PROJECT_AGENTS_MD}\n${CLAUDEMD_START}\nanother member's selection\n${CLAUDEMD_END}\n`; + fs.writeFileSync(agentsMd, leftover); + + const dryRun = await pullAs(member, ['--dry-run']); + expect(dryRun.code, dryRun.output).toBe(0); + expect(dryRun.output).toContain(`Would remove teamai instruction blocks from ${agentsMd}`); + expect(fs.readFileSync(agentsMd, 'utf8')).toBe(leftover); + + const result = await pullAs(member); + expect(result.code, result.output).toBe(0); + expect(result.output).toContain(`Removed teamai instruction blocks from ${agentsMd}`); + expect(fs.readFileSync(agentsMd, 'utf8')).toBe(PROJECT_AGENTS_MD); + }); + + it('does not rewrite an instruction file whose blocks are already current', async () => { + const sandbox = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'teamai-945-e2e-'))); + sandboxes.push(sandbox); + const member = makeProjectMember(sandbox, makeTeamAndProject(sandbox), 'dev', 'developer', ['.claude/skills']); + + const first = await pullAs(member); + expect(first.code, first.output).toBe(0); + const written = fs.readdirSync(member.projectRoot, { recursive: true, withFileTypes: true }) + .filter((entry) => entry.isFile() && !entry.parentPath.includes(`${path.sep}.git`) && !entry.parentPath.includes('.teamai')) + .map((entry) => path.join(entry.parentPath, entry.name)) + .filter((file) => fs.readFileSync(file, 'utf8').includes(CLAUDEMD_START)); + expect(written.length).toBeGreaterThan(0); + const mtimes = written.map((file) => fs.statSync(file).mtimeMs); + + await new Promise((resolve) => setTimeout(resolve, 20)); + const second = await pullAs(member); + expect(second.code, second.output).toBe(0); + expect(written.map((file) => fs.statSync(file).mtimeMs)).toEqual(mtimes); + }); + + it('gives two members of one project their own role selection without touching the shared AGENTS.md (Claude)', async () => { + const sandbox = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'teamai-945-e2e-'))); + sandboxes.push(sandbox); + const fixture = makeTeamAndProject(sandbox); + const developer = makeProjectMember(sandbox, fixture, 'dev', 'developer', ['.claude/skills']); + const product = makeProjectMember(sandbox, fixture, 'pm', 'product', ['.claude/skills']); + const context = (member: ProjectMember): string => + fs.readFileSync(path.join(member.projectRoot, '.claude', 'rules', 'teamai-context.md'), 'utf8'); + + for (const member of [developer, product, developer]) { + const result = await pullAs(member); + expect(result.code, result.output).toBe(0); + } + + expect(context(developer)).toContain('COMMON-SENTINEL'); + expect(context(developer)).toContain('DEVELOPMENT-SENTINEL'); + expect(context(developer)).not.toContain('PRODUCT-SENTINEL'); + expect(context(developer)).toContain(CULTURE_START); + expect(context(developer)).toContain(RECALL_START); + expect(context(product)).toContain('COMMON-SENTINEL'); + expect(context(product)).toContain('PRODUCT-SENTINEL'); + expect(context(product)).not.toContain('DEVELOPMENT-SENTINEL'); + for (const member of [developer, product]) { + expect(fs.readFileSync(path.join(member.projectRoot, 'AGENTS.md'), 'utf8')).toBe(PROJECT_AGENTS_MD); + expect(fs.existsSync(path.join(member.projectRoot, '.claude', 'CLAUDE.md'))).toBe(false); + expect(fs.existsSync(path.join(member.projectRoot, 'CLAUDE.local.md'))).toBe(false); + } + }); + + it('moves Claude blocks an earlier release left in .claude/CLAUDE.md, keeping the authored text', async () => { + const sandbox = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'teamai-945-e2e-'))); + sandboxes.push(sandbox); + const member = makeProjectMember(sandbox, makeTeamAndProject(sandbox), 'dev', 'developer', ['.claude/skills']); + const legacy = path.join(member.projectRoot, '.claude', 'CLAUDE.md'); + fs.writeFileSync(legacy, `# Team notes\n\n${CLAUDEMD_START}\nold selection\n${CLAUDEMD_END}\n`); + + const result = await pullAs(member); + expect(result.code, result.output).toBe(0); + + expect(result.output).toContain(`Removed teamai instruction blocks from ${legacy}`); + expect(fs.readFileSync(legacy, 'utf8')).toBe('# Team notes\n'); + expect(fs.readFileSync(path.join(member.projectRoot, '.claude', 'rules', 'teamai-context.md'), 'utf8')).toContain('DEVELOPMENT-SENTINEL'); + }); + + it('gives Cursor an always-applied teamai-context.mdc in both scopes', async () => { + const sandbox = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'teamai-945-e2e-'))); + sandboxes.push(sandbox); + const member = makeProjectMember(sandbox, makeTeamAndProject(sandbox), 'dev', 'developer', ['.cursor/skills']); + const projectRule = path.join(member.projectRoot, '.cursor', 'rules', 'teamai-context.mdc'); + + for (let i = 0; i < 2; i++) { + const result = await pullAs(member); + expect(result.code, result.output).toBe(0); + } + const rule = fs.readFileSync(projectRule, 'utf8'); + expect(rule.startsWith('---\nalwaysApply: true\n---\n\n')).toBe(true); + expect(rule).toContain('DEVELOPMENT-SENTINEL'); + expect(rule).toContain(RECALL_START); + expect(fs.readFileSync(path.join(member.projectRoot, 'AGENTS.md'), 'utf8')).toBe(PROJECT_AGENTS_MD); + + const user = makeUserSandbox(['.cursor']); + sandboxes.push(user.sandbox); + const userPull = await runCLI(['pull'], { HOME: user.home }, user.sandbox); + expect(userPull.code, userPull.output).toBe(0); + const userRule = fs.readFileSync(path.join(user.home, '.cursor', 'rules', 'teamai-context.mdc'), 'utf8'); + expect(userRule.startsWith('---\nalwaysApply: true\n---\n\n')).toBe(true); + expect(userRule).toContain(CLAUDEMD_START); + expect(fs.existsSync(path.join(user.home, 'AGENTS.md'))).toBe(false); + }); + + it('gives CodeBuddy and WorkBuddy one shared rule file and keeps it while either tool remains', async () => { + const sandbox = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'teamai-945-e2e-'))); + sandboxes.push(sandbox); + const member = makeProjectMember(sandbox, makeTeamAndProject(sandbox), 'dev', 'developer', ['.codebuddy/skills', '.workbuddy/skills']); + const rule = path.join(member.projectRoot, '.codebuddy', 'rules', 'teamai-context.md'); + const legacy = path.join(member.projectRoot, '.codebuddy', 'CODEBUDDY.md'); + const agentsMd = path.join(member.projectRoot, 'AGENTS.md'); + fs.writeFileSync(legacy, `# CodeBuddy notes\n\n${CLAUDEMD_START}\nold selection\n${CLAUDEMD_END}\n`); + fs.writeFileSync(agentsMd, `${PROJECT_AGENTS_MD}\n${CULTURE_START}\nold culture\n${CULTURE_END}\n`); + + const result = await pullAs(member); + expect(result.code, result.output).toBe(0); + + const content = fs.readFileSync(rule, 'utf8'); + expect(content.startsWith('---\nalwaysApply: true\n---\n\n')).toBe(true); + expect(content).toContain('DEVELOPMENT-SENTINEL'); + expect(content.split(CLAUDEMD_START).length - 1).toBe(1); + expect(fs.existsSync(path.join(member.projectRoot, '.workbuddy', 'rules', 'teamai-context.md'))).toBe(false); + expect(fs.readFileSync(legacy, 'utf8')).toBe('# CodeBuddy notes\n'); + expect(fs.readFileSync(agentsMd, 'utf8')).toBe(PROJECT_AGENTS_MD); + + const uninstall = await runCLI(['uninstall', '--agent', 'workbuddy', '--force'], { HOME: member.home }, member.projectRoot); + expect(uninstall.code, uninstall.output).toBe(0); + expect(fs.readFileSync(rule, 'utf8')).toContain('DEVELOPMENT-SENTINEL'); + }); + + it('gives WorkBuddy its user blocks in ~/.workbuddy/rules, not ~/AGENTS.md', async () => { + const { sandbox, home } = makeUserSandbox(['.workbuddy']); + sandboxes.push(sandbox); + + const result = await runCLI(['pull'], { HOME: home }, sandbox); + expect(result.code, result.output).toBe(0); + + const rule = fs.readFileSync(path.join(home, '.workbuddy', 'rules', 'teamai-context.md'), 'utf8'); + expect(rule.startsWith('---\nalwaysApply: true\n---\n\n')).toBe(true); + expect(rule).toContain(CLAUDEMD_START); + expect(fs.existsSync(path.join(home, 'AGENTS.md'))).toBe(false); + }); + + it('gives Oh My Pi its project blocks through the extension, its user blocks in RULES.md, and frees both context files', async () => { + const sandbox = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'teamai-945-e2e-'))); + sandboxes.push(sandbox); + const fixture = makeTeamAndProject(sandbox); + const developer = makeProjectMember(sandbox, fixture, 'dev', 'developer', ['.omp/skills']); + const product = makeProjectMember(sandbox, fixture, 'pm', 'product', ['.omp/skills']); + const legacy = path.join(developer.projectRoot, '.omp', 'AGENTS.md'); + fs.writeFileSync(legacy, `${CLAUDEMD_START}\nold selection\n${CLAUDEMD_END}\n`); + for (const member of [developer, product]) { + fs.mkdirSync(path.join(member.home, '.omp'), { recursive: true }); + const result = await pullAs(member); + expect(result.code, result.output).toBe(0); + } + + expect(fs.existsSync(legacy)).toBe(false); + const sub = path.join(developer.projectRoot, 'src'); + fs.mkdirSync(sub, { recursive: true }); + const devContext = await sessionInstructions('omp', developer.home, sub); + expect(devContext).toContain('DEVELOPMENT-SENTINEL'); + expect(devContext).not.toContain('PRODUCT-SENTINEL'); + expect(devContext).toContain('Acme'); + expect(devContext).toContain('teamai-recall'); + const pmContext = await sessionInstructions('omp', product.home, product.projectRoot); + expect(pmContext).toContain('PRODUCT-SENTINEL'); + expect(pmContext).not.toContain('DEVELOPMENT-SENTINEL'); + expect(await sessionInstructions('claude', developer.home, developer.projectRoot)).toBe(''); + for (const member of [developer, product]) { + expect(fs.readFileSync(path.join(member.projectRoot, 'AGENTS.md'), 'utf8')).toBe(PROJECT_AGENTS_MD); + } + + const user = makeUserSandbox(['.omp']); + sandboxes.push(user.sandbox); + const userContextFile = path.join(user.home, '.omp', 'agent', 'AGENTS.md'); + fs.mkdirSync(path.dirname(userContextFile), { recursive: true }); + fs.writeFileSync(userContextFile, `${CULTURE_START}\nold culture\n${CULTURE_END}\n`); + const userPull = await runCLI(['pull'], { HOME: user.home }, user.sandbox); + expect(userPull.code, userPull.output).toBe(0); + expect(fs.readFileSync(path.join(user.home, '.omp', 'agent', 'RULES.md'), 'utf8')).toContain(CLAUDEMD_START); + expect(fs.existsSync(userContextFile)).toBe(false); + expect(await sessionInstructions('omp', user.home, user.sandbox)).toBe(''); + }); + + it('gives Pi its project blocks through the extension and stops writing the project AGENTS.md', async () => { + const sandbox = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'teamai-945-e2e-'))); + sandboxes.push(sandbox); + const member = makeProjectMember(sandbox, makeTeamAndProject(sandbox), 'pm', 'product', ['.pi/skills']); + const agentsMd = path.join(member.projectRoot, 'AGENTS.md'); + fs.writeFileSync(agentsMd, `${PROJECT_AGENTS_MD}\n${CLAUDEMD_START}\nanother member's selection\n${CLAUDEMD_END}\n`); + + const result = await pullAs(member); + expect(result.code, result.output).toBe(0); + + expect(fs.readFileSync(agentsMd, 'utf8')).toBe(PROJECT_AGENTS_MD); + const context = await sessionInstructions('pi', member.home, member.projectRoot); + expect(context).toContain('PRODUCT-SENTINEL'); + expect(context).not.toContain('DEVELOPMENT-SENTINEL'); + expect(context).toContain('Acme'); + // Pi has no recall subagent: it is told to run the command itself. + expect(context).toContain('teamai recall "'); + expect(context).not.toContain('teamai-recall'); + }); + + it('suppresses Pi hook blocks while a blocked WorkBuddy replacement retains shared native instructions', async () => { + const sandbox = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'teamai-945-held-native-'))); + sandboxes.push(sandbox); + const member = makeProjectMember(sandbox, makeTeamAndProject(sandbox), 'dev', 'developer', ['.workbuddy/skills']); + fs.mkdirSync(path.join(member.home, '.pi'), { recursive: true }); + const legacy = path.join(member.projectRoot, 'AGENTS.md'); + const original = `${PROJECT_AGENTS_MD}${CLAUDEMD_START}\nPRODUCT-STALE\n${CLAUDEMD_END}\n`; + fs.writeFileSync(legacy, original); + const replacement = path.join(member.projectRoot, '.codebuddy/rules/teamai-context.md'); + fs.mkdirSync(path.dirname(replacement), { recursive: true }); + fs.writeFileSync(replacement, '# Foreign file\n'); + + const blocked = await pullAs(member); + expect(blocked.code, blocked.output).toBe(0); + expect(fs.readFileSync(legacy, 'utf8')).toBe(original); + const held = await sessionInstructions('pi', member.home, member.projectRoot); + expect(held).not.toContain('DEVELOPMENT-SENTINEL'); + expect(held).not.toContain('PRODUCT-STALE'); + expect(held).toContain('Acme'); + console.log('blocked WorkBuddy replacement: native prompt retained; Pi hook omits that block and still delivers culture'); + + fs.unlinkSync(replacement); + const retry = await runCLI(['pull'], { HOME: member.home }, member.projectRoot); + expect(retry.code, retry.output).toBe(0); + expect(fs.readFileSync(replacement, 'utf8')).toContain('DEVELOPMENT-SENTINEL'); + expect(fs.readFileSync(legacy, 'utf8')).toBe(PROJECT_AGENTS_MD); + expect(await sessionInstructions('pi', member.home, member.projectRoot)).toContain('DEVELOPMENT-SENTINEL'); + console.log('WorkBuddy repair: legacy block removed; Pi hook resumes the developer prompt'); + }); + + it('retains Claude legacy instructions until a foreign replacement is repaired', async () => { + const sandbox = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'teamai-945-migration-'))); + sandboxes.push(sandbox); + const member = makeProjectMember(sandbox, makeTeamAndProject(sandbox), 'dev', 'developer', ['.claude/skills']); + const legacy = path.join(member.projectRoot, '.claude', 'CLAUDE.md'); + const original = `# Mine\n${CLAUDEMD_START}\nworking old prompt\n${CLAUDEMD_END}\n`; + fs.writeFileSync(legacy, original); + const replacement = path.join(member.projectRoot, '.claude', 'rules', 'teamai-context.md'); + fs.mkdirSync(path.dirname(replacement), { recursive: true }); + fs.writeFileSync(replacement, '# Foreign file\n'); + const blocked = await pullAs(member); + expect(blocked.code, blocked.output).toBe(0); + expect(fs.readFileSync(legacy, 'utf8')).toBe(original); + console.log('pull with foreign Claude target: old working prompt retained'); + fs.unlinkSync(replacement); + // Exercise the unchanged-revision path as well as the first full pull. + const retry = await runCLI(['pull'], { HOME: member.home }, member.projectRoot); + expect(retry.code, retry.output).toBe(0); + expect(fs.readFileSync(replacement, 'utf8')).toContain('DEVELOPMENT-SENTINEL'); + expect(fs.readFileSync(legacy, 'utf8')).toBe('# Mine\n'); + console.log('pull after repair: replacement delivered, old prompt removed'); + }); + + it('keeps unreadable culture in its retired file until that block is delivered', async () => { + const sandbox = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'teamai-945-block-migration-'))); + sandboxes.push(sandbox); + const member = makeProjectMember(sandbox, makeTeamAndProject(sandbox), 'dev', 'developer', ['.claude/skills']); + const source = path.join(member.projectRoot, '.teamai/team-repo/culture.md'); + fs.unlinkSync(source); + fs.mkdirSync(source); + const legacy = path.join(member.projectRoot, '.claude/CLAUDE.md'); + fs.writeFileSync(legacy, `# Mine\n${CULTURE_START}\nWORKING-CULTURE\n${CULTURE_END}\n${CLAUDEMD_START}\nold prompt\n${CLAUDEMD_END}\n`); + const result = await pullAs(member); + expect(result.code, result.output).toBe(0); + expect(result.output).toContain('Failed to read team culture'); + expect(fs.readFileSync(legacy, 'utf8')).toContain('WORKING-CULTURE'); + expect(fs.readFileSync(legacy, 'utf8')).not.toContain('old prompt'); + const replacement = path.join(member.projectRoot, '.claude/rules/teamai-context.md'); + expect(fs.readFileSync(replacement, 'utf8')).toContain('DEVELOPMENT-SENTINEL'); + console.log('unreadable culture: old culture retained; resolved shared instructions delivered and retired'); + + const repaired = path.join(memberData(member).teamRepo, 'culture.md'); + fs.rmdirSync(repaired); + fs.writeFileSync(repaired, '---\ncompany:\n name: Acme\n---\n\nRESTORED-CULTURE\n'); + const retry = await runCLI(['pull'], { HOME: member.home }, member.projectRoot); + expect(retry.code, retry.output).toBe(0); + expect(fs.readFileSync(legacy, 'utf8')).toBe('# Mine\n'); + expect(fs.readFileSync(replacement, 'utf8')).toContain('RESTORED-CULTURE'); + console.log('culture repair: replacement delivered; retired culture removed on the next pull'); + }); + + it('keeps globally installed Pi delivering after project WorkBuddy uninstall', async () => { + const sandbox = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'teamai-945-active-hook-'))); + sandboxes.push(sandbox); + const member = makeProjectMember(sandbox, makeTeamAndProject(sandbox), 'dev', 'developer', ['.workbuddy/skills']); + const config = path.join(member.projectRoot, '.teamai/config.yaml'); + fs.appendFileSync(config, '\nenabledAgents: [workbuddy, pi]\n'); + fs.mkdirSync(path.join(member.home, '.pi'), { recursive: true }); + const result = await pullAs(member); + expect(result.code, result.output).toBe(0); + expect(fs.existsSync(path.join(member.projectRoot, '.pi'))).toBe(false); + const data = memberData(member); + const removed = await runCLI(['uninstall', '--force', '--agent', 'workbuddy'], { HOME: member.home }, member.projectRoot); + expect(removed.code, removed.output).toBe(0); + expect(fs.existsSync(data.config)).toBe(true); + expect(fs.existsSync(path.join(member.projectRoot, '.codebuddy/rules/teamai-context.md'))).toBe(false); + expect(await sessionInstructions('pi', member.home, member.projectRoot)).toContain('DEVELOPMENT-SENTINEL'); + console.log('WorkBuddy project uninstall: project state preserved; global-only Pi still receives member instructions'); + }); + + it('previews the cleanup a real pull does once it installs the Pi extension, and none behind a foreign one', async () => { + const sandbox = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'teamai-945-dryrun-'))); + sandboxes.push(sandbox); + const member = makeProjectMember(sandbox, makeTeamAndProject(sandbox), 'pm', 'product', ['.pi/skills']); + fs.mkdirSync(path.join(member.home, '.pi'), { recursive: true }); + const legacy = path.join(member.projectRoot, 'AGENTS.md'); + fs.writeFileSync(legacy, `${PROJECT_AGENTS_MD}${CLAUDEMD_START}\nworking old prompt\n${CLAUDEMD_END}\n`); + const extension = path.join(member.home, '.pi', 'agent', 'extensions', 'teamai-hooks.ts'); + const removal = `Would remove teamai instruction blocks from ${legacy}`; + + const preview = await pullAs(member, ['--dry-run']); + expect(preview.code, preview.output).toBe(0); + expect(preview.output).toContain(removal); + expect(fs.existsSync(extension)).toBe(false); + + fs.mkdirSync(path.dirname(extension), { recursive: true }); + fs.writeFileSync(extension, '// Foreign extension\n'); + const blocked = await pullAs(member, ['--dry-run']); + expect(blocked.code, blocked.output).toBe(0); + expect(blocked.output).not.toContain(removal); + }); + + it('retains Pi legacy instructions until its extension is ready, and protects exclusions', async () => { + const sandbox = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'teamai-945-migration-'))); + sandboxes.push(sandbox); + const member = makeProjectMember(sandbox, makeTeamAndProject(sandbox), 'pm', 'product', ['.pi/skills']); + const extension = path.join(member.home, '.pi', 'agent', 'extensions', 'teamai-hooks.ts'); + fs.mkdirSync(path.dirname(extension), { recursive: true }); + fs.writeFileSync(extension, '// Foreign extension\n'); + const legacy = path.join(member.projectRoot, 'AGENTS.md'); + const original = `${PROJECT_AGENTS_MD}${CLAUDEMD_START}\nworking old prompt\n${CLAUDEMD_END}\n`; + fs.writeFileSync(legacy, original); + const blocked = await pullAs(member); + expect(blocked.code, blocked.output).toBe(0); + expect(fs.readFileSync(legacy, 'utf8')).toBe(original); + console.log('pull with foreign Pi extension: old working prompt retained'); + fs.unlinkSync(extension); + const configFile = memberData(member).config; + const config = fs.readFileSync(configFile, 'utf8'); + fs.writeFileSync(configFile, `${config}\ndisabledAgents: [pi]\n`); + const excluded = await pullAs(member); + expect(excluded.code, excluded.output).toBe(0); + expect(fs.existsSync(extension)).toBe(false); + expect(fs.readFileSync(legacy, 'utf8')).toBe(original); + console.log('pull with Pi excluded: extension and legacy instructions unchanged'); + fs.writeFileSync(configFile, config); + const repaired = await runCLI(['pull'], { HOME: member.home }, member.projectRoot); + expect(repaired.code, repaired.output).toBe(0); + expect(fs.readFileSync(extension, 'utf8')).toContain('hook-dispatch'); + expect(fs.readFileSync(legacy, 'utf8')).toBe(PROJECT_AGENTS_MD); + console.log('pull with Pi enabled: extension installed before old prompt removed'); + }); + + it('previews and cancels empty-plan Pi exclusion without changing project config on the real CLI', async () => { + const sandbox = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'teamai-945-empty-uninstall-'))); + sandboxes.push(sandbox); + const member = makeProjectMember(sandbox, makeTeamAndProject(sandbox), 'dev', 'developer', []); + fs.mkdirSync(path.join(member.home, '.pi'), { recursive: true }); + fs.appendFileSync(path.join(member.projectRoot, '.teamai/config.yaml'), '\nenabledAgents: [pi]\n'); + const prepared = await pullAs(member); + expect(prepared.code, prepared.output).toBe(0); + const config = memberData(member).config; + const before = fs.readFileSync(config, 'utf8'); + const hooks = path.join(member.home, '.pi/agent/extensions/teamai-hooks.ts'); + const adapter = fs.readFileSync(hooks, 'utf8'); + const preview = await runCLI(['uninstall', '--agent', 'pi', '--dry-run'], { HOME: member.home }, member.projectRoot); + expect(preview.code, preview.output).toBe(0); + expect(preview.output).toContain('Exclude pi from this project'); + expect(preview.output).toContain('Dry run'); + expect(fs.readFileSync(config, 'utf8')).toBe(before); + const cancelled = await runCLI(['uninstall', '--agent', 'pi'], { HOME: member.home }, member.projectRoot); + expect(cancelled.code, cancelled.output).toBe(0); + expect(cancelled.output).toContain('Cancelled'); + expect(fs.readFileSync(config, 'utf8')).toBe(before); + const confirmed = await runCLI(['uninstall', '--agent', 'pi', '--force'], { HOME: member.home }, member.projectRoot); + expect(confirmed.code, confirmed.output).toBe(0); + expect(confirmed.output).toContain('Excluded pi from this project'); + expect(fs.readFileSync(config, 'utf8')).toContain('disabledAgents:'); + expect(fs.readFileSync(hooks, 'utf8')).toBe(adapter); + console.log('real Pi uninstall: dry-run and cancellation keep config unchanged; --force records exclusion and keeps global adapter'); + }); + + it('uninstalls Pi from one project while keeping delivery to another project on the same machine', async () => { + const sandbox = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'teamai-945-uninstall-'))); + sandboxes.push(sandbox); + const fixture = makeTeamAndProject(sandbox); + const developer = makeProjectMember(sandbox, fixture, 'dev', 'developer', ['.pi/skills']); + const product = { ...makeProjectMember(sandbox, fixture, 'pm', 'product', ['.pi/skills']), home: developer.home }; + fs.mkdirSync(path.join(developer.home, '.pi'), { recursive: true }); + for (const member of [developer, product]) { + const result = await pullAs(member); + expect(result.code, result.output).toBe(0); + } + const extension = path.join(developer.home, '.pi', 'agent', 'extensions', 'teamai-hooks.ts'); + const before = fs.readFileSync(extension, 'utf8'); + const removed = await runCLI(['uninstall', '--force', '--agent', 'pi'], { HOME: developer.home }, developer.projectRoot); + expect(removed.code, removed.output).toBe(0); + expect(fs.readFileSync(extension, 'utf8')).toBe(before); + expect(await sessionInstructions('pi', product.home, product.projectRoot)).toContain('PRODUCT-SENTINEL'); + expect(await sessionInstructions('pi', developer.home, developer.projectRoot)).toBe(''); + console.log('uninstall Pi from project A: global extension unchanged; project B instructions still delivered; project A empty'); + }); + + it.each([true, false])('preserves global Codex delivery across two projects on project uninstall, targeted: %s', async (targeted) => { + const sandbox = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'teamai-945-codex-uninstall-'))); + sandboxes.push(sandbox); + const fixture = makeTeamAndProject(sandbox); + const developer = makeProjectMember(sandbox, fixture, 'dev', 'developer', []); + const product = { ...makeProjectMember(sandbox, fixture, 'pm', 'product', []), home: developer.home }; + fs.mkdirSync(path.join(developer.home, '.codex'), { recursive: true }); + for (const member of [developer, product]) { + fs.appendFileSync(path.join(member.projectRoot, '.teamai/config.yaml'), '\nenabledAgents: [codex]\n'); + const result = await pullAs(member); + expect(result.code, result.output).toBe(0); + } + const hooks = path.join(developer.home, '.codex/hooks.json'); + const before = fs.readFileSync(hooks, 'utf8'); + const removed = await runCLI(['uninstall', '--force', ...targeted ? ['--agent', 'codex'] : []], { HOME: developer.home }, developer.projectRoot); + expect(removed.code, removed.output).toBe(0); + expect(fs.readFileSync(hooks, 'utf8')).toBe(before); + expect(await sessionInstructions('codex', product.home, product.projectRoot)).toContain('PRODUCT-SENTINEL'); + expect(await sessionInstructions('codex', developer.home, developer.projectRoot)).toBe(''); + if (targeted) expect(fs.readFileSync(memberData(developer).config, 'utf8')).toContain('codex'); + console.log(`Codex project ${targeted ? 'targeted' : 'full'} uninstall: global hooks unchanged; project B delivered; project A empty`); + }); + + it('gives Hermes its project blocks through its plugin and frees the project AGENTS.md', async () => { + const sandbox = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'teamai-945-e2e-'))); + sandboxes.push(sandbox); + const member = makeProjectMember(sandbox, makeTeamAndProject(sandbox), 'dev', 'developer', []); + const hermesHome = path.join(sandbox, 'hermes-home'); + fs.mkdirSync(hermesHome, { recursive: true }); + const agentsMd = path.join(member.projectRoot, 'AGENTS.md'); + fs.writeFileSync(agentsMd, `${PROJECT_AGENTS_MD}\n${CULTURE_START}\nold culture\n${CULTURE_END}\n`); + + const result = await pullAs(member, [], { HERMES_HOME: hermesHome }); + expect(result.code, result.output).toBe(0); + + expect(fs.readFileSync(agentsMd, 'utf8')).toBe(PROJECT_AGENTS_MD); + expect(fs.readFileSync(path.join(hermesHome, 'plugins', 'teamai-instructions', '__init__.py'), 'utf8')) + .toContain('register_system_prompt_section'); + expect(fs.readFileSync(path.join(hermesHome, 'config.yaml'), 'utf8')).toMatch(/plugins:\n\s+enabled:\n\s+- teamai-instructions/); + const sub = path.join(member.projectRoot, 'docs'); + fs.mkdirSync(sub, { recursive: true }); + const run = await runCLI(['hook-dispatch', 'instructions', '--tool', 'hermes'], { HOME: member.home, HERMES_HOME: hermesHome }, sub, JSON.stringify({ cwd: sub })); + const context = JSON.parse(run.stdout).hookSpecificOutput.additionalContext as string; + expect(context).toContain('DEVELOPMENT-SENTINEL'); + expect(context).not.toContain('PRODUCT-SENTINEL'); + expect(context).not.toContain('teamai-recall'); + expect(context).toContain('teamai recall "'); + }); + + it('says Hermes cannot load project instructions over its 4,000-character section, without cutting them or using AGENTS.md', async () => { + const sandbox = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'teamai-945-e2e-'))); + sandboxes.push(sandbox); + const member = makeProjectMember(sandbox, makeTeamAndProject(sandbox), 'dev', 'developer', []); + const teamRepo = path.join(member.projectRoot, '.teamai', 'team-repo'); + fs.writeFileSync(path.join(teamRepo, 'claudemd', 'development', 'long.md'), `${'Long developer guidance. '.repeat(200)}\n`); + const hermesHome = path.join(sandbox, 'hermes-home'); + fs.mkdirSync(hermesHome, { recursive: true }); + + const result = await pullAs(member, [], { HERMES_HOME: hermesHome }); + expect(result.code, result.output).toBe(0); + + expect(result.output).toMatch(/hermes cannot load this project's team instructions: they are \d+ characters, over the 4000-character limit/); + expect(fs.readFileSync(path.join(member.projectRoot, 'AGENTS.md'), 'utf8')).toBe(PROJECT_AGENTS_MD); + }); + + it('gives OpenCode its project blocks in .opencode/teamai-context.md, registered in .opencode/opencode.json', async () => { + const sandbox = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'teamai-945-e2e-'))); + sandboxes.push(sandbox); + const member = makeProjectMember(sandbox, makeTeamAndProject(sandbox), 'dev', 'developer', ['.opencode/skills']); + const config = path.join(member.projectRoot, '.opencode', 'opencode.json'); + fs.writeFileSync(config, JSON.stringify({ instructions: ['docs/style.md'], theme: 'dark' }, null, 2)); + + for (let i = 0; i < 2; i++) { + const result = await pullAs(member); + expect(result.code, result.output).toBe(0); + } + + expect(fs.readFileSync(path.join(member.projectRoot, '.opencode', 'teamai-context.md'), 'utf8')).toContain('DEVELOPMENT-SENTINEL'); + expect(JSON.parse(fs.readFileSync(config, 'utf8'))).toEqual({ instructions: ['docs/style.md', '.opencode/teamai-context.md'], theme: 'dark' }); + expect(fs.existsSync(path.join(member.projectRoot, 'opencode.json'))).toBe(false); + expect(fs.readFileSync(path.join(member.projectRoot, 'AGENTS.md'), 'utf8')).toBe(PROJECT_AGENTS_MD); + + const uninstall = await runCLI(['uninstall', '--agent', 'opencode', '--force'], { HOME: member.home }, member.projectRoot); + expect(uninstall.code, uninstall.output).toBe(0); + expect(fs.existsSync(path.join(member.projectRoot, '.opencode', 'teamai-context.md'))).toBe(false); + expect(JSON.parse(fs.readFileSync(config, 'utf8'))).toEqual({ instructions: ['docs/style.md'], theme: 'dark' }); + }); + + it('gives OpenCode its user blocks in its config dir, registered with an absolute path, and adds no copy beside its Claude fallback', async () => { + const own = makeUserSandbox(['.config/opencode']); + sandboxes.push(own.sandbox); + const ocDir = path.join(own.home, '.config', 'opencode'); + fs.writeFileSync(path.join(ocDir, 'AGENTS.md'), '# My OpenCode notes\n'); + fs.writeFileSync(path.join(ocDir, 'opencode.json'), JSON.stringify({ instructions: ['~/notes.md'] })); + const result = await runCLI(['pull'], { HOME: own.home }, own.sandbox); + expect(result.code, result.output).toBe(0); + const contextFile = path.join(ocDir, 'teamai-context.md'); + expect(fs.readFileSync(contextFile, 'utf8')).toContain(CLAUDEMD_START); + expect(JSON.parse(fs.readFileSync(path.join(ocDir, 'opencode.json'), 'utf8')).instructions).toEqual(['~/notes.md', contextFile]); + expect(fs.readFileSync(path.join(ocDir, 'AGENTS.md'), 'utf8')).toBe('# My OpenCode notes\n'); + + // No native user AGENTS.md: OpenCode falls back to ~/.claude/CLAUDE.md, which already holds the blocks. + const fallback = makeUserSandbox(['.config/opencode', '.claude']); + sandboxes.push(fallback.sandbox); + const viaClaude = await runCLI(['pull'], { HOME: fallback.home }, fallback.sandbox); + expect(viaClaude.code, viaClaude.output).toBe(0); + expect(fs.readFileSync(path.join(fallback.home, '.claude', 'CLAUDE.md'), 'utf8')).toContain(CLAUDEMD_START); + expect(fs.existsSync(path.join(fallback.home, '.config', 'opencode', 'teamai-context.md'))).toBe(false); + expect(viaClaude.output).toContain('OpenCode reads the team instructions from'); + const recall = await runCLI(['recall', 'enable'], { HOME: fallback.home }, fallback.sandbox); + expect(recall.code, recall.output).toBe(0); + expect(fs.existsSync(path.join(fallback.home, '.config', 'opencode', 'teamai-context.md'))).toBe(false); + }); + + it('has doctor report what keeps a tool from loading its instructions, not just whether a file was written', async () => { + const sandbox = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'teamai-945-e2e-'))); + sandboxes.push(sandbox); + const member = makeProjectMember(sandbox, makeTeamAndProject(sandbox), 'dev', 'developer', ['.opencode/skills']); + const pulled = await pullAs(member); + expect(pulled.code, pulled.output).toBe(0); + const doctor = async (): Promise> => { + const run = await runCLI(['doctor', '--json'], { HOME: member.home }, member.projectRoot); + const report = JSON.parse(run.stdout) as { checks: Array<{ name: string; ok: boolean; fix?: string }> }; + return new Map(report.checks.map((c) => [c.name, c])); + }; + + const healthy = await doctor(); + expect(healthy.get('Team instructions are current for opencode')?.ok).toBe(true); + expect(healthy.get('Team instructions are listed in opencode instructions')?.ok).toBe(true); + expect(healthy.get('No team instruction blocks are left in files no tool loads them from')?.ok).toBe(true); + + fs.writeFileSync(path.join(member.projectRoot, '.opencode', 'opencode.json'), '{}\n'); + fs.appendFileSync(path.join(member.projectRoot, 'AGENTS.md'), `\n${CLAUDEMD_START}\nold\n${CLAUDEMD_END}\n`); + const broken = await doctor(); + expect(broken.get('Team instructions are listed in opencode instructions')).toMatchObject({ ok: false }); + expect(broken.get('Team instructions are listed in opencode instructions')?.fix).toContain('.opencode/teamai-context.md'); + expect(broken.get('No team instruction blocks are left in files no tool loads them from')).toMatchObject({ ok: false }); + expect(broken.get('No team instruction blocks are left in files no tool loads them from')?.fix).toContain('AGENTS.md'); + }); + + const ALL_FILE_TOOLS = ['.claude/skills', '.cursor/skills', '.codebuddy/skills', '.workbuddy/skills', '.opencode/skills', '.omp/skills', '.pi/skills']; + + it('keeps the shared AGENTS.md byte-identical for two roles with every tool installed, across role and content changes', async () => { + const sandbox = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'teamai-945-e2e-'))); + sandboxes.push(sandbox); + const fixture = makeTeamAndProject(sandbox); + const developer = makeProjectMember(sandbox, fixture, 'dev', 'developer', ALL_FILE_TOOLS); + const product = makeProjectMember(sandbox, fixture, 'pm', 'product', ALL_FILE_TOOLS); + const agentsMd = (m: ProjectMember) => fs.readFileSync(path.join(m.projectRoot, 'AGENTS.md'), 'utf8'); + const generated = (m: ProjectMember) => fs.readFileSync(path.join(m.projectRoot, '.claude', 'rules', 'teamai-context.md'), 'utf8'); + + for (const member of [developer, product]) { + const result = await pullAs(member); + expect(result.code, result.output).toBe(0); + expect(agentsMd(member)).toBe(PROJECT_AGENTS_MD); + } + expect(generated(developer)).not.toBe(generated(product)); + + // The developer changes role, and the team edits a claudemd file. + const { config, teamRepo } = memberData(developer); + fs.writeFileSync(config, fs.readFileSync(config, 'utf8').replace('primaryRole: developer', 'primaryRole: product')); + fs.writeFileSync(path.join(teamRepo, 'claudemd', 'common.md'), 'COMMON-SENTINEL, revised.\n'); + const again = await pullAs(developer); + expect(again.code, again.output).toBe(0); + expect(agentsMd(developer)).toBe(PROJECT_AGENTS_MD); + expect(generated(developer)).toContain('PRODUCT-SENTINEL'); + expect(generated(developer)).not.toContain('DEVELOPMENT-SENTINEL'); + expect(generated(developer)).toContain('revised'); + }); + + it('leaves an existing task diff and the staged diff unchanged when generated targets are excluded', async () => { + const sandbox = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'teamai-945-e2e-'))); + sandboxes.push(sandbox); + const member = makeProjectMember(sandbox, makeTeamAndProject(sandbox), 'dev', 'developer', ALL_FILE_TOOLS); + const root = member.projectRoot; + // The team's own choice, made by the test, never by teamai. + const exclude = path.join(root, '.git', 'info', 'exclude'); + fs.appendFileSync(exclude, ['.teamai/', '.teamai.bak/', '.claude/', '.cursor/', '.codebuddy/', '.workbuddy/', '.opencode/', '.omp/', '.pi/', ''].join('\n')); + const first = await pullAs(member); + expect(first.code, first.output).toBe(0); + + fs.writeFileSync(path.join(root, 'app.txt'), 'v2 staged\n'); + git(['add', 'app.txt'], root); + fs.writeFileSync(path.join(root, 'app.txt'), 'v3 unstaged\n'); + const snapshot = () => ({ + diff: execFileSync('git', ['diff'], { cwd: root, encoding: 'utf8' }), + staged: execFileSync('git', ['diff', '--cached'], { cwd: root, encoding: 'utf8' }), + status: execFileSync('git', ['status', '--porcelain'], { cwd: root, encoding: 'utf8' }), + index: fs.readFileSync(path.join(root, '.git', 'index')).toString('base64'), + exclude: fs.readFileSync(exclude, 'utf8'), + }); + const before = snapshot(); + + fs.writeFileSync(path.join(memberData(member).teamRepo, 'claudemd', 'common.md'), 'COMMON-SENTINEL, updated instructions.\n'); + const update = await pullAs(member); + expect(update.code, update.output).toBe(0); + + expect(fs.readFileSync(path.join(root, '.claude', 'rules', 'teamai-context.md'), 'utf8')).toContain('updated instructions'); + expect(snapshot()).toEqual(before); + }); + + it('removes the generated content when recall is disabled, a source is deleted, or a namespace is left', async () => { + const sandbox = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'teamai-945-e2e-'))); + sandboxes.push(sandbox); + const member = makeProjectMember(sandbox, makeTeamAndProject(sandbox), 'dev', 'developer', ['.claude/skills', '.omp/skills', '.pi/skills']); + fs.mkdirSync(path.join(member.home, '.omp'), { recursive: true }); + const context = () => fs.readFileSync(path.join(member.projectRoot, '.claude', 'rules', 'teamai-context.md'), 'utf8'); + expect((await pullAs(member)).code).toBe(0); + expect(context()).toContain(RECALL_START); + expect(await sessionInstructions('omp', member.home, member.projectRoot)).toContain('teamai-recall'); + expect(await sessionInstructions('pi', member.home, member.projectRoot)).toContain('teamai recall "'); + + const disable = await runCLI(['recall', 'disable'], { HOME: member.home }, member.projectRoot); + expect(disable.code, disable.output).toBe(0); + expect(context()).not.toContain(RECALL_START); + expect(await sessionInstructions('omp', member.home, member.projectRoot)).not.toContain('teamai-recall'); + expect(await sessionInstructions('pi', member.home, member.projectRoot)).not.toContain('teamai recall "'); + + const { config, teamRepo } = memberData(member); + fs.rmSync(path.join(teamRepo, 'claudemd', 'common.md')); + fs.writeFileSync(config, fs.readFileSync(config, 'utf8').replace('primaryRole: developer', 'primaryRole: product')); + expect((await pullAs(member)).code).toBe(0); + expect(context()).not.toContain('COMMON-SENTINEL'); + expect(context()).not.toContain('DEVELOPMENT-SENTINEL'); + const omp = await sessionInstructions('omp', member.home, member.projectRoot); + expect(omp).not.toContain('COMMON-SENTINEL'); + expect(omp).not.toContain('DEVELOPMENT-SENTINEL'); + expect(omp).toContain('PRODUCT-SENTINEL'); + }); + + it('reports no OMP channel problem for a member without OMP in a project that has .omp/', async () => { + const sandbox = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'teamai-945-e2e-'))); + sandboxes.push(sandbox); + const member = makeProjectMember(sandbox, makeTeamAndProject(sandbox), 'dev', 'developer', ['.claude/skills', '.omp/skills']); + + const result = await pullAs(member); + expect(result.code, result.output).toBe(0); + + expect(result.output).not.toMatch(/omp sessions in this project get no team instructions/); + }); + + it('leaves a tracked Copilot file alone for a member without Copilot', async () => { + const sandbox = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'teamai-945-e2e-'))); + sandboxes.push(sandbox); + const member = makeProjectMember(sandbox, makeTeamAndProject(sandbox), 'dev', 'developer', ['.claude/skills']); + const copilot = path.join(member.projectRoot, '.github', 'copilot-instructions.md'); + fs.mkdirSync(path.dirname(copilot), { recursive: true }); + // A teammate with Copilot committed their selection; this member has no Copilot. + fs.writeFileSync(copilot, `# Copilot notes\n\n${CLAUDEMD_START}\nthe teammate's selection\n${CLAUDEMD_END}\n`); + git(['add', '.github/copilot-instructions.md'], member.projectRoot); + git(['commit', '-q', '-m', 'copilot instructions'], member.projectRoot); + + const result = await pullAs(member); + expect(result.code, result.output).toBe(0); + + expect(execFileSync('git', ['status', '--porcelain', '--', '.github', 'AGENTS.md'], { cwd: member.projectRoot, encoding: 'utf8' })).toBe(''); + }); + + it('strips the blocks an earlier release left in a claudemd path the team configured', async () => { + const sandbox = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'teamai-945-e2e-'))); + sandboxes.push(sandbox); + const fixture = makeTeamAndProject(sandbox, { + teamYaml: ['toolPaths:', ' claude:', ' skills: .claude/skills', ' rules: .claude/rules', ' claudemd: CLAUDE.md'], + }); + const member = makeProjectMember(sandbox, fixture, 'dev', 'developer', ['.claude/skills']); + // An earlier release wrote another member's selection to the configured path. + const configured = path.join(member.projectRoot, 'CLAUDE.md'); + fs.writeFileSync(configured, `# Project notes\n\n${CLAUDEMD_START}\nPRODUCT-SENTINEL from the last pull\n${CLAUDEMD_END}\n`); + + const result = await pullAs(member); + expect(result.code, result.output).toBe(0); + + expect(result.output).toContain(`Removed teamai instruction blocks from ${configured}`); + expect(fs.readFileSync(configured, 'utf8')).toBe('# Project notes\n'); + expect(fs.readFileSync(path.join(member.projectRoot, '.claude', 'rules', 'teamai-context.md'), 'utf8')).toContain('DEVELOPMENT-SENTINEL'); + }); + + it('delivers the team instructions, not a team rule named teamai-context, to the context rule file', async () => { + const sandbox = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'teamai-945-e2e-'))); + sandboxes.push(sandbox); + const fixture = makeTeamAndProject(sandbox, { files: { 'rules/teamai-context.md': 'RESERVED-RULE-SENTINEL\n' } }); + const member = makeProjectMember(sandbox, fixture, 'dev', 'developer', ['.claude/skills', '.cursor/skills']); + + const result = await pullAs(member); + expect(result.code, result.output).toBe(0); + + expect(result.output).toMatch(/rules\/teamai-context\.md is not delivered: teamai-context is the name of teamai's own instruction file/); + for (const file of [path.join('.claude', 'rules', 'teamai-context.md'), path.join('.cursor', 'rules', 'teamai-context.mdc')]) { + const content = fs.readFileSync(path.join(member.projectRoot, file), 'utf8'); + expect(content).toContain('DEVELOPMENT-SENTINEL'); + expect(content).not.toContain('RESERVED-RULE-SENTINEL'); + } + }); + + it('does not list a teamai-context.md teamai did not write in OpenCode\'s instructions', async () => { + const sandbox = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'teamai-945-e2e-'))); + sandboxes.push(sandbox); + const member = makeProjectMember(sandbox, makeTeamAndProject(sandbox), 'dev', 'developer', ['.opencode/skills']); + const contextFile = path.join(member.projectRoot, '.opencode', 'teamai-context.md'); + fs.writeFileSync(contextFile, '# My own notes\n'); + const config = path.join(member.projectRoot, '.opencode', 'opencode.json'); + fs.writeFileSync(config, JSON.stringify({ instructions: ['docs/style.md'] }, null, 2)); + + for (const args of [['--dry-run'], []]) { + const result = await pullAs(member, args); + expect(result.code, result.output).toBe(0); + expect(result.output).toContain(`${contextFile} was not written by teamai`); + expect(result.output).not.toContain('.opencode/teamai-context.md" to the instructions'); + } + + expect(fs.readFileSync(contextFile, 'utf8')).toBe('# My own notes\n'); + expect(JSON.parse(fs.readFileSync(config, 'utf8'))).toEqual({ instructions: ['docs/style.md'] }); + }); + + it('activates OpenCode instructions only after repairing a malformed target', async () => { + const sandbox = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'teamai-945-e2e-'))); + sandboxes.push(sandbox); + const member = makeProjectMember(sandbox, makeTeamAndProject(sandbox), 'dev', 'developer', ['.opencode/skills']); + const contextFile = path.join(member.projectRoot, '.opencode', 'teamai-context.md'); + const config = path.join(member.projectRoot, '.opencode', 'opencode.json'); + const malformed = `${CULTURE_START}\nOLD-CULTURE\n${CULTURE_END}\n\n${CLAUDEMD_START}\nPRODUCT-STALE\n`; + fs.writeFileSync(contextFile, malformed); + fs.writeFileSync(config, JSON.stringify({ instructions: ['docs/style.md'] })); + for (const args of [['--dry-run'], []]) { + const pull = await pullAs(member, args); + expect(pull.code, pull.output).toBe(0); + expect(pull.output).toContain('incomplete teamai claudemd block'); + expect(fs.readFileSync(contextFile, 'utf8')).toBe(malformed); + expect(JSON.parse(fs.readFileSync(config, 'utf8')).instructions).toEqual(['docs/style.md']); + console.log(`pull ${args.join(' ')}: malformed target warned; OpenCode instructions=${JSON.stringify(JSON.parse(fs.readFileSync(config, 'utf8')).instructions)}`); + } + fs.writeFileSync(contextFile, `${CLAUDEMD_START}\nPRODUCT-STALE\n${CLAUDEMD_END}\n`); + const retry = await pullAs(member); + expect(retry.code, retry.output).toBe(0); + const content = fs.readFileSync(contextFile, 'utf8'); + expect(content).toContain('DEVELOPMENT-SENTINEL'); + expect(content).not.toContain('PRODUCT-STALE'); + expect(JSON.parse(fs.readFileSync(config, 'utf8')).instructions).toEqual(['docs/style.md', '.opencode/teamai-context.md']); + console.log('pull after repair: DEVELOPMENT=1 PRODUCT-STALE=0; OpenCode instructions=["docs/style.md",".opencode/teamai-context.md"]'); + }); + + it('reports OpenCode delivery only after its config lists the replacement', async () => { + const sandbox = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'teamai-945-registration-'))); + sandboxes.push(sandbox); + const member = makeProjectMember(sandbox, makeTeamAndProject(sandbox), 'dev', 'developer', ['.opencode/skills']); + const config = path.join(member.projectRoot, '.opencode', 'opencode.json'); + fs.writeFileSync(config, '{ invalid JSON'); + const blocked = await pullAs(member); + expect(blocked.code, blocked.output).toBe(0); + expect(blocked.output).not.toContain('Synced team culture'); + expect(blocked.output).not.toContain('Synced shared instructions'); + expect(fs.readFileSync(config, 'utf8')).toBe('{ invalid JSON'); + expect(fs.readFileSync(path.join(member.projectRoot, '.opencode', 'teamai-context.md'), 'utf8')).toContain('DEVELOPMENT-SENTINEL'); + console.log('pull with malformed OpenCode config: replacement file written; delivery not reported as successful'); + fs.writeFileSync(config, '{}'); + const repaired = await runCLI(['pull'], { HOME: member.home }, member.projectRoot); + expect(repaired.code, repaired.output).toBe(0); + expect(repaired.output).toContain('Synced shared instructions'); + expect(JSON.parse(fs.readFileSync(config, 'utf8')).instructions).toEqual(['.opencode/teamai-context.md']); + console.log('pull after OpenCode config repair: entry listed; delivery reported as successful'); + }); + + it('includes a freshly downloaded HTTP prompt in the first real Codex SessionStart', async () => { + const sandbox = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'teamai-945-first-http-'))); + sandboxes.push(sandbox); + const member = makeProjectMember(sandbox, makeTeamAndProject(sandbox), 'dev', 'developer', []); + fs.mkdirSync(path.join(member.home, '.codex', 'skills'), { recursive: true }); + const prepared = await pullAs(member); + expect(prepared.code, prepared.output).toBe(0); + const workers = path.join(sandbox, 'workers'); + fs.mkdirSync(workers); + const preload = path.join(sandbox, 'capture-workers.cjs'); + fs.writeFileSync(preload, [ + "const fs = require('node:fs');", + "const cp = require('node:child_process');", + `const workers = ${JSON.stringify(workers)};`, + 'const spawn = cp.spawn;', + 'cp.spawn = (command, args, options) => {', + // Keep the fixture's output pipes open until detached workers exit, so + // runCLI's close event joins them before server/filesystem teardown. + " if (options?.detached) options = { ...options, stdio: [Array.isArray(options.stdio) ? options.stdio[0] : 'ignore', 'inherit', 'inherit'] };", + ' const child = spawn(command, args, options);', + " if (options?.detached && child.pid) fs.writeFileSync(workers + '/' + child.pid + '.started', '');", + ' return child;', + '};', + "require('node:module').syncBuiltinESMExports();", + "process.on('exit', () => fs.writeFileSync(workers + '/' + process.pid + '.done', ''));", + ].join('\n')); + let endpoint = ''; + let syncRequests = 0; + const acks: Array<{ status: string }> = []; + const server = createServer((request, response) => { + let body = ''; + request.on('data', (chunk: Buffer) => { body += chunk.toString(); }); + request.on('end', () => { + if (request.url?.endsWith('/local-agent/sync')) syncRequests++; + if (request.url?.endsWith('/first.md')) { + setTimeout(() => response.end('FIRST-HTTP-PROMPT-SENTINEL'), 100); + return; + } + response.setHeader('Content-Type', 'application/json'); + if (request.url?.endsWith('/commands/ack')) acks.push(JSON.parse(body)); + response.end(JSON.stringify(request.url?.endsWith('/local-agent/sync') ? { + ok: true, cmds: [{ id: 1, type: 'install_prompt_rule', handle_type: 'prompt', slug: 'first', version: '1', + scope: 'workspace', workspace_path: member.projectRoot, download_url: `${endpoint}/first.md`, + }], + } : { ok: true })); + }); + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + endpoint = `http://127.0.0.1:${(server.address() as { port: number }).port}`; + try { + const agentDir = path.join(member.home, '.teamai', 'local-agent'); + fs.mkdirSync(agentDir, { recursive: true }); + fs.writeFileSync(path.join(agentDir, 'config.json'), JSON.stringify({ endpoint, token: 'fixture-token', + localAgentId: 'fixture', createdAt: '2026-01-01T00:00:00.000Z', workspaceBindings: {}, + })); + const first = await runCLI(['hook-dispatch', 'session-start', '--tool', 'codex'], { HOME: member.home, NODE_OPTIONS: `--require ${JSON.stringify(preload)}` }, member.projectRoot, + JSON.stringify({ cwd: member.projectRoot, session_id: 'first-http', hook_event_name: 'SessionStart', source: 'startup' })); + expect(first.code, first.output).toBe(0); + expect(first.stdout).toContain('FIRST-HTTP-PROMPT-SENTINEL'); + expect(acks).toEqual([expect.objectContaining({ status: 'success' })]); + const started = fs.readdirSync(workers).filter((file) => file.endsWith('.started')); + expect(started.length).toBeGreaterThan(0); + for (const file of started) { + expect(fs.existsSync(path.join(workers, file.replace('.started', '.done'))), `Worker ${file} must exit before fixture cleanup`).toBe(true); + } + console.log('real Codex SessionStart: empty HTTP cache โ†’ download ACK success โ†’ FIRST-HTTP-PROMPT-SENTINEL in first output'); + const removed = await runCLI(['uninstall', '--agent', 'codex', '--force'], { HOME: member.home }, member.projectRoot); + expect(removed.code, removed.output).toBe(0); + const before = syncRequests; + for (const [event, hook_event_name] of [['session-start', 'SessionStart'], ['subagent-start', 'SubagentStart']] as const) { + const excluded = await runCLI(['hook-dispatch', event, '--tool', 'codex'], { HOME: member.home, NODE_OPTIONS: `--require ${JSON.stringify(preload)}` }, member.projectRoot, + JSON.stringify({ cwd: member.projectRoot, session_id: 'excluded-http', hook_event_name, source: 'startup' })); + expect(excluded.code, excluded.output).toBe(0); + expect(excluded.stdout).not.toContain('FIRST-HTTP-PROMPT-SENTINEL'); + } + expect(syncRequests).toBe(before); + console.log('after project Codex uninstall: SessionStart/SubagentStart inject no cached HTTP prompt and send no HTTP sync'); + } finally { + await new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve())); + } + }); + + it('drops OpenCode\'s instructions entry on uninstall even when the member\'s text keeps the file', async () => { + const sandbox = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'teamai-945-e2e-'))); + sandboxes.push(sandbox); + const member = makeProjectMember(sandbox, makeTeamAndProject(sandbox), 'dev', 'developer', ['.opencode/skills']); + const contextFile = path.join(member.projectRoot, '.opencode', 'teamai-context.md'); + const config = path.join(member.projectRoot, '.opencode', 'opencode.json'); + + const pull = await pullAs(member); + expect(pull.code, pull.output).toBe(0); + expect(JSON.parse(fs.readFileSync(config, 'utf8')).instructions).toEqual(['.opencode/teamai-context.md']); + fs.appendFileSync(contextFile, '\n# My own notes\n'); + + const uninstall = await runCLI(['uninstall', '--agent', 'opencode', '--force'], { HOME: member.home }, member.projectRoot); + expect(uninstall.code, uninstall.output).toBe(0); + expect(fs.readFileSync(contextFile, 'utf8')).toBe('# My own notes\n'); + expect(JSON.parse(fs.readFileSync(config, 'utf8')).instructions ?? []).toEqual([]); + }); +}); diff --git a/src/__tests__/helpers/pi-extensions.ts b/src/__tests__/helpers/pi-extensions.ts index 2089e0c38..a8a04d35b 100644 --- a/src/__tests__/helpers/pi-extensions.ts +++ b/src/__tests__/helpers/pi-extensions.ts @@ -18,7 +18,7 @@ export interface ExtensionContext { agent?: { kind: 'main' | 'sub'; id: string; name: string; depth?: number; parentId?: string }; } -export type ExtensionHandler = (event: Record, ctx: ExtensionContext) => Promise; +export type ExtensionHandler = (event: Record, ctx: ExtensionContext) => Promise; export interface LoadedExtension { /** The handler the extension registered for each host event. */ @@ -37,14 +37,19 @@ function register(source: string, context: Record): Record string = () => ''): LoadedExtension { const dispatches: ExtensionDispatch[] = []; const spawn = (_command: string, args: string[]) => { - const child = new EventEmitter() as EventEmitter & { stdin: EventEmitter & { end: (s: string) => void }; kill: () => void }; + const child = new EventEmitter() as EventEmitter & { stdin: EventEmitter & { end: (s: string) => void }; stdout: EventEmitter; kill: () => void }; + child.stdout = new EventEmitter(); child.stdin = Object.assign(new EventEmitter(), { end: (stdin: string) => { dispatches.push({ args, payload: JSON.parse(stdin) as Record }); - queueMicrotask(() => child.emit('close', 0)); + queueMicrotask(() => { + const stdout = stdoutFor(args); + if (stdout) child.stdout.emit('data', stdout); + child.emit('close', 0); + }); }, }); child.kill = () => undefined; @@ -59,13 +64,15 @@ export function loadPiExtension(): LoadedExtension { * instead of running `teamai`: the argv is the template's first value, the * STDIN the Response redirected into it. */ -export function loadOmpExtension(): LoadedExtension { +export function loadOmpExtension(stdoutFor: (args: string[]) => string = () => ''): LoadedExtension { const dispatches: ExtensionDispatch[] = []; const $ = (_strings: TemplateStringsArray, args: string[], stdin: Response) => { const run = (async () => { dispatches.push({ args, payload: JSON.parse(await stdin.text()) as Record }); + return stdoutFor(args); })(); - return { quiet: () => ({ nothrow: () => run }) }; + const output = Object.assign(run, { text: async () => run }); + return { quiet: () => ({ nothrow: () => output }) }; }; const on = register(buildOmpExtensionSource(), { $, Response, fs, path, Buffer }); return { on, dispatches }; diff --git a/src/__tests__/hermes-hooks.test.ts b/src/__tests__/hermes-hooks.test.ts index d0ae75802..6aaef1d46 100644 --- a/src/__tests__/hermes-hooks.test.ts +++ b/src/__tests__/hermes-hooks.test.ts @@ -2,8 +2,9 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import fs from 'node:fs'; import path from 'node:path'; import os from 'node:os'; -import { injectHermesHooks, getReportScriptPath } from '../hermes-hooks.js'; +import { injectHermesHooks, removeHermesHooks, getReportScriptPath, getInstructionsPluginDir } from '../hermes-hooks.js'; import { log } from '../utils/logger.js'; +import { instructionHookChannel } from '../instruction-targets.js'; let tmpDir: string; let savedHermesHome: string | undefined; @@ -36,3 +37,53 @@ describe('injectHermesHooks', () => { } }); }); + +describe('the teamai-instructions plugin (#945)', () => { + const config = () => fs.readFileSync(path.join(tmpDir, 'config.yaml'), 'utf8'); + + it('installs the plugin and enables it beside the member\'s own plugins, then removes both', async () => { + fs.writeFileSync(path.join(tmpDir, 'config.yaml'), '# mine\nplugins:\n enabled:\n - disk-cleanup\n'); + vi.spyOn(log, 'success').mockImplementation(() => {}); + + await injectHermesHooks(); + expect(fs.readFileSync(path.join(getInstructionsPluginDir(), '__init__.py'), 'utf8')).toContain('register_system_prompt_section'); + expect(fs.readFileSync(path.join(getInstructionsPluginDir(), 'plugin.yaml'), 'utf8')).toContain('name: teamai-instructions'); + expect(config()).toContain('# mine'); + expect(config()).toMatch(/- disk-cleanup\n\s+- teamai-instructions/); + + await removeHermesHooks(); + expect(fs.existsSync(getInstructionsPluginDir())).toBe(false); + expect(config()).toContain('- disk-cleanup'); + expect(config()).not.toContain('teamai-instructions'); + }); + + it('leaves the plugin off when the member disabled it', async () => { + fs.writeFileSync(path.join(tmpDir, 'config.yaml'), 'plugins:\n disabled:\n - teamai-instructions\n'); + vi.spyOn(log, 'success').mockImplementation(() => {}); + + await injectHermesHooks(); + + expect(config()).not.toMatch(/enabled:/); + }); + + it('leaves a same-named plugin teamai did not write alone on inject and remove', async () => { + fs.writeFileSync(path.join(tmpDir, 'config.yaml'), 'plugins:\n enabled:\n - teamai-instructions\n'); + const dir = getInstructionsPluginDir(); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, 'plugin.yaml'), 'name: teamai-instructions\n'); + fs.writeFileSync(path.join(dir, '__init__.py'), 'def register(ctx): pass\n'); + fs.writeFileSync(path.join(dir, 'notes.txt'), 'mine\n'); + vi.spyOn(log, 'success').mockImplementation(() => {}); + const warn = vi.spyOn(log, 'warn').mockImplementation(() => {}); + + await injectHermesHooks(); + expect(warn).toHaveBeenCalledWith(expect.stringContaining(`${dir} exists without the TeamAI marker`)); + expect(fs.readFileSync(path.join(dir, '__init__.py'), 'utf8')).toBe('def register(ctx): pass\n'); + expect(fs.existsSync(getReportScriptPath())).toBe(true); + expect(await instructionHookChannel('hermes')).toEqual({ ready: false, fix: expect.stringContaining(`${dir} holds a plugin teamai did not write`) }); + + await removeHermesHooks(); + expect(fs.readdirSync(dir).sort()).toEqual(['__init__.py', 'notes.txt', 'plugin.yaml']); + expect(config()).toContain('- teamai-instructions'); + }); +}); diff --git a/src/__tests__/hook-handlers.test.ts b/src/__tests__/hook-handlers.test.ts index aa6fca6f6..7ed2fd0a5 100644 --- a/src/__tests__/hook-handlers.test.ts +++ b/src/__tests__/hook-handlers.test.ts @@ -24,6 +24,7 @@ const mockIncrementUpvoted = vi.fn().mockImplementation(async (_p: string, docId const mockSyncVotesToTeam = vi.fn().mockResolvedValue(false); const mockDoUpdate = vi.fn().mockResolvedValue(undefined); const mockReportAndSyncFromHook = vi.fn().mockResolvedValue(null); +const mockLocalAgentInstructionText = vi.fn().mockResolvedValue(''); const mockPackageManifestHash = vi.fn().mockResolvedValue('before-hash'); const mockStashPackageHint = vi.fn().mockResolvedValue(undefined); const mockClaimPackageHint = vi.fn().mockResolvedValue(null); @@ -114,6 +115,7 @@ vi.mock('../utils/logger.js', () => ({ vi.mock('../local-agent.js', () => ({ reportAndSyncFromHook: mockReportAndSyncFromHook, + localAgentInstructionText: mockLocalAgentInstructionText, })); vi.mock('../pkg/pkg-hint.js', () => ({ @@ -177,6 +179,7 @@ const scope: LocalConfig = { repo: { localPath: '/tmp', remote: '' }, username: describe('hook-handlers registry', () => { beforeEach(() => { vi.clearAllMocks(); + mockLocalAgentInstructionText.mockResolvedValue(''); mockParseTranscriptForVotes.mockResolvedValue({ recalledDocIds: [], finalAssistantText: '', recalledDocPaths: {}, recalledDocScopes: {} }); mockIncrementUpvoted.mockImplementation(async (_p: string, docIds: string[]) => docIds); mockCreditAdoptedDocs.mockResolvedValue({ credited: [], recalled: 0 }); @@ -231,6 +234,28 @@ describe('hook-handlers registry', () => { expect(sessionStartHandlers).toContain('dashboard-report'); }); + it.each(['pi', 'omp', 'hermes', 'codex', 'codex-internal', 'tcodex'])('does not read or sync HTTP prompts for excluded %s', async (tool) => { + const registry = buildHandlerRegistry(); + const cached = registry.find((r) => r.handler.name === 'http-prompt-instructions')!.handler; + const sync = registry.find((r) => r.handler.name === 'local-agent-sync')!.handler; + const config = { scope: 'project', disabledAgents: [tool] } as never; + const input = { cwd: '/project', hook_event_name: tool.includes('codex') ? 'SubagentStart' : 'instructions' }; + expect(await cached.execute(input, tool, config)).toBeNull(); + expect(await sync.execute({ cwd: '/project', hook_event_name: 'SessionStart' }, tool, config)).toBeNull(); + expect(mockLocalAgentInstructionText).not.toHaveBeenCalled(); + expect(mockReportAndSyncFromHook).not.toHaveBeenCalled(); + }); + + it('still delivers cached HTTP prompts without a git team configuration', async () => { + mockLocalAgentInstructionText.mockResolvedValue('HTTP-PROMPT'); + const registry = buildHandlerRegistry(); + const cached = registry.find((r) => r.handler.name === 'http-prompt-instructions')!.handler; + const sync = registry.find((r) => r.handler.name === 'local-agent-sync')!.handler; + expect(await cached.execute({ cwd: '/project' }, 'pi', null)).toContain('HTTP-PROMPT'); + expect(await sync.execute({ cwd: '/project', hook_event_name: 'SessionStart' }, 'codex', null)).toContain('HTTP-PROMPT'); + expect(mockReportAndSyncFromHook).toHaveBeenCalledOnce(); + }); + it('session-start pull seeds the hook tool root before pulling', async () => { const registry = buildHandlerRegistry(); const handler = registry.find( @@ -828,6 +853,7 @@ describe('hook-handlers registry', () => { // A new handler must decide: team handlers set requiresConfig, the rest join this list. const names = new Set(filterHandlersForConfig(buildHandlerRegistry(), null).map((r) => r.handler.name)); expect([...names].sort()).toEqual([ + 'http-prompt-instructions', 'local-agent-sync', 'package-pending-hint', 'pull', diff --git a/src/__tests__/instruction-targets.test.ts b/src/__tests__/instruction-targets.test.ts new file mode 100644 index 000000000..1e3705920 --- /dev/null +++ b/src/__tests__/instruction-targets.test.ts @@ -0,0 +1,777 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { execFileSync } from 'node:child_process'; +import fs from 'node:fs'; +import fse from 'fs-extra'; +import os from 'node:os'; +import path from 'node:path'; +import { + applyInstructionPlan, + clearInstructionFile, + instructionChannelProblems, + instructionHookTextFor, + planInstructionFiles, + registerOpencodeContext, + retiredFilesOfReached, + resolveInstructionTargets, + type InstructionTarget, +} from '../instruction-targets.js'; +import { injectPiHooks } from '../pi-hooks.js'; +import { + scopedToolPaths, + toolInstallRoot, + TeamaiConfigSchema, + type LocalConfig, + type TeamaiConfig, + TEAMAI_CLAUDEMD_END, + TEAMAI_CLAUDEMD_START, + TEAMAI_CULTURE_END, + TEAMAI_CULTURE_START, +} from '../types.js'; + +const culture = (text: string) => `${TEAMAI_CULTURE_START}\n${text}\n${TEAMAI_CULTURE_END}`; +const claudemd = (text: string) => `${TEAMAI_CLAUDEMD_START}\n${text}\n${TEAMAI_CLAUDEMD_END}`; + +describe('instruction file planning (#945)', () => { + let dir: string; + + beforeEach(() => { + dir = fs.realpathSync.native(fs.mkdtempSync(path.join(os.tmpdir(), 'teamai-945-plan-'))); + }); + + afterEach(() => { + fs.rmSync(dir, { recursive: true, force: true }); + }); + + const target = (file: string, extra: Partial = {}): InstructionTarget => ({ + path: path.join(dir, file), + tools: ['claude'], + recall: false, + ...extra, + }); + + it('gives the recall block to a target whose tool has the subagent, and the direct variant otherwise', async () => { + const recall = '\nuse the subagent\n'; + const direct = '\nrun teamai recall\n'; + const plan = await planInstructionFiles( + [target('a.md', { recall: true }), target('b.md', { recall: false })], + { recall, directRecall: direct }, + ); + + expect(plan.changes.map((c) => c.content)).toEqual([`${recall}\n`, `${direct}\n`]); + }); + + it('restores the header of teamai\'s own rule file when it was lost or changed', async () => { + const header = '---\nalwaysApply: true\n---\n'; + const own = target('teamai-context.mdc', { header, owned: true }); + const fresh = await planInstructionFiles([own], { culture: culture('c') }); + const expected = fresh.changes[0].content; + for (const lost of [`${culture('c')}\n`, `---\nalwaysApply: false\n---\n\n${culture('c')}\n`]) { + fs.writeFileSync(own.path, lost); + const plan = await planInstructionFiles([own], { culture: culture('c') }); + expect(plan.changes.map((c) => c.content)).toEqual([expected]); + } + }); + + it('creates a missing target with the blocks only', async () => { + const plan = await planInstructionFiles([target('CLAUDE.local.md')], { culture: culture('c'), claudemd: claudemd('s') }); + await applyInstructionPlan(plan, { dryRun: false }); + + expect(fs.readFileSync(path.join(dir, 'CLAUDE.local.md'), 'utf8')).toBe(`${culture('c')}\n\n${claudemd('s')}\n`); + }); + + it('plans no change when the blocks are already current', async () => { + const file = path.join(dir, 'CLAUDE.local.md'); + fs.writeFileSync(file, `# Mine\n\n${culture('c')}\n`); + + const plan = await planInstructionFiles([target('CLAUDE.local.md')], { culture: culture('c') }); + + expect(plan.changes).toEqual([]); + }); + + it('reports each file outcome independently of paths mentioned in warnings', async () => { + const ready = target('ready.md'); + const blocked = target('ready.md.blocked'); + const failed = target('failed.md'); + const removed = target('removed.md'); + fs.writeFileSync(ready.path, `${culture('current')}\n`); + fs.writeFileSync(blocked.path, `${TEAMAI_CULTURE_START}\n${ready.path}\n`); + fs.writeFileSync(failed.path, `${culture('old')}\n`); + fs.writeFileSync(removed.path, `${culture('old')}\n`); + const plan = await planInstructionFiles([ready, blocked, failed], { culture: culture('current') }, [removed]); + const write = vi.spyOn(fse, 'writeFile').mockRejectedValueOnce(new Error('EACCES')); + try { + const { files } = await applyInstructionPlan(plan, { dryRun: false }); + expect(files).toEqual([ + { path: ready.path, status: 'current' }, + { path: blocked.path, status: 'blocked' }, + { path: failed.path, status: 'failed' }, + { path: removed.path, status: 'removed' }, + ]); + expect(fs.readFileSync(ready.path, 'utf8')).toBe(`${culture('current')}\n`); + expect(fs.readFileSync(failed.path, 'utf8')).toBe(`${culture('old')}\n`); + expect(fs.existsSync(removed.path)).toBe(false); + } finally { + write.mockRestore(); + } + }); + + it('keeps the whole file unchanged when one requested block is malformed', async () => { + const file = target('mixed.md'); + const original = `${culture('old')}\n\n${TEAMAI_CLAUDEMD_START}\nold selection\n`; + fs.writeFileSync(file.path, original); + const plan = await planInstructionFiles([file], { culture: culture('current'), claudemd: claudemd('current') }); + const { files } = await applyInstructionPlan(plan, { dryRun: false }); + expect(files).toEqual([{ path: file.path, status: 'blocked' }]); + expect(fs.readFileSync(file.path, 'utf8')).toBe(original); + }); + + it('leaves a block with a missing end marker intact and warns', async () => { + const file = path.join(dir, 'AGENTS.md'); + const original = `# Project\n\n${TEAMAI_CLAUDEMD_START}\nold selection\n`; + fs.writeFileSync(file, original); + + const plan = await planInstructionFiles([], {}, [target('AGENTS.md', { tools: [] })]); + await applyInstructionPlan(plan, { dryRun: false }); + + expect(fs.readFileSync(file, 'utf8')).toBe(original); + expect(plan.warnings.join('\n')).toMatch(/AGENTS\.md.*incomplete teamai claudemd block.*by hand/); + }); + + it('removes stale blocks and keeps the authored text', async () => { + const file = path.join(dir, 'AGENTS.md'); + fs.writeFileSync(file, `# Project\n\nAuthored.\n\n${culture('c')}\n\n${claudemd('dev')}\n`); + + const plan = await planInstructionFiles([], {}, [target('AGENTS.md', { tools: [] })]); + await applyInstructionPlan(plan, { dryRun: false }); + + expect(fs.readFileSync(file, 'utf8')).toBe('# Project\n\nAuthored.\n'); + }); + + it('deletes a stale file that held only teamai blocks and is not tracked by git', async () => { + const file = path.join(dir, 'AGENTS.md'); + fs.writeFileSync(file, `\n\n${culture('c')}\n`); + + const plan = await planInstructionFiles([], {}, [target('AGENTS.md', { tools: [] })]); + await applyInstructionPlan(plan, { dryRun: false }); + + expect(fs.existsSync(file)).toBe(false); + }); + + it('keeps a tracked stale file that held only teamai blocks, emptied', async () => { + execFileSync('git', ['init', '-q'], { cwd: dir }); + const file = path.join(dir, 'AGENTS.md'); + fs.writeFileSync(file, `${culture('c')}\n`); + execFileSync('git', ['add', 'AGENTS.md'], { cwd: dir }); + + const plan = await planInstructionFiles([], {}, [target('AGENTS.md', { tools: [] })]); + await applyInstructionPlan(plan, { dryRun: false }); + + expect(fs.readFileSync(file, 'utf8')).toBe(''); + }); + + it('writes nothing in a dry run and reports each file', async () => { + const file = path.join(dir, 'AGENTS.md'); + const original = `# Project\n\n${culture('c')}\n`; + fs.writeFileSync(file, original); + + const plan = await planInstructionFiles([target('CLAUDE.local.md')], { culture: culture('c') }, [target('AGENTS.md', { tools: [] })]); + const { report, files } = await applyInstructionPlan(plan, { dryRun: true }); + + expect(fs.readFileSync(file, 'utf8')).toBe(original); + expect(fs.existsSync(path.join(dir, 'CLAUDE.local.md'))).toBe(false); + expect(report).toEqual([ + `Would write teamai instruction blocks to ${path.join(dir, 'CLAUDE.local.md')}`, + `Would remove teamai instruction blocks from ${file}`, + ]); + expect(files).toEqual([ + { path: path.join(dir, 'CLAUDE.local.md'), status: 'would-write' }, + { path: file, status: 'would-write' }, + ]); + }); + + it('keeps a same-named file teamai does not own and reports it', async () => { + const file = path.join(dir, 'teamai-context.mdc'); + fs.writeFileSync(file, 'my own rule\n'); + + const plan = await planInstructionFiles([target('teamai-context.mdc', { header: '---\nalwaysApply: true\n---\n', owned: true })], { culture: culture('c') }); + + expect(plan.changes).toEqual([]); + expect(plan.warnings.join('\n')).toMatch(/teamai-context\.mdc.*not written by teamai.*left it unchanged/); + }); + + it('writes the header above the blocks and deletes the owned file once its blocks are gone', async () => { + const file = path.join(dir, 'teamai-context.mdc'); + const header = '---\nalwaysApply: true\n---\n'; + const owned = target('teamai-context.mdc', { header, owned: true }); + + await applyInstructionPlan(await planInstructionFiles([owned], { culture: culture('c') }), { dryRun: false }); + expect(fs.readFileSync(file, 'utf8')).toBe(`${header}\n${culture('c')}\n`); + + await applyInstructionPlan(await planInstructionFiles([owned], { culture: null, claudemd: null }), { dryRun: false }); + expect(fs.existsSync(file)).toBe(false); + }); + + it('clears teamai\'s own teamai-context file whole, header included, on uninstall', async () => { + const file = path.join(dir, 'teamai-context.mdc'); + fs.writeFileSync(file, `---\nalwaysApply: true\n---\n\n${culture('c')}\n`); + + expect((await clearInstructionFile(file)).changed).toBe(true); + expect(fs.existsSync(file)).toBe(false); + }); + + it('keeps the member\'s text when uninstall clears another instruction file', async () => { + const file = path.join(dir, 'CLAUDE.md'); + fs.writeFileSync(file, `# Mine\n\n${claudemd('s')}\n`); + + await clearInstructionFile(file); + + expect(fs.readFileSync(file, 'utf8')).toBe('# Mine\n'); + }); +}); + +describe('instruction channel problems (#945)', () => { + it.each(['pi', 'omp', 'hermes', 'codex', 'codex-internal', 'tcodex'])('suppresses only native legacy blocks for %s until cleanup succeeds', async (tool) => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'teamai-945-native-')); + try { + const projectRoot = path.join(root, 'project'); + const repo = path.join(root, 'repo'); + const legacy = path.join(projectRoot, tool === 'omp' ? '.omp/AGENTS.md' : 'AGENTS.md'); + fs.mkdirSync(path.dirname(legacy), { recursive: true }); + fs.mkdirSync(path.join(repo, 'claudemd'), { recursive: true }); + fs.writeFileSync(path.join(repo, 'claudemd/shared.md'), 'FRESH-PROMPT'); + fs.writeFileSync(path.join(repo, 'culture.md'), 'FRESH-CULTURE'); + fs.writeFileSync(legacy, `${claudemd('OLD-MEMBER-PROMPT')}\n`); + const localConfig = { repo: { localPath: repo, remote: '' }, username: 'u', additionalRoles: [], scope: 'project', projectRoot, recallEnabled: false } as LocalConfig; + const teamConfig = TeamaiConfigSchema.parse({ team: 't', repo: 'https://example.invalid/t.git' }); + const held = await instructionHookTextFor(teamConfig, localConfig, tool); + expect(held).not.toContain('FRESH-PROMPT'); + expect(held).toContain('FRESH-CULTURE'); + fs.writeFileSync(legacy, '# Authored instructions\n'); + expect(await instructionHookTextFor(teamConfig, localConfig, tool)).toContain('FRESH-PROMPT'); + if (tool.includes('codex')) { + fs.writeFileSync(legacy, claudemd('SHADOWED-PROMPT')); + fs.writeFileSync(path.join(projectRoot, 'AGENTS.override.md'), '# Native override\n'); + expect(await instructionHookTextFor(teamConfig, localConfig, tool)).toContain('FRESH-PROMPT'); + fs.writeFileSync(path.join(projectRoot, 'AGENTS.override.md'), `${TEAMAI_CLAUDEMD_START}\nMALFORMED-LEGACY`); + expect(await instructionHookTextFor(teamConfig, localConfig, tool)).not.toContain('FRESH-PROMPT'); + } + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }); + + it('names a missing Pi extension in a project, and nothing once it is installed', async () => { + const root = fs.realpathSync.native(fs.mkdtempSync(path.join(os.tmpdir(), 'teamai-945-channel-'))); + const prevHome = process.env.HOME; + process.env.HOME = path.join(root, 'home'); + try { + const projectRoot = path.join(root, 'project'); + const repo = path.join(root, 'repo'); + // Pi is installed for the member (~/.pi); the project has no .pi/. + fs.mkdirSync(path.join(root, 'home', '.pi'), { recursive: true }); + fs.mkdirSync(projectRoot, { recursive: true }); + fs.mkdirSync(path.join(repo, 'claudemd'), { recursive: true }); + fs.writeFileSync(path.join(repo, 'claudemd', 'shared.md'), 'Shared.\n'); + const teamConfig = TeamaiConfigSchema.parse({ team: 't', repo: 'https://example.invalid/t.git' }); + const localConfig = { + repo: { localPath: repo, remote: 'https://example.invalid/t.git' }, + username: 'u', additionalRoles: [], scope: 'project', projectRoot, enabledAgents: ['pi'], + } as unknown as LocalConfig; + + expect((await instructionChannelProblems(teamConfig, localConfig)).join('\n')).toMatch(/teamai-hooks\.ts is missing or out of date, so pi sessions/); + + await injectPiHooks(); + expect(await instructionChannelProblems(teamConfig, localConfig)).toEqual([]); + } finally { + process.env.HOME = prevHome; + fs.rmSync(root, { recursive: true, force: true }); + } + }); +}); + +describe('instruction targets shared by several tools (#945)', () => { + it('retires a shared file only when all its installed former writers reached their replacements', async () => { + const root = fs.realpathSync.native(fs.mkdtempSync(path.join(os.tmpdir(), 'teamai-945-writers-'))); + vi.stubEnv('HOME', path.join(root, 'home')); + try { + const projectRoot = path.join(root, 'project'); + fs.mkdirSync(path.join(root, 'home', '.pi'), { recursive: true }); + fs.mkdirSync(path.join(projectRoot, '.claude'), { recursive: true }); + const localConfig = { + repo: { localPath: path.join(root, 'repo'), remote: 'https://example.invalid/t.git' }, + username: 'u', additionalRoles: [], scope: 'project', projectRoot, + } as unknown as LocalConfig; + const teamConfig = TeamaiConfigSchema.parse({ team: 't', repo: 'https://example.invalid/t.git', toolPaths: { + claude: { rules: '.claude/rules', claudemd: 'AGENTS.md' }, pi: { claudemd: 'AGENTS.md' }, + } }); + const legacy = path.join(projectRoot, 'AGENTS.md'); + expect((await retiredFilesOfReached(teamConfig, localConfig, ['claude'])).map((target) => target.path)).not.toContain(legacy); + expect((await retiredFilesOfReached(teamConfig, localConfig, ['pi'])).map((target) => target.path)).not.toContain(legacy); + expect((await retiredFilesOfReached(teamConfig, localConfig, ['claude', 'pi'])).map((target) => target.path)).toContain(legacy); + } finally { + vi.unstubAllEnvs(); + fs.rmSync(root, { recursive: true, force: true }); + } + }); + + it.each(['pi', 'omp', 'hermes', 'codex'])('protects retired files of excluded project hook tool %s', async (tool) => { + const root = fs.realpathSync.native(fs.mkdtempSync(path.join(os.tmpdir(), 'teamai-945-excluded-'))); + vi.stubEnv('HOME', path.join(root, 'home')); + vi.stubEnv('HERMES_HOME', path.join(root, 'home', '.hermes')); + try { + fs.mkdirSync(path.join(root, 'home', `.${tool}`), { recursive: true }); + const projectRoot = path.join(root, 'project'); + const localConfig = { + repo: { localPath: path.join(root, 'repo'), remote: 'https://example.invalid/t.git' }, + username: 'u', additionalRoles: [], scope: 'project', projectRoot, disabledAgents: [tool], + } as unknown as LocalConfig; + const teamConfig = TeamaiConfigSchema.parse({ team: 't', repo: 'https://example.invalid/t.git', + toolPaths: { [tool]: { settings: `.${tool}/hooks.json`, claudemd: 'AGENTS.md' } }, + }); + const { targets, hooks, stale } = await resolveInstructionTargets(teamConfig, localConfig); + expect(targets).toEqual([]); + expect(hooks).toEqual([]); + expect(stale.map((target) => target.path)).not.toContain(path.join(projectRoot, 'AGENTS.md')); + } finally { + vi.unstubAllEnvs(); + fs.rmSync(root, { recursive: true, force: true }); + } + }); + + it('gives a shared file the subagent recall block only when every tool reading it has the subagent', async () => { + const root = fs.realpathSync.native(fs.mkdtempSync(path.join(os.tmpdir(), 'teamai-945-shared-'))); + try { + const projectRoot = path.join(root, 'project'); + fs.mkdirSync(path.join(projectRoot, '.codebuddy', 'skills'), { recursive: true }); + fs.mkdirSync(path.join(projectRoot, '.workbuddy', 'skills'), { recursive: true }); + const localConfig = { + repo: { localPath: path.join(root, 'repo'), remote: 'https://example.invalid/t.git' }, + username: 'u', additionalRoles: [], scope: 'project', projectRoot, + } as unknown as LocalConfig; + const resolve = async (workbuddyAgents: boolean) => (await resolveInstructionTargets(TeamaiConfigSchema.parse({ + team: 't', + repo: 'https://example.invalid/t.git', + toolPaths: { + codebuddy: { skills: '.codebuddy/skills', rules: '.codebuddy/rules', agents: '.codebuddy/agents' }, + workbuddy: { skills: '.workbuddy/skills', rules: '.workbuddy/rules', ...(workbuddyAgents ? { agents: '.workbuddy/agents' } : {}) }, + }, + }), localConfig)).targets; + + const [mixed] = await resolve(false); + expect(mixed).toMatchObject({ tools: ['codebuddy', 'workbuddy'], recall: false }); + const [both] = await resolve(true); + expect(both).toMatchObject({ tools: ['codebuddy', 'workbuddy'], recall: true }); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }); +}); + +describe('a tool configured without a rules directory (#945)', () => { + it('keeps the configured claudemd as its target, as the member\'s own file, instead of retiring it', async () => { + const root = fs.realpathSync.native(fs.mkdtempSync(path.join(os.tmpdir(), 'teamai-945-norules-'))); + try { + const projectRoot = path.join(root, 'project'); + fs.mkdirSync(path.join(projectRoot, '.claude'), { recursive: true }); + fs.writeFileSync(path.join(projectRoot, '.claude', 'settings.json'), '{}\n'); + const localConfig = { + repo: { localPath: path.join(root, 'repo'), remote: 'https://example.invalid/t.git' }, + username: 'u', additionalRoles: [], scope: 'project', projectRoot, + } as unknown as LocalConfig; + const teamConfig = TeamaiConfigSchema.parse({ + team: 't', + repo: 'https://example.invalid/t.git', + toolPaths: { claude: { settings: '.claude/settings.json', claudemd: '.claude/CLAUDE.md' } }, + }); + + const { targets, stale } = await resolveInstructionTargets(teamConfig, localConfig); + + const file = path.join(projectRoot, '.claude', 'CLAUDE.md'); + expect(targets).toEqual([expect.objectContaining({ path: file, tools: ['claude'], header: undefined, owned: undefined })]); + expect(stale.map((t) => t.path)).not.toContain(file); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }); + + it('keeps WorkBuddy\'s configured project claudemd when its entry has no rules', async () => { + const root = fs.realpathSync.native(fs.mkdtempSync(path.join(os.tmpdir(), 'teamai-945-wb-norules-'))); + try { + const projectRoot = path.join(root, 'project'); + fs.mkdirSync(path.join(projectRoot, '.workbuddy'), { recursive: true }); + fs.writeFileSync(path.join(projectRoot, '.workbuddy', 'settings.json'), '{}\n'); + const localConfig = { + repo: { localPath: path.join(root, 'repo'), remote: 'https://example.invalid/t.git' }, + username: 'u', additionalRoles: [], scope: 'project', projectRoot, + } as unknown as LocalConfig; + const teamConfig = TeamaiConfigSchema.parse({ + team: 't', + repo: 'https://example.invalid/t.git', + toolPaths: { workbuddy: { settings: '.workbuddy/settings.json', claudemd: 'AGENTS.md' } }, + }); + + const { targets, stale } = await resolveInstructionTargets(teamConfig, localConfig); + + const file = path.join(projectRoot, 'AGENTS.md'); + expect(targets).toEqual([expect.objectContaining({ path: file, tools: ['workbuddy'], header: undefined, owned: undefined })]); + expect(stale.map((t) => t.path)).not.toContain(file); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }); + + it('probes a tool whose entry has only claudemd through that file\'s directory', async () => { + const root = fs.realpathSync.native(fs.mkdtempSync(path.join(os.tmpdir(), 'teamai-945-onlymd-'))); + try { + const projectRoot = path.join(root, 'project'); + fs.mkdirSync(projectRoot, { recursive: true }); + const localConfig = { + repo: { localPath: path.join(root, 'repo'), remote: 'https://example.invalid/t.git' }, + username: 'u', additionalRoles: [], scope: 'project', projectRoot, + } as unknown as LocalConfig; + const teamConfig = TeamaiConfigSchema.parse({ + team: 't', repo: 'https://example.invalid/t.git', toolPaths: { claude: { claudemd: '.claude/CLAUDE.md' } }, + }); + + expect((await resolveInstructionTargets(teamConfig, localConfig)).targets).toEqual([]); + fs.mkdirSync(path.join(projectRoot, '.claude')); + expect((await resolveInstructionTargets(teamConfig, localConfig)).targets.map((t) => t.path)) + .toEqual([path.join(projectRoot, '.claude', 'CLAUDE.md')]); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }); +}); + +describe('OpenCode\'s Claude fallback (#945)', () => { + it('counts ~/.claude/CLAUDE.md as OpenCode\'s fallback while it holds blocks, even with Claude excluded', async () => { + const root = fs.realpathSync.native(fs.mkdtempSync(path.join(os.tmpdir(), 'teamai-945-ocfallback-'))); + const prevHome = process.env.HOME; + process.env.HOME = path.join(root, 'home'); + try { + const home = process.env.HOME; + fs.mkdirSync(path.join(home, '.config', 'opencode', 'skills'), { recursive: true }); + fs.mkdirSync(path.join(home, '.claude', 'skills'), { recursive: true }); + const claudeFile = path.join(home, '.claude', 'CLAUDE.md'); + fs.writeFileSync(claudeFile, `${claudemd('an earlier selection')}\n`); + const localConfig = { + repo: { localPath: path.join(root, 'repo'), remote: 'https://example.invalid/t.git' }, + username: 'u', additionalRoles: [], scope: 'user', enabledAgents: ['opencode'], + } as unknown as LocalConfig; + + const resolved = await resolveInstructionTargets(TeamaiConfigSchema.parse({ team: 't', repo: 'https://example.invalid/t.git' }), localConfig); + + expect(resolved.opencodeFallback).toBe(claudeFile); + expect(resolved.targets.map((t) => t.path)).not.toContain(path.join(home, '.config', 'opencode', 'teamai-context.md')); + } finally { + process.env.HOME = prevHome; + fs.rmSync(root, { recursive: true, force: true }); + } + }); +}); + +describe('OpenCode instructions registration (#945)', () => { + it.each(['malformed', 'write failure', 'current', 'written'])('registers only delivered instructions when the target is %s', async (state) => { + const root = fs.realpathSync.native(fs.mkdtempSync(path.join(os.tmpdir(), 'teamai-945-ocreg-'))); + try { + const projectRoot = path.join(root, 'project'); + const file = path.join(projectRoot, '.opencode', 'teamai-context.md'); + fs.mkdirSync(path.join(projectRoot, '.opencode', 'skills'), { recursive: true }); + fs.writeFileSync(file, state === 'malformed' ? `${TEAMAI_CLAUDEMD_START}\nold selection\n` : `${claudemd(state === 'current' ? 'desired' : 'old selection')}\n`); + const localConfig = { + repo: { localPath: path.join(root, 'repo'), remote: 'https://example.invalid/t.git' }, + username: 'u', additionalRoles: [], scope: 'project', projectRoot, + } as unknown as LocalConfig; + const teamConfig = TeamaiConfigSchema.parse({ team: 't', repo: 'https://example.invalid/t.git' }); + const resolved = await resolveInstructionTargets(teamConfig, localConfig); + const plan = await planInstructionFiles(resolved.targets, { claudemd: claudemd('desired') }); + if (state === 'write failure') vi.spyOn(fse, 'writeFile').mockRejectedValueOnce(new Error('EACCES')); + const { files } = await applyInstructionPlan(plan, { dryRun: false }); + await registerOpencodeContext(teamConfig, localConfig, resolved, false, files); + + const config = path.join(projectRoot, '.opencode', 'opencode.json'); + if (state === 'current' || state === 'written') { + expect(JSON.parse(fs.readFileSync(config, 'utf8')).instructions).toEqual(['.opencode/teamai-context.md']); + expect(fs.readFileSync(file, 'utf8')).toContain('desired'); + } else { + expect(fs.existsSync(config)).toBe(false); + expect(fs.readFileSync(file, 'utf8')).toContain('old selection'); + } + } finally { + vi.restoreAllMocks(); + fs.rmSync(root, { recursive: true, force: true }); + } + }); + + it('leaves the member\'s own listed teamai-context.md entry alone, in a dry run and a real pull', async () => { + const root = fs.realpathSync.native(fs.mkdtempSync(path.join(os.tmpdir(), 'teamai-945-ocreg-'))); + try { + const projectRoot = path.join(root, 'project'); + fs.mkdirSync(path.join(projectRoot, '.opencode', 'skills'), { recursive: true }); + fs.writeFileSync(path.join(projectRoot, '.opencode', 'teamai-context.md'), '# My own notes\n'); + const config = path.join(projectRoot, '.opencode', 'opencode.json'); + const listed = JSON.stringify({ instructions: ['.opencode/teamai-context.md'] }); + fs.writeFileSync(config, listed); + const localConfig = { + repo: { localPath: path.join(root, 'repo'), remote: 'https://example.invalid/t.git' }, + username: 'u', additionalRoles: [], scope: 'project', projectRoot, + } as unknown as LocalConfig; + const teamConfig = TeamaiConfigSchema.parse({ team: 't', repo: 'https://example.invalid/t.git' }); + const resolved = await resolveInstructionTargets(teamConfig, localConfig); + + const plan = await planInstructionFiles(resolved.targets, { claudemd: claudemd('desired') }); + for (const dryRun of [true, false]) { + const { files } = await applyInstructionPlan(plan, { dryRun }); + expect(await registerOpencodeContext(teamConfig, localConfig, resolved, dryRun, files)).toBeNull(); + } + expect(fs.readFileSync(config, 'utf8')).toBe(listed); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }); +}); + +describe('Codex project instructions (#945)', () => { + it('reaches a Codex the member relocated with toolRoots, with no project .codex/', async () => { + const root = fs.realpathSync.native(fs.mkdtempSync(path.join(os.tmpdir(), 'teamai-945-codexroot-'))); + const prevHome = process.env.HOME; + process.env.HOME = path.join(root, 'home'); + try { + const projectRoot = path.join(root, 'project'); + fs.mkdirSync(projectRoot, { recursive: true }); + fs.mkdirSync(path.join(root, 'home', '.codex-work', 'skills'), { recursive: true }); + const localConfig = { + repo: { localPath: path.join(root, 'repo'), remote: 'https://example.invalid/t.git' }, + username: 'u', additionalRoles: [], scope: 'project', projectRoot, toolRoots: { codex: '~/.codex-work' }, + } as unknown as LocalConfig; + + const resolved = await resolveInstructionTargets(TeamaiConfigSchema.parse({ team: 't', repo: 'https://example.invalid/t.git' }), localConfig); + + expect(resolved.hooks.map((hook) => hook.tool)).toContain('codex'); + } finally { + process.env.HOME = prevHome; + fs.rmSync(root, { recursive: true, force: true }); + } + }); +}); + +describe('a configured claudemd named like teamai\'s file (#945)', () => { + it('is the member\'s file: an existing one gets the blocks beside its text', async () => { + const root = fs.realpathSync.native(fs.mkdtempSync(path.join(os.tmpdir(), 'teamai-945-fallback-'))); + try { + const projectRoot = path.join(root, 'project'); + const notes = path.join(projectRoot, '.claude', 'teamai-context.md'); + fs.mkdirSync(path.join(projectRoot, '.claude', 'skills'), { recursive: true }); + fs.writeFileSync(notes, '# My notes\n'); + const teamConfig = TeamaiConfigSchema.parse({ + team: 't', repo: 'https://example.invalid/t.git', + toolPaths: { claude: { skills: '.claude/skills', claudemd: '.claude/teamai-context.md' } }, + }); + const localConfig = { + repo: { localPath: path.join(root, 'repo'), remote: 'https://example.invalid/t.git' }, + username: 'u', additionalRoles: [], scope: 'project', projectRoot, enabledAgents: ['claude'], + } as unknown as LocalConfig; + + const { targets } = await resolveInstructionTargets(teamConfig, localConfig); + const plan = await planInstructionFiles(targets.filter((t) => t.path === notes), { culture: culture('c') }); + + expect(plan.warnings).toEqual([]); + expect(plan.changes.map((c) => c.content)).toEqual([`# My notes\n\n${culture('c')}\n`]); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }); +}); + +describe('OpenCode instructions ownership (#945)', () => { + it.each(['state', 'config'])('keeps OpenCode registration retryable after a %s write fails', async (failure) => { + const root = fs.realpathSync.native(fs.mkdtempSync(path.join(os.tmpdir(), 'teamai-945-ocretry-'))); + vi.stubEnv('HOME', path.join(root, 'home')); + try { + const projectRoot = path.join(root, 'project'); + const context = path.join(projectRoot, '.opencode/teamai-context.md'); + fs.mkdirSync(path.dirname(context), { recursive: true }); + fs.writeFileSync(context, `${claudemd('team')}\n`); + const config = path.join(projectRoot, '.opencode/opencode.json'); + fs.writeFileSync(config, JSON.stringify({ instructions: ['docs/style.md'] })); + const localConfig = { + repo: { localPath: path.join(root, 'repo'), remote: 'https://example.invalid/t.git' }, + username: 'u', additionalRoles: [], scope: 'project', projectRoot, dataHome: path.join(root, 'data'), enabledAgents: ['opencode'], + } as unknown as LocalConfig; + const teamConfig = TeamaiConfigSchema.parse({ team: 't', repo: 'https://example.invalid/t.git' }); + const resolved = await resolveInstructionTargets(teamConfig, localConfig); + const files = [{ path: context, status: 'current' as const }]; + const rename = fse.rename.bind(fse); + const failedPath = failure === 'state' ? path.join(root, 'data/state.json') : config; + const write = vi.spyOn(fse, 'rename').mockImplementation(async (from, to) => { + if (String(to) === failedPath) throw new Error(`EACCES ${failure}`); + return rename(from, to); + }); + await expect(registerOpencodeContext(teamConfig, localConfig, resolved, false, files)).rejects.toThrow('EACCES'); + // A failed ownership save must never leave an unowned active entry. + expect(JSON.parse(fs.readFileSync(config, 'utf8')).instructions).toEqual(['docs/style.md']); + write.mockRestore(); + + await registerOpencodeContext(teamConfig, localConfig, resolved, false, files); + const { loadStateForScope } = await import('../config.js'); + expect((await loadStateForScope(localConfig)).opencodeContextEntries).toEqual([{ config, entry: '.opencode/teamai-context.md' }]); + fs.unlinkSync(context); + if (failure === 'state') { + const removalWrite = vi.spyOn(fse, 'rename').mockImplementation(async (from, to) => { + if (String(to) === failedPath) throw new Error('EACCES removal state'); + return rename(from, to); + }); + await expect(registerOpencodeContext(teamConfig, localConfig, { targets: [], stale: resolved.targets }, false, [])) + .rejects.toThrow('EACCES removal state'); + expect(JSON.parse(fs.readFileSync(config, 'utf8')).instructions).toEqual(['docs/style.md']); + expect((await loadStateForScope(localConfig)).opencodeContextEntries).toHaveLength(1); + removalWrite.mockRestore(); + } + await registerOpencodeContext(teamConfig, localConfig, { targets: [], stale: resolved.targets }, false, []); + expect(JSON.parse(fs.readFileSync(config, 'utf8')).instructions).toEqual(['docs/style.md']); + expect((await loadStateForScope(localConfig)).opencodeContextEntries).toEqual([]); + } finally { + vi.restoreAllMocks(); + vi.unstubAllEnvs(); + fs.rmSync(root, { recursive: true, force: true }); + } + }); + + it.each(['incomplete', 'repeated'])('reports %s markers left in a retired file through doctor', async (kind) => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'teamai-945-malformed-doctor-')); + vi.stubEnv('HOME', path.join(root, 'home')); + try { + const projectRoot = path.join(root, 'project'); + const legacy = path.join(projectRoot, '.claude/CLAUDE.md'); + fs.mkdirSync(path.dirname(legacy), { recursive: true }); + fs.writeFileSync(legacy, kind === 'incomplete' ? `${TEAMAI_CLAUDEMD_START}\nold` : `${claudemd('old')}\n${claudemd('repeated')}`); + const localConfig = { repo: { localPath: path.join(root, 'repo'), remote: '' }, username: 'u', additionalRoles: [], scope: 'project', projectRoot } as LocalConfig; + const teamConfig = TeamaiConfigSchema.parse({ team: 't', repo: 'https://example.invalid/t.git' }); + const { buildInstructionDeliveryChecks } = await import('../doctor-delivery.js'); + const checks = await buildInstructionDeliveryChecks({ teamConfig, localConfig } as never); + const stale = checks.find((check) => check.name === 'No team instruction blocks are left in files no tool loads them from'); + expect(await stale!.check()).toBe(false); + expect(stale!.fix).toContain(legacy); + expect(stale!.fix).toMatch(/marker/i); + } finally { + vi.unstubAllEnvs(); + fs.rmSync(root, { recursive: true, force: true }); + } + }); + + it('does not report excluded Claude legacy blocks as a stale doctor delivery', async () => { + const root = fs.realpathSync.native(fs.mkdtempSync(path.join(os.tmpdir(), 'teamai-945-excluded-file-'))); + vi.stubEnv('HOME', path.join(root, 'home')); + try { + const projectRoot = path.join(root, 'project'); + const legacy = path.join(projectRoot, '.claude/CLAUDE.md'); + fs.mkdirSync(path.dirname(legacy), { recursive: true }); + const original = `${claudemd('excluded prompt')}\n`; + fs.writeFileSync(legacy, original); + const localConfig = { + repo: { localPath: path.join(root, 'repo'), remote: 'https://example.invalid/t.git' }, + username: 'u', additionalRoles: [], scope: 'project', projectRoot, disabledAgents: ['claude'], + } as unknown as LocalConfig; + const teamConfig = TeamaiConfigSchema.parse({ team: 't', repo: 'https://example.invalid/t.git' }); + const { buildInstructionDeliveryChecks } = await import('../doctor-delivery.js'); + const checks = await buildInstructionDeliveryChecks({ teamConfig, localConfig } as never); + const stale = checks.find((check) => check.name === 'No team instruction blocks are left in files no tool loads them from'); + expect(await stale!.check()).toBe(true); + expect((await resolveInstructionTargets(teamConfig, localConfig)).stale.map((target) => target.path)).not.toContain(legacy); + expect(fs.readFileSync(legacy, 'utf8')).toBe(original); + } finally { + vi.unstubAllEnvs(); + fs.rmSync(root, { recursive: true, force: true }); + } + }); + + it('records the entry teamai adds, for uninstall, and forgets it once removed', async () => { + const root = fs.realpathSync.native(fs.mkdtempSync(path.join(os.tmpdir(), 'teamai-945-ocown-'))); + const prevHome = process.env.HOME; + process.env.HOME = path.join(root, 'home'); + try { + const projectRoot = path.join(root, 'project'); + const context = path.join(projectRoot, '.opencode', 'teamai-context.md'); + fs.mkdirSync(path.join(projectRoot, '.opencode', 'skills'), { recursive: true }); + fs.writeFileSync(context, `${claudemd('team')}\n`); + const config = path.join(projectRoot, '.opencode', 'opencode.json'); + const localConfig = { + repo: { localPath: path.join(root, 'repo'), remote: 'https://example.invalid/t.git' }, + username: 'u', additionalRoles: [], scope: 'project', projectRoot, dataHome: path.join(root, 'data'), + } as unknown as LocalConfig; + const teamConfig = TeamaiConfigSchema.parse({ team: 't', repo: 'https://example.invalid/t.git' }); + const { loadStateForScope } = await import('../config.js'); + + const initial = await resolveInstructionTargets(teamConfig, localConfig); + const { files } = await applyInstructionPlan(await planInstructionFiles(initial.targets, { claudemd: claudemd('team') }), { dryRun: false }); + await registerOpencodeContext(teamConfig, localConfig, initial, false, files); + expect((await loadStateForScope(localConfig)).opencodeContextEntries).toEqual([{ config, entry: '.opencode/teamai-context.md' }]); + + fs.rmSync(context); + const resolved = await resolveInstructionTargets(teamConfig, localConfig); + await registerOpencodeContext(teamConfig, localConfig, { targets: [], stale: resolved.targets }, false, []); + expect((await loadStateForScope(localConfig)).opencodeContextEntries).toEqual([]); + + // An entry the member listed, which teamai did not record, stays. + fs.writeFileSync(config, JSON.stringify({ instructions: ['.opencode/teamai-context.md'] })); + await registerOpencodeContext(teamConfig, localConfig, { targets: [], stale: resolved.targets }, false, []); + expect(JSON.parse(fs.readFileSync(config, 'utf8')).instructions).toEqual(['.opencode/teamai-context.md']); + // Even a generated file does not prove ownership of a member's entry. + fs.writeFileSync(context, `${claudemd('team')}\n`); + await registerOpencodeContext(teamConfig, localConfig, resolved, false, [{ path: context, status: 'current' }]); + expect((await loadStateForScope(localConfig)).opencodeContextEntries).toEqual([]); + } finally { + process.env.HOME = prevHome; + fs.rmSync(root, { recursive: true, force: true }); + } + }); +}); + +describe('every tool and toolPaths shape keeps its instructions (#945)', () => { + type Paths = TeamaiConfig['toolPaths'][string]; + const DEFAULTS = TeamaiConfigSchema.parse({ team: 't', repo: 'https://example.invalid/t.git' }); + const SHAPES: Record Paths> = { + full: (paths) => paths, + 'without rules': ({ rules: _rules, ...rest }) => rest, + 'only claudemd': (paths) => (paths.claudemd === undefined ? {} : { claudemd: paths.claudemd }), + 'only settings': (paths) => (paths.settings === undefined ? {} : { settings: paths.settings }), + }; + const cases = (['user', 'project'] as const).flatMap((scope) => + Object.keys(DEFAULTS.toolPaths).flatMap((tool) => Object.keys(SHAPES).map((shape) => [scope, tool, shape] as const))); + + it.each(cases)('%s scope, %s, %s: an installed tool with a claudemd still gets the blocks, and no target is retired', async (scope, tool, shape) => { + const root = fs.realpathSync.native(fs.mkdtempSync(path.join(os.tmpdir(), 'teamai-945-shapes-'))); + const saved = { HOME: process.env.HOME, HERMES_HOME: process.env.HERMES_HOME, COPILOT_HOME: process.env.COPILOT_HOME }; + process.env.HOME = path.join(root, 'home'); + process.env.HERMES_HOME = path.join(root, 'hermes'); + process.env.COPILOT_HOME = path.join(root, 'copilot'); + try { + const projectRoot = path.join(root, 'project'); + const localConfig = { + repo: { localPath: path.join(root, 'repo'), remote: 'https://example.invalid/t.git' }, + username: 'u', additionalRoles: [], scope, ...(scope === 'project' ? { projectRoot } : {}), + } as unknown as LocalConfig; + const paths = SHAPES[shape](scopedToolPaths(DEFAULTS, localConfig)[tool]); + const base = scope === 'project' ? projectRoot : process.env.HOME; + for (const dir of [process.env.HERMES_HOME, process.env.COPILOT_HOME, path.join(process.env.HOME, '.omp')]) fs.mkdirSync(dir, { recursive: true }); + for (const value of Object.values(paths)) { + if (typeof value === 'string') fs.mkdirSync(path.join(base, toolInstallRoot(value)), { recursive: true }); + } + const teamConfig = TeamaiConfigSchema.parse({ team: 't', repo: 'https://example.invalid/t.git', toolPaths: { [tool]: paths } }); + + const { targets, hooks, stale } = await resolveInstructionTargets(teamConfig, localConfig); + + const targetPaths = new Set(targets.map((t) => t.path)); + expect(stale.filter((t) => targetPaths.has(t.path))).toEqual([]); + if (paths.claudemd !== undefined) { + expect([...targets.flatMap((t) => t.tools), ...hooks.map((h) => h.tool)]).toContain(tool); + } + } finally { + for (const [key, value] of Object.entries(saved)) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + fs.rmSync(root, { recursive: true, force: true }); + } + }); +}); diff --git a/src/__tests__/local-agent.test.ts b/src/__tests__/local-agent.test.ts index 70da0f394..44e4e7b8c 100644 --- a/src/__tests__/local-agent.test.ts +++ b/src/__tests__/local-agent.test.ts @@ -1685,6 +1685,89 @@ describe('local-agent: cmds[] migration', () => { expect(manifest.scopes.user.rules?.['doc-a']).toBeUndefined(); }); + it('handle_type=prompt strips the block an earlier release left in ~/AGENTS.md (#945)', async () => { + const legacy = path.join(tmpDir, 'AGENTS.md'); + await fse.writeFile(legacy, `# mine\n\n${TEAMAI_CLAUDEMD_START}\nanother member's selection\n\n`); + + const acks = await runResponse({ + cmds: [{ + id: 6, type: 'install_prompt_rule', handle_type: 'prompt', slug: 'doc-a', + version: '1.0.0', download_url: 'http://127.0.0.1:42100/doc-a.md', scope: 'user', + }], + }); + + expect(acks.find((a) => a.id === 6)?.status).toBe('success'); + expect(await fse.readFile(legacy, 'utf8')).toBe('# mine\n'); + }); + + it.each(['malformed', 'write failure'])('keeps the final HTTP prompt retryable when removal encounters %s (#945)', async (failure) => { + await runResponse({ cmds: [{ + id: 61, type: 'install_prompt_rule', handle_type: 'prompt', slug: 'doc-a', + version: '1.0.0', download_url: 'http://127.0.0.1:42100/doc-a.md', scope: 'user', + }] }); + const target = path.join(tmpDir, '.codebuddy', 'CODEBUDDY.md'); + // Keep member text so removal writes a file rather than deleting it. + const installed = '# My notes\n' + await fse.readFile(target, 'utf8'); + const retained = failure === 'malformed' ? installed.replace('', '') : installed; + await fse.writeFile(target, retained); + const originalWrite = fse.writeFile.bind(fse); + if (failure === 'write failure') { + vi.spyOn(fse, 'writeFile').mockImplementation((...args: Parameters) => { + if (args[0] === target) return Promise.reject(new Error('EACCES')); + return originalWrite(...args); + }); + } + const command = { id: 62, type: 'uninstall_prompt_rule', handle_type: 'prompt', slug: 'doc-a', scope: 'user' }; + const acks = await runResponse({ cmds: [command] }); + expect(acks.find((ack) => ack.id === 62)?.status).toBe('failed'); + expect(await fse.readFile(target, 'utf8')).toBe(retained); + const manifestFile = path.join(tmpDir, '.teamai', 'local-agent', 'manifest.json'); + expect((await fse.readJson(manifestFile)).scopes.user.claudemd['doc-a']).toBeDefined(); + expect(await fse.readFile(path.join(tmpDir, '.teamai', 'local-agent', 'resources', 'user', 'claudemd', 'doc-a.md'), 'utf8')).toContain('# content'); + vi.restoreAllMocks(); + await fse.writeFile(target, installed); + const retry = await runResponse({ cmds: [{ ...command, id: 63 }] }); + expect(retry.find((ack) => ack.id === 63)?.status).toBe('success'); + expect(await fse.readFile(target, 'utf8')).not.toContain(TEAMAI_CLAUDEMD_START); + expect((await fse.readJson(manifestFile)).scopes.user.claudemd['doc-a']).toBeUndefined(); + }); + + it.each([ + ['install', 'malformed'], ['remove', 'malformed'], ['install', 'write failure'], ['remove', 'write failure'], + ])('fails the HTTP %s ACK and retains its manifest when retired cleanup encounters %s (#945)', async (operation, failure) => { + const command = { + id: 64, type: 'install_prompt_rule', handle_type: 'prompt', slug: 'doc-a', + version: '1.0.0', download_url: 'http://127.0.0.1:42100/doc-a.md', scope: 'user', + }; + if (operation === 'remove') await runResponse({ cmds: [command] }); + const legacy = path.join(tmpDir, 'AGENTS.md'); + const retained = `# My notes\n${TEAMAI_CLAUDEMD_START}\nold instructions\n${failure === 'malformed' ? '' : '\n'}`; + await fse.writeFile(legacy, retained); + if (failure === 'write failure') { + const originalWrite = fse.writeFile.bind(fse); + vi.spyOn(fse, 'writeFile').mockImplementation((...args: Parameters) => { + if (args[0] === legacy) return Promise.reject(new Error('EACCES')); + return originalWrite(...args); + }); + } + const acks = await runResponse({ cmds: [{ ...command, id: 65, + ...(operation === 'remove' ? { type: 'uninstall_prompt_rule' } : {}), + }] }); + expect(acks.find((ack) => ack.id === 65)?.status).toBe('failed'); + expect(await fse.readFile(legacy, 'utf8')).toBe(retained); + const manifestFile = path.join(tmpDir, '.teamai', 'local-agent', 'manifest.json'); + const manifest = await fse.pathExists(manifestFile) ? await fse.readJson(manifestFile) : null; + expect(Boolean(manifest?.scopes.user?.claudemd?.['doc-a'])).toBe(operation === 'remove'); + const cache = path.join(tmpDir, '.teamai', 'local-agent', 'resources', 'user', 'claudemd', 'doc-a.md'); + expect(await fse.pathExists(cache)).toBe(operation === 'remove'); + vi.restoreAllMocks(); + await fse.writeFile(legacy, '# My notes\n'); + const retry = await runResponse({ cmds: [{ ...command, id: 66, + ...(operation === 'remove' ? { type: 'uninstall_prompt_rule' } : {}), + }] }); + expect(retry.find((ack) => ack.id === 66)?.status).toBe('success'); + }); + // Codex's default `claudemd` (#938) makes a Codex report a target of this sync. it('handle_type=prompt from Codex writes the prompt into ~/.codex/AGENTS.md when ~/.codex exists', async () => { await fse.ensureDir(path.join(tmpDir, '.codex')); @@ -2039,14 +2122,14 @@ describe('local-agent: per-worktree claudemd isolation (issue #374 P1-2C)', () = syncFor = { ws: wtBReal, slug: 'b-doc' }; await reportAndSyncLocalAgent({ cwd: wtBReal, tool: 'codebuddy', status: 'running' }); - // B's injected claudemd (.codebuddy/CODEBUDDY.md) must contain ONLY B's + // B's injected claudemd (.codebuddy/rules/teamai-context.md, #945) must contain ONLY B's // instruction (the pre-fix shared cache made syncClaudemd merge A's in too). const readTxt = async (p: string) => (await fse.pathExists(p)) ? fse.readFile(p, 'utf-8') : ''; - const bClaudemd = await readTxt(path.join(wtBReal, '.codebuddy', 'CODEBUDDY.md')); + const bClaudemd = await readTxt(path.join(wtBReal, '.codebuddy', 'rules', 'teamai-context.md')); expect(bClaudemd).toContain('INSTRUCTION-FROM-B'); expect(bClaudemd).not.toContain('INSTRUCTION-FROM-A'); // A keeps only A's. - const aClaudemd = await readTxt(path.join(repo, '.codebuddy', 'CODEBUDDY.md')); + const aClaudemd = await readTxt(path.join(repo, '.codebuddy', 'rules', 'teamai-context.md')); expect(aClaudemd).toContain('INSTRUCTION-FROM-A'); expect(aClaudemd).not.toContain('INSTRUCTION-FROM-B'); @@ -2055,6 +2138,222 @@ describe('local-agent: per-worktree claudemd isolation (issue #374 P1-2C)', () = }); }); +describe('local-agent: project prompts reach every installed tool (#945)', () => { + async function installProjectPrompt(tool: string, toolDirs: string[], options: { prompt?: string; files?: Record; sessionStart?: boolean } = {}) { + const { execFileSync } = await import('node:child_process'); + const repo = path.join(tmpDir, 'project'); + await fse.ensureDir(repo); + execFileSync('git', ['init', '-q'], { cwd: repo, stdio: 'pipe' }); + for (const dir of toolDirs) await fse.ensureDir(path.join(repo, dir)); + for (const [rel, text] of Object.entries(options.files ?? {})) await fse.outputFile(path.join(repo, rel), text); + await fse.ensureDir(path.join(tmpDir, '.teamai', 'local-agent')); + await fse.writeJson(path.join(tmpDir, '.teamai', 'local-agent', 'config.json'), { + endpoint: 'https://test.example.com/api', token: 't', localAgentId: 'id', + createdAt: '2026-01-01T00:00:00.000Z', workspaceBindings: {}, + }); + const acks: Array> = []; + vi.stubGlobal('fetch', vi.fn(async (input: string | URL, init?: { body?: string }) => { + const url = String(input); + if (url.endsWith('doc.md')) return new Response(options.prompt ?? 'PROJECT-PROMPT'); + if (url.includes('/local-agent/sync')) { + return new Response(JSON.stringify({ + ok: true, + cmds: [{ + id: 201, type: 'install_prompt_rule', handle_type: 'prompt', slug: 'doc', + version: '1.0.0', download_url: 'http://127.0.0.1:42100/doc.md', scope: 'workspace', workspace_path: repo, + }], + })); + } + if (url.includes('/commands/ack')) acks.push(JSON.parse(init?.body ?? '{}')); + return new Response(JSON.stringify({ ok: true })); + })); + let output: string | null = null; + if (options.sessionStart) { + const { createDispatcher } = await import('../hook-dispatch.js'); + const { buildHandlerRegistry, filterHandlersForConfig } = await import('../hook-handlers.js'); + const dispatcher = createDispatcher({ localConfig: null, handlers: filterHandlersForConfig(buildHandlerRegistry(), null) }); + const result = await dispatcher.dispatch('session-start', '*', { cwd: repo, hook_event_name: 'SessionStart', source: 'startup' }, tool, 'foreground'); + output = result.output; + } else { + const { reportAndSyncLocalAgent } = await import('../local-agent.js'); + await reportAndSyncLocalAgent({ cwd: repo, tool, status: 'running' }); + } + return { repo, ack: acks.find((a) => a.id === 201), output }; + } + + it('installs a WorkBuddy project prompt where .codebuddy/ does not exist', async () => { + const { repo, ack } = await installProjectPrompt('workbuddy', ['.workbuddy/skills']); + + expect(ack?.status).toBe('success'); + expect(await fse.readFile(path.join(repo, '.codebuddy', 'rules', 'teamai-context.md'), 'utf8')).toContain('PROJECT-PROMPT'); + }); + + it('gives Pi a project prompt through its extension, not the project AGENTS.md', async () => { + const { injectPiHooks } = await import('../pi-hooks.js'); + await injectPiHooks(); + const { repo, ack } = await installProjectPrompt('pi', ['.pi/skills']); + + expect(ack?.status).toBe('success'); + expect(await fse.pathExists(path.join(repo, 'AGENTS.md'))).toBe(false); + const { buildHandlerRegistry, filterHandlersForConfig } = await import('../hook-handlers.js'); + const outputs = await Promise.all(filterHandlersForConfig(buildHandlerRegistry(), null) + .filter((reg) => reg.event === 'instructions') + .map((reg) => reg.handler.execute({ cwd: repo } as never, 'pi', null as never))); + expect(outputs.filter(Boolean).join('\n')).toContain('PROJECT-PROMPT'); + }); + + it.each([['pi', 'workbuddy'], ['workbuddy', 'pi']])('retires a shared HTTP prompt after separate %s then %s deliveries', async (first, second) => { + await fse.ensureDir(path.join(tmpDir, '.pi')); + const { injectPiHooks } = await import('../pi-hooks.js'); + if (first === 'pi') await injectPiHooks(); + const legacy = '# Team notes\n\nold member prompt\n\n\nworking culture\n\n'; + const { repo, ack } = await installProjectPrompt(first, ['.workbuddy/skills'], { files: { 'AGENTS.md': legacy } }); + expect(ack?.status).toBe('success'); + expect(await fse.readFile(path.join(repo, 'AGENTS.md'), 'utf8')).toContain('old member prompt'); + const { localAgentInstructionText } = await import('../local-agent.js'); + expect(await localAgentInstructionText(repo, 'pi')).toBe(''); + + await injectPiHooks(); + const next = await installProjectPrompt(second, []); + expect(next.ack?.status).toBe('success'); + const retired = await fse.readFile(path.join(repo, 'AGENTS.md'), 'utf8'); + expect(retired).not.toContain('old member prompt'); + expect(retired).toContain('working culture'); + expect(await fse.readFile(path.join(repo, '.codebuddy/rules/teamai-context.md'), 'utf8')).toContain('PROJECT-PROMPT'); + expect(await localAgentInstructionText(repo)).toContain('PROJECT-PROMPT'); + }); + + it('does not count a previous HTTP delivery after the cached prompt changes', async () => { + await fse.ensureDir(path.join(tmpDir, '.pi')); + const legacy = '# Notes\n\nlegacy prompt\n\n'; + const first = await installProjectPrompt('workbuddy', ['.workbuddy/skills'], { prompt: 'OLD-PROMPT', files: { 'AGENTS.md': legacy } }); + expect(first.ack?.status).toBe('success'); + const { injectPiHooks } = await import('../pi-hooks.js'); + await injectPiHooks(); + const next = await installProjectPrompt('pi', [], { prompt: 'NEW-PROMPT' }); + expect(next.ack?.status).toBe('success'); + expect(await fse.readFile(path.join(next.repo, 'AGENTS.md'), 'utf8')).toBe(legacy); + expect(await fse.readFile(path.join(next.repo, '.codebuddy/rules/teamai-context.md'), 'utf8')).toContain('OLD-PROMPT'); + + const retry = await installProjectPrompt('workbuddy', [], { prompt: 'NEW-PROMPT' }); + expect(retry.ack?.status).toBe('success'); + expect(await fse.readFile(path.join(next.repo, 'AGENTS.md'), 'utf8')).toBe('# Notes\n'); + }); + + it('retains a shared HTTP prompt while another writer has a rejected replacement', async () => { + await fse.ensureDir(path.join(tmpDir, '.pi')); + const { injectPiHooks } = await import('../pi-hooks.js'); + await injectPiHooks(); + const legacy = '# Notes\n\nlegacy prompt\n\n'; + const first = await installProjectPrompt('pi', ['.workbuddy/skills'], { files: { + 'AGENTS.md': legacy, '.codebuddy/rules/teamai-context.md': '# Foreign\n', + } }); + expect(first.ack?.status).toBe('success'); + const failed = await installProjectPrompt('workbuddy', []); + expect(failed.ack?.status).toBe('failed'); + expect(await fse.readFile(path.join(first.repo, 'AGENTS.md'), 'utf8')).toBe(legacy); + await fse.remove(path.join(first.repo, '.codebuddy/rules/teamai-context.md')); + const retry = await installProjectPrompt('workbuddy', []); + expect(retry.ack?.status).toBe('success'); + expect(await fse.readFile(path.join(first.repo, 'AGENTS.md'), 'utf8')).toBe('# Notes\n'); + }); + + it('leaves the blocks an earlier release left for another installed tool that this prompt did not reach', async () => { + const legacy = '# Team notes\n\n\nClaude\'s earlier selection\n\n'; + const { repo, ack } = await installProjectPrompt('codebuddy', ['.codebuddy/skills', '.claude/skills'], { + files: { '.claude/CLAUDE.md': legacy }, + }); + + expect(ack?.status).toBe('success'); + expect(await fse.readFile(path.join(repo, '.codebuddy', 'rules', 'teamai-context.md'), 'utf8')).toContain('PROJECT-PROMPT'); + expect(await fse.readFile(path.join(repo, '.claude', 'CLAUDE.md'), 'utf8')).toBe(legacy); + }); + + it('gives Codex a project prompt through its SessionStart and SubagentStart hooks', async () => { + // Codex is installed for the member (~/.codex), not in the project. + await fse.ensureDir(path.join(tmpDir, '.codex', 'skills')); + const { reconcileHooks } = await import('../hooks.js'); + await reconcileHooks(path.join(tmpDir, '.codex', 'hooks.json'), 'codex', []); + const { repo, ack } = await installProjectPrompt('codex', []); + expect(await fse.pathExists(path.join(repo, '.codex'))).toBe(false); + + expect(ack?.status).toBe('success'); + const { buildHandlerRegistry, filterHandlersForConfig } = await import('../hook-handlers.js'); + const registry = filterHandlersForConfig(buildHandlerRegistry(), null); + for (const [event, hookEventName] of [['session-start', 'SessionStart'], ['subagent-start', 'SubagentStart']] as const) { + const outputs = (await Promise.all(registry.filter((reg) => reg.event === event) + .map((reg) => reg.handler.execute({ cwd: repo, hook_event_name: hookEventName, source: 'startup' } as never, 'codex', null as never)))) + .filter((output): output is string => typeof output === 'string' && output.includes('PROJECT-PROMPT')); + expect(outputs.map((output) => JSON.parse(output).hookSpecificOutput.hookEventName)).toEqual([hookEventName]); + } + }); + + it('includes a newly downloaded HTTP prompt in Codex\'s first SessionStart output', async () => { + await fse.ensureDir(path.join(tmpDir, '.codex', 'skills')); + const { reconcileHooks } = await import('../hooks.js'); + await reconcileHooks(path.join(tmpDir, '.codex', 'hooks.json'), 'codex', []); + const { ack, output } = await installProjectPrompt('codex', [], { sessionStart: true }); + expect(ack?.status).toBe('success'); + expect(output).toContain('PROJECT-PROMPT'); + }); + + it('records the OpenCode entry it adds in the project\'s data home, where uninstall reads it', async () => { + // A partitioned project: its data home is under ~/.teamai/projects. + const { projectSlug } = await import('../utils/partition.js'); + const project = path.join(tmpDir, 'project'); + const partition = path.join(tmpDir, '.teamai', 'projects', projectSlug(fs.realpathSync(tmpDir) + '/project')); + await fse.outputFile(path.join(partition, 'config.yaml'), + `repo:\n localPath: ${partition}/team-repo\n remote: https://example.com/t.git\nusername: u\nscope: project\nprojectRoot: ${project}\n`); + const { repo, ack } = await installProjectPrompt('opencode', ['.opencode/skills']); + + expect(ack?.status).toBe('success'); + const { loadStateForScope, resolveDataHomeForScope } = await import('../config.js'); + const dataHome = await resolveDataHomeForScope('project', repo); + expect(dataHome).toBe(partition); + const state = await loadStateForScope({ scope: 'project', projectRoot: repo, dataHome } as never); + expect(state.opencodeContextEntries?.map((e) => e.entry)).toEqual(['.opencode/teamai-context.md']); + }); + + it('fails an OpenCode project prompt whose config teamai cannot list it in', async () => { + const { ack } = await installProjectPrompt('opencode', ['.opencode/skills'], { + files: { '.opencode/opencode.json': '{\n // my settings\n "instructions": []\n}\n' }, + }); + + expect(ack?.status).toBe('failed'); + }); + + it('fails a Pi project prompt while its extension is missing', async () => { + const { ack } = await installProjectPrompt('pi', ['.pi/skills']); + + expect(ack?.status).toBe('failed'); + }); + + it('fails a Claude project prompt whose target is a file teamai did not write, and says why', async () => { + const mine = '# My own context rule\n'; + const { repo, ack } = await installProjectPrompt('claude', ['.claude/skills'], { files: { '.claude/rules/teamai-context.md': mine } }); + + expect(ack?.status).toBe('failed'); + expect(String(ack?.error)).toContain('was not written by teamai'); + expect(await fse.readFile(path.join(repo, '.claude', 'rules', 'teamai-context.md'), 'utf8')).toBe(mine); + }); + + it('fails a Hermes project prompt over its 4,000-character section, and acks one that fits', async () => { + const { injectHermesHooks } = await import('../hermes-hooks.js'); + await fse.ensureDir(path.join(tmpDir, '.hermes')); + await injectHermesHooks(); + + const over = await installProjectPrompt('hermes', [], { prompt: 'x'.repeat(4100) }); + expect(over.ack?.status).toBe('failed'); + expect(String(over.ack?.error)).toMatch(/over the 4000-character limit/); + // The rejected prompt does not reach the session hook either. + const { localAgentInstructionText } = await import('../local-agent.js'); + expect(await localAgentInstructionText(over.repo)).not.toContain('xxxx'); + + const fits = await installProjectPrompt('hermes', [], { prompt: 'PROJECT-PROMPT' }); + expect(fits.ack?.status).toBe('success'); + }); +}); + describe('local-agent: loadLocalAgentConfig({ dryRun: true }) writes nothing (#893)', () => { const configPath = () => path.join(tmpDir, '.teamai', 'local-agent', 'config.json'); diff --git a/src/__tests__/omp-hooks.test.ts b/src/__tests__/omp-hooks.test.ts index 1452408fc..87a102114 100644 --- a/src/__tests__/omp-hooks.test.ts +++ b/src/__tests__/omp-hooks.test.ts @@ -24,7 +24,7 @@ import { } from '../omp-hooks.js'; import { reconcileHooksToAllTools } from '../hooks.js'; import { log } from '../utils/logger.js'; -import { loadOmpExtension } from './helpers/pi-extensions.js'; +import { loadOmpExtension, type ExtensionDispatch } from './helpers/pi-extensions.js'; describe('resolveOmpExtensionsDir', () => { it('always targets the user agent dir (single-copy policy)', () => { @@ -82,6 +82,8 @@ describe('buildOmpExtensionSource', () => { it('is syntactically valid JavaScript', () => { assertValidJs(src); }); + + }); describe('injectOmpHooks / removeOmpHooks', () => { @@ -128,6 +130,16 @@ describe('injectOmpHooks / removeOmpHooks', () => { await removeOmpHooks(); expect(await fse.pathExists(extFile())).toBe(false); }); + + it('leaves a same-named extension teamai did not write alone on inject and remove', async () => { + await fse.outputFile(extFile(), 'export default function mine() {}\n'); + + await injectOmpHooks(); + expect(log.warn).toHaveBeenCalledWith(expect.stringContaining(`${extFile()} exists without the TeamAI marker`)); + await removeOmpHooks(); + + expect(await fse.readFile(extFile(), 'utf8')).toBe('export default function mine() {}\n'); + }); }); describe('reconcileHooksToAllTools routes omp to the extension adapter', () => { @@ -191,7 +203,46 @@ describe('reconcileHooksToAllTools routes omp to the extension adapter', () => { }); }); +// Team instructions (#945): the extension adds hook-dispatch's context to the prompt. +describe('OMP extension: team instructions in the system prompt (#945)', () => { + const ctx = { cwd: '/work/proj/src', sessionManager: { getSessionId: () => 'omp-main' } }; + const context = (text: string) => (args: string[]) => args[1] === 'instructions' + ? JSON.stringify({ hookSpecificOutput: { hookEventName: 'SessionStart', additionalContext: text } }) + : ''; + + it('appends the blocks to each turn\'s system prompt, asking hook-dispatch once per session', async () => { + const { on, dispatches } = loadOmpExtension(context('TEAM-BLOCKS')); + await on.session_start({}, ctx); + const first = await on.before_agent_start({ prompt: 'one', systemPrompt: ['BASE'] }, ctx); + const second = await on.before_agent_start({ prompt: 'two', systemPrompt: ['BASE'] }, ctx); + expect(first).toEqual({ systemPrompt: ['BASE', 'TEAM-BLOCKS'] }); + expect(second).toEqual({ systemPrompt: ['BASE', 'TEAM-BLOCKS'] }); + expect(dispatches.filter((d) => d.args[1] === 'instructions')).toHaveLength(1); + expect(dispatches.find((d) => d.args[1] === 'instructions')?.payload).toEqual({ cwd: '/work/proj/src', session_id: 'omp-main' }); + }); + + it('waits for session-start HTTP sync before reading the first prompt cache', async () => { + let synced = false; + const { on } = loadOmpExtension((args) => { + if (args[1] === 'session-start') synced = true; + return args[1] === 'instructions' ? context(synced ? 'FRESH-HTTP-PROMPT' : '')(args) : ''; + }); + await on.session_start({}, ctx); + expect(await on.before_agent_start({ prompt: 'one', systemPrompt: ['BASE'] }, ctx)) + .toEqual({ systemPrompt: ['BASE', 'FRESH-HTTP-PROMPT'] }); + }); + + it('leaves the system prompt alone when there are no blocks (user scope, or teamai unavailable)', async () => { + const { on } = loadOmpExtension(); + await on.session_start({}, ctx); + expect(await on.before_agent_start({ prompt: 'one', systemPrompt: ['BASE'] }, ctx)).toBeUndefined(); + }); +}); + // Recall attribution (#884): the extension evaluated in `vm`, with a fake host. +/** The lifecycle dispatches, without the team-instructions request (#945), which has its own tests. */ +const lifecycle = (dispatches: ExtensionDispatch[]) => dispatches.filter((d) => d.args[1] !== 'instructions'); + describe('OMP extension: bridge payloads (#884)', () => { const main = { cwd: '/work/proj', sessionManager: { getSessionId: () => 'omp-main' }, agent: { kind: 'main' as const, id: 'Main', name: 'main', depth: 0 } }; const sub = { @@ -204,12 +255,12 @@ describe('OMP extension: bridge payloads (#884)', () => { await on.session_start({}, main); await on.before_agent_start({ prompt: 'hi' }, main); await on.session_stop({}, main); - expect(dispatches.map((d) => [d.args[1], d.payload])).toEqual([ + expect(lifecycle(dispatches).map((d) => [d.args[1], d.payload])).toEqual([ ['session-start', { cwd: '/work/proj', session_id: 'omp-main' }], ['prompt-submit', { cwd: '/work/proj', session_id: 'omp-main', prompt: 'hi' }], ['stop', { cwd: '/work/proj', session_id: 'omp-main' }], ]); - expect(dispatches.every((d) => d.args.join(' ').endsWith('--tool omp'))).toBe(true); + expect(lifecycle(dispatches).every((d) => d.args.join(' ').endsWith('--tool omp'))).toBe(true); }); it('sends the text output and the status on post-tool-use', async () => { @@ -218,7 +269,7 @@ describe('OMP extension: bridge payloads (#884)', () => { content: [{ type: 'text', text: 'a.md' }, { type: 'image', data: 'AAAA' }, { type: 'text', text: 'b.md' }], isError: false }, main); await on.tool_result({ type: 'tool_result', toolCallId: 'c2', toolName: 'bash', input: { command: 'false' }, content: [{ type: 'text', text: 'Command exited with code 1' }], isError: true }, main); - expect(dispatches.map((d) => d.payload)).toEqual([ + expect(lifecycle(dispatches).map((d) => d.payload)).toEqual([ { cwd: '/work/proj', session_id: 'omp-main', tool_name: 'bash', tool_input: { command: 'ls' }, tool_response: 'a.md\nb.md', tool_status: 'success' }, { cwd: '/work/proj', session_id: 'omp-main', tool_name: 'bash', tool_input: { command: 'false' }, tool_response: 'Command exited with code 1', tool_status: 'failure' }, ]); @@ -229,7 +280,7 @@ describe('OMP extension: bridge payloads (#884)', () => { await on.session_start({}, sub); await on.tool_result({ type: 'tool_result', toolCallId: 'c1', toolName: 'read', input: { path: 'x.md' }, content: [], isError: false }, sub); await on.session_stop({}, sub); - expect(dispatches.map((d) => d.payload)).toEqual([ + expect(lifecycle(dispatches).map((d) => d.payload)).toEqual([ { cwd: '/work/proj', session_id: 'omp-sub', agent_id: '0-TeamaiRecall', agent_type: 'teamai-recall' }, { cwd: '/work/proj', session_id: 'omp-sub', agent_id: '0-TeamaiRecall', agent_type: 'teamai-recall', tool_name: 'read', tool_input: { path: 'x.md' }, tool_response: '', tool_status: 'success' }, { cwd: '/work/proj', session_id: 'omp-sub', agent_id: '0-TeamaiRecall', agent_type: 'teamai-recall' }, @@ -240,7 +291,7 @@ describe('OMP extension: bridge payloads (#884)', () => { const { on, dispatches } = loadOmpExtension(); await on.session_start({}, { cwd: '/work/proj', sessionManager: { getSessionId: () => 'omp-old' } }); await on.tool_result({ toolName: 'read', input: { path: 'x.md' } }, { cwd: '/work/proj' }); - expect(dispatches.map((d) => d.payload)).toEqual([ + expect(lifecycle(dispatches).map((d) => d.payload)).toEqual([ { cwd: '/work/proj', session_id: 'omp-old' }, { cwd: '/work/proj', tool_name: 'read', tool_input: { path: 'x.md' }, tool_status: 'unknown' }, ]); diff --git a/src/__tests__/pi-hooks.test.ts b/src/__tests__/pi-hooks.test.ts index 8261442fb..219d4ae27 100644 --- a/src/__tests__/pi-hooks.test.ts +++ b/src/__tests__/pi-hooks.test.ts @@ -53,7 +53,7 @@ import { import { reconcileHooksToAllTools } from '../hooks.js'; import { log } from '../utils/logger.js'; import type { HookDef } from '../types.js'; -import { loadPiExtension } from './helpers/pi-extensions.js'; +import { loadPiExtension, type ExtensionDispatch } from './helpers/pi-extensions.js'; describe('Pi hook extension', () => { let tmp: string; @@ -367,6 +367,45 @@ describe('Pi hook extension', () => { }); // Recall attribution (#884): the extension evaluated in `vm`, with a fake host. +/** The lifecycle dispatches, without the team-instructions request (#945), which has its own tests. */ +const lifecycle = (dispatches: ExtensionDispatch[]) => dispatches.filter((d) => d.args[1] !== 'instructions'); + +// Team instructions (#945): the extension adds hook-dispatch's context to the prompt. +describe('Pi extension: team instructions in the system prompt (#945)', () => { + const ctx = { cwd: '/work/proj/src', sessionManager: { getSessionId: () => 'pi-sess' } }; + const context = (text: string) => (args: string[]) => args[1] === 'instructions' + ? JSON.stringify({ hookSpecificOutput: { hookEventName: 'SessionStart', additionalContext: text } }) + : ''; + + it('appends the blocks to each run\'s system prompt, asking hook-dispatch once per session', async () => { + const { on, dispatches } = loadPiExtension(context('TEAM-BLOCKS')); + await on.session_start({}, ctx); + const first = await on.before_agent_start({ prompt: 'one', systemPrompt: 'BASE' }, ctx); + const second = await on.before_agent_start({ prompt: 'two', systemPrompt: 'BASE' }, ctx); + expect(first).toEqual({ systemPrompt: 'BASE\n\nTEAM-BLOCKS' }); + expect(second).toEqual({ systemPrompt: 'BASE\n\nTEAM-BLOCKS' }); + expect(dispatches.filter((d) => d.args[1] === 'instructions')).toHaveLength(1); + expect(dispatches.find((d) => d.args[1] === 'instructions')?.payload).toEqual({ cwd: '/work/proj/src', session_id: 'pi-sess' }); + }); + + it('leaves the system prompt alone when there are no blocks (user scope, or teamai unavailable)', async () => { + const { on } = loadPiExtension(); + await on.session_start({}, ctx); + expect(await on.before_agent_start({ prompt: 'one', systemPrompt: 'BASE' }, ctx)).toBeUndefined(); + }); + + it('waits for session-start HTTP sync before reading the first prompt cache', async () => { + let synced = false; + const { on } = loadPiExtension((args) => { + if (args[1] === 'session-start') synced = true; + return args[1] === 'instructions' ? context(synced ? 'FRESH-HTTP-PROMPT' : '')(args) : ''; + }); + await on.session_start({}, ctx); + expect(await on.before_agent_start({ prompt: 'one', systemPrompt: 'BASE' }, ctx)) + .toEqual({ systemPrompt: 'BASE\n\nFRESH-HTTP-PROMPT' }); + }); +}); + describe('Pi extension: bridge payloads (#884)', () => { const ctx = { cwd: '/work/proj', sessionManager: { getSessionId: () => 'pi-sess' } }; @@ -375,12 +414,12 @@ describe('Pi extension: bridge payloads (#884)', () => { await on.session_start({}, ctx); await on.before_agent_start({ prompt: 'hi' }, ctx); await on.agent_settled({}, ctx); - expect(dispatches.map((d) => [d.args[1], d.payload])).toEqual([ + expect(lifecycle(dispatches).map((d) => [d.args[1], d.payload])).toEqual([ ['session-start', { cwd: '/work/proj', session_id: 'pi-sess' }], ['prompt-submit', { cwd: '/work/proj', session_id: 'pi-sess', prompt: 'hi' }], ['stop', { cwd: '/work/proj', session_id: 'pi-sess' }], ]); - expect(dispatches.every((d) => d.args.join(' ').endsWith('--tool pi'))).toBe(true); + expect(lifecycle(dispatches).every((d) => d.args.join(' ').endsWith('--tool pi'))).toBe(true); }); it('sends the cached input, the text output and the status on post-tool-use', async () => { @@ -392,7 +431,7 @@ describe('Pi extension: bridge payloads (#884)', () => { await end('c1', false, [{ type: 'text', text: 'line one' }, { type: 'image', data: 'AAAA' }, { type: 'text', text: 'line two' }]); await end('c2', true, [{ type: 'text', text: 'cat: x.md: No such file\n\nCommand exited with code 1' }]); await end('c3', undefined, []); - expect(dispatches.map((d) => d.payload)).toEqual([ + expect(lifecycle(dispatches).map((d) => d.payload)).toEqual([ { cwd: '/work/proj', session_id: 'pi-sess', tool_name: 'bash', tool_input: { command: 'cat x.md' }, tool_response: 'line one\nline two', tool_status: 'success' }, { cwd: '/work/proj', session_id: 'pi-sess', tool_name: 'bash', tool_input: { command: 'cat x.md' }, tool_response: 'cat: x.md: No such file\n\nCommand exited with code 1', tool_status: 'failure' }, { cwd: '/work/proj', session_id: 'pi-sess', tool_name: 'bash', tool_input: { command: 'cat x.md' }, tool_response: '', tool_status: 'unknown' }, @@ -403,7 +442,7 @@ describe('Pi extension: bridge payloads (#884)', () => { const { on, dispatches } = loadPiExtension(); await on.session_start({}, { cwd: '/work/proj' }); await on.tool_execution_end({ toolCallId: 'c1', toolName: 'read' }, { cwd: '/work/proj' }); - expect(dispatches.map((d) => d.payload)).toEqual([ + expect(lifecycle(dispatches).map((d) => d.payload)).toEqual([ { cwd: '/work/proj' }, { cwd: '/work/proj', tool_name: 'read', tool_input: {}, tool_status: 'unknown' }, ]); diff --git a/src/__tests__/pull-skip-sync.test.ts b/src/__tests__/pull-skip-sync.test.ts index f2ae089d4..69d13199c 100644 --- a/src/__tests__/pull-skip-sync.test.ts +++ b/src/__tests__/pull-skip-sync.test.ts @@ -689,7 +689,7 @@ describe('pull skip-sync refreshes CLAUDE.md recall block (CLI upgrade)', () => expect(updated.split(TEAMAI_RECALL_RULES_END).length - 1).toBe(1); }); - it('does not touch CLAUDE.md when recall is disabled for the scope', async () => { + it('removes the recall block when recall is disabled for the scope (#945)', async () => { vi.mocked(loadLocalConfigForScope).mockResolvedValue({ repo: { localPath: repoPath, remote: 'https://git.woa.com/test/repo.git' }, username: 'testuser', @@ -704,8 +704,8 @@ describe('pull skip-sync refreshes CLAUDE.md recall block (CLI upgrade)', () => await pull({}); const after = await fse.readFile(claudeMdPath, 'utf8'); - // Untouched: the stale block remains exactly as seeded. - expect(after).toContain('you **MUST** first invoke the `teamai-recall` subagent'); + expect(after).not.toContain(TEAMAI_RECALL_RULES_START); + expect(after).toContain('# CLAUDE.md'); }); it('refreshes Copilot recall instructions under a custom COPILOT_HOME', async () => { @@ -1035,8 +1035,7 @@ describe('enabledAgents whitelist on pull inject, skip-sync, and cleanup (#510)' await pull({ silent: true }); - expect(log.warn).toHaveBeenCalledWith(expect.stringContaining('Failed to inject culture into copilot')); - expect(log.warn).toHaveBeenCalledWith(expect.stringContaining('Failed to inject shared instructions into copilot')); + expect(log.warn).toHaveBeenCalledWith(expect.stringContaining(`Could not update ${instructionPath}`)); expect((await fse.stat(instructionPath)).isDirectory()).toBe(true); }); diff --git a/src/__tests__/pull-sync-truth.test.ts b/src/__tests__/pull-sync-truth.test.ts index e4d119159..fe466f140 100644 --- a/src/__tests__/pull-sync-truth.test.ts +++ b/src/__tests__/pull-sync-truth.test.ts @@ -143,6 +143,33 @@ describe('pull reports what reached the tool directory (#585)', () => { return vi.mocked(log.success).mock.calls.map(([msg]) => String(msg)); } + it('retains unresolved culture during migration while retiring delivered instruction blocks (#945)', async () => { + const projectRoot = path.join(tmpDir, 'project'); + localConfig.scope = 'project'; + localConfig.projectRoot = projectRoot; + localConfig.enabledAgents = ['claude']; + teamConfig.toolPaths.claude.claudemd = '.claude/CLAUDE.md'; + vi.mocked(detectProjectConfig).mockResolvedValue(localConfig); + const legacy = path.join(projectRoot, '.claude/CLAUDE.md'); + await fse.outputFile(legacy, '# Mine\n\nworking culture\n\n\nold prompt\n\n'); + // A directory makes the read fail deterministically, including as root. + await fse.ensureDir(path.join(repoPath, 'culture.md')); + await fse.outputFile(path.join(repoPath, 'claudemd', 'shared.md'), 'new prompt'); + + await pull({ force: true }); + + const retained = await fse.readFile(legacy, 'utf8'); + expect(retained).toContain('working culture'); + expect(retained).not.toContain('old prompt'); + expect(await fse.readFile(path.join(projectRoot, '.claude/rules/teamai-context.md'), 'utf8')).toContain('new prompt'); + + await fse.remove(path.join(repoPath, 'culture.md')); + await fse.writeFile(path.join(repoPath, 'culture.md'), '---\ncompany:\n name: Acme\n---\n\nrestored culture'); + await pull({ force: true }); + expect(await fse.readFile(legacy, 'utf8')).toBe('# Mine\n'); + expect(await fse.readFile(path.join(projectRoot, '.claude/rules/teamai-context.md'), 'utf8')).toContain('restored culture'); + }); + it.each(['copy', 'prune', 'unsafe destination', 'unreadable source', 'realpath'])( 'does not report a successful docs sync after %s fails, and retries on the next pull', async (failure) => { // Simulate a force-pull of an already-synced revision: failure must clear diff --git a/src/__tests__/pull-tombstone.test.ts b/src/__tests__/pull-tombstone.test.ts index 1dad43a9d..9c8da00c8 100644 --- a/src/__tests__/pull-tombstone.test.ts +++ b/src/__tests__/pull-tombstone.test.ts @@ -278,6 +278,59 @@ describe('pull role-aware sync and cleanup', () => { await expectAgentRendersGone(); }); + it('keeps teamai\'s instruction file when the team tombstoned a rule named teamai-context (#945)', async () => { + const base = (await loadTeamConfig(repoPath))!; + vi.mocked(loadTeamConfig).mockResolvedValue({ ...base, toolPaths: { cursor: { skills: '.cursor/skills', rules: '.cursor/rules' } } }); + await fse.ensureDir(path.join(homeDir, '.cursor', 'skills')); + await fse.writeFile(path.join(repoPath, 'culture.md'), 'Be kind.\n'); + await fse.writeFile(path.join(repoPath, 'rules', '.removed'), 'teamai-context\n'); + const context = path.join(homeDir, '.cursor', 'rules', 'teamai-context.mdc'); + + await pull({}); + expect(await fse.readFile(context, 'utf8')).toContain('Be kind.'); + vi.mocked(loadStateForScope).mockImplementation(async () => ({ + lastPull: null, + lastPullRev: HEAD_REV, + lastPullTargets: ['cursor'], + }) as Awaited>); + await pull({}); + + expect(await fse.readFile(context, 'utf8')).toContain('Be kind.'); + }); + + it('reclaims an earlier release\'s teamai-context rule copy when the revision is unchanged (#945)', async () => { + const base = (await loadTeamConfig(repoPath))!; + vi.mocked(loadTeamConfig).mockResolvedValue({ ...base, toolPaths: { claude: { skills: '.claude/skills', rules: '.claude/rules' } } }); + await fse.writeFile(path.join(repoPath, 'culture.md'), 'Be kind.\n'); + await fse.writeFile(path.join(repoPath, 'rules', 'teamai-context.md'), '# Old team rule\n'); + const context = path.join(homeDir, '.claude', 'rules', 'teamai-context.md'); + await fse.copy(path.join(repoPath, 'rules', 'teamai-context.md'), context); + vi.mocked(loadStateForScope).mockImplementation(async () => ({ + lastPull: null, + lastPullRev: HEAD_REV, + lastPullTargets: ['claude'], + }) as Awaited>); + + await pull({}); + + expect(await fse.pathExists(context)).toBe(false); + }); + + it('reports no synced culture when every instruction target was left alone (#945)', async () => { + const { log } = await import('../utils/logger.js'); + const base = (await loadTeamConfig(repoPath))!; + vi.mocked(loadTeamConfig).mockResolvedValue({ ...base, toolPaths: { cursor: { skills: '.cursor/skills', rules: '.cursor/rules' } } }); + await fse.ensureDir(path.join(homeDir, '.cursor', 'skills')); + await fse.writeFile(path.join(repoPath, 'culture.md'), 'Be kind.\n'); + // A member's own file holds teamai's target path. + await fse.outputFile(path.join(homeDir, '.cursor', 'rules', 'teamai-context.mdc'), '# Mine\n'); + vi.mocked(log.success).mockClear(); + + await pull({}); + + expect(vi.mocked(log.success).mock.calls.flat()).not.toContain('Synced team culture'); + }); + it('should not delete files that are NOT tombstoned', async () => { // No tombstone files await fse.writeFile(path.join(homeDir, '.claude/rules', 'keep-rule.md'), '# Keep'); diff --git a/src/__tests__/recall-rules.test.ts b/src/__tests__/recall-rules.test.ts index 3d9c30eb2..ab8754a75 100644 --- a/src/__tests__/recall-rules.test.ts +++ b/src/__tests__/recall-rules.test.ts @@ -19,7 +19,7 @@ vi.mock('../utils/logger.js', () => ({ })), })); -import { compileRecallRulesBlock } from '../pull.js'; +import { compileDirectRecallRulesBlock, compileRecallRulesBlock } from '../pull.js'; import { injectClaudeMdSection } from '../utils/claudemd.js'; import { TEAMAI_RECALL_RULES_START, TEAMAI_RECALL_RULES_END } from '../types.js'; @@ -38,6 +38,18 @@ describe('compileRecallRulesBlock', () => { }); }); +describe('compileDirectRecallRulesBlock (#945)', () => { + it('tells a tool without the recall subagent to run teamai recall itself, inside the same markers', () => { + const block = compileDirectRecallRulesBlock(); + expect(block.startsWith(TEAMAI_RECALL_RULES_START)).toBe(true); + expect(block.endsWith(TEAMAI_RECALL_RULES_END)).toBe(true); + expect(block).toContain('teamai recall "'); + expect(block).toMatch(/Before/); + expect(block).not.toContain('teamai-recall'); + expect(block).not.toMatch(/[\u4e00-\u9fff]/); + }); +}); + describe('injectClaudeMdSection โ€” recall rules block lifecycle', () => { let tmpDir: string; let claudeMdPath: string; diff --git a/src/__tests__/recall-toggle.test.ts b/src/__tests__/recall-toggle.test.ts index 8beb33e9f..9027dc23f 100644 --- a/src/__tests__/recall-toggle.test.ts +++ b/src/__tests__/recall-toggle.test.ts @@ -28,6 +28,7 @@ vi.mock('../utils/logger.js', () => ({ })); import { recallDisable, recallEnable } from '../recall-toggle.js'; +import { loadStateForScope, saveStateForScope } from '../config.js'; import { TeamaiConfigSchema, TEAMAI_RECALL_RULES_START } from '../types.js'; import type { LocalConfig, TeamaiConfig } from '../types.js'; @@ -121,6 +122,26 @@ describe('recall toggle native agent cleanup', () => { expect(await fse.readFile(backup, 'utf8')).toBe('user backup'); }); + it('keeps OpenCode registered when malformed recall markers block an edit of a file with other instructions', async () => { + const projectRoot = path.join(tmpDir, 'project'); + const configFile = path.join(projectRoot, '.opencode', 'opencode.json'); + const entry = '.opencode/teamai-context.md'; + const contextFile = path.join(projectRoot, entry); + const original = `\nCulture\n\n${TEAMAI_RECALL_RULES_START}\nIncomplete recall\n`; + await fse.outputFile(contextFile, original); + await fse.outputJson(configFile, { instructions: [entry] }); + const localConfig = { repo: { localPath: path.join(tmpDir, 'team-repo'), remote: 'https://example.invalid/t.git' }, + username: 'u', scope: 'project', projectRoot, enabledAgents: ['opencode'], additionalRoles: [], + } as LocalConfig; + const teamConfig = TeamaiConfigSchema.parse({ team: 't', repo: 'https://example.invalid/t.git' }); + mockAutoDetectInit.mockResolvedValue({ localConfig, teamConfig }); + await saveStateForScope({ ...await loadStateForScope(localConfig), opencodeContextEntries: [{ config: configFile, entry }] }, localConfig); + await recallDisable({}); + expect(await fse.readFile(contextFile, 'utf8')).toBe(original); + expect((await fse.readJson(configFile)).instructions).toEqual([entry]); + expect((await loadStateForScope(localConfig)).opencodeContextEntries).toEqual([{ config: configFile, entry }]); + }); + it('uses custom COPILOT_HOME for recall injection and cleanup', async () => { const copilotHome = path.join(tmpDir, 'copilot-home'); const instructionPath = path.join(copilotHome, 'copilot-instructions.md'); @@ -197,8 +218,8 @@ describe('recall toggle native agent cleanup', () => { // `enabledAgents` (from `teamai init --agent`) is documented as gating the CLI // built-in skills/rules/agents and CLAUDE.md-class injects. recallEnable deploys // all four, but only the first three went through the whitelist โ€” the CLAUDE.md -// recall block was still injected into excluded tools. Same loop and guard as -// injectRecallBlockIntoTools (src/pull.ts). +// recall block was still injected into excluded tools. Same targets as pull +// (resolveInstructionTargets, src/instruction-targets.ts). describe('recall toggle honors the enabledAgents whitelist', () => { let tmpDir: string; let homeDir: string; diff --git a/src/__tests__/rules.test.ts b/src/__tests__/rules.test.ts index 6f7991a95..3c752ba7c 100644 --- a/src/__tests__/rules.test.ts +++ b/src/__tests__/rules.test.ts @@ -774,6 +774,22 @@ scope: 'user', expect(await fse.pathExists(path.join(localRulesDir, 'fe-know/my-rule.md'))).toBe(false); }); + it('keeps a placed rule named teamai-context at its namespaced path, off teamai\'s instruction file (#945)', async () => { + const teamRulesDir = path.join(localConfig.repo.localPath, 'rules'); + await fse.outputFile(path.join(teamRulesDir, 'fe-know/teamai-context.md'), 'the author\'s namespaced rule'); + 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: { 'teamai-context': 'rules/fe-know/teamai-context.md' }, + } as State); + + await handler.pullAllRules(teamConfig, localConfig); + + expect(await fse.pathExists(path.join(localRulesDir, 'teamai-context.md'))).toBe(false); + expect(await fse.readFile(path.join(localRulesDir, 'fe-know/teamai-context.md'), 'utf-8')).toBe('the author\'s namespaced rule'); + }); + 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'); @@ -971,6 +987,27 @@ scope: 'user', expect(await fse.pathExists(path.join(localRulesDir, 'teamai-recall.md'))).toBe(true); expect(await fse.pathExists(path.join(localRulesDir, 'old-user-rule.md'))).toBe(false); }); + + it.each([[['team-rule.md']], [[]]])('removes the copy of a team rule named teamai-context an earlier release delivered, and keeps teamai\'s own context file (other team rules: %j) (#945)', async (others) => { + const teamRulesDir = path.join(localConfig.repo.localPath, 'rules'); + for (const other of others) await fse.writeFile(path.join(teamRulesDir, other), 'team content'); + await fse.writeFile(path.join(teamRulesDir, 'teamai-context.md'), 'RESERVED-RULE'); + const localRulesDir = path.join(homeDir, '.claude/rules'); + const context = path.join(localRulesDir, 'teamai-context.md'); + + await fse.writeFile(context, 'RESERVED-RULE'); + await handler.pullAllRules(teamConfig, localConfig); + expect(await fse.pathExists(context)).toBe(false); + + const blocks = '\nShared.\n\n'; + await fse.writeFile(context, blocks); + await handler.pullAllRules(teamConfig, localConfig); + expect(await fse.readFile(context, 'utf8')).toBe(blocks); + + await fse.writeFile(context, 'RESERVED-RULE, edited by the member'); + await handler.pullAllRules(teamConfig, localConfig); + expect(await fse.readFile(context, 'utf8')).toBe('RESERVED-RULE, edited by the member'); + }); }); describe('RulesHandler.pullAllRules โ€” OpenCode instructions activation', () => { diff --git a/src/__tests__/uninstall.test.ts b/src/__tests__/uninstall.test.ts index 20d26ee83..6dc49ce6e 100644 --- a/src/__tests__/uninstall.test.ts +++ b/src/__tests__/uninstall.test.ts @@ -472,11 +472,38 @@ describe('uninstall', () => { expect(bashrcAfter).not.toContain(TEAMAI_ENV_START); }); - it('project-scope Pi uninstall also removes the global extension', async () => { - // Mirrors OMP: Pi has no way to scope its single shared extension to one - // project โ€” the generated extension fires for every Pi session - // machine-wide โ€” so a scoped uninstall removes it outright rather than - // preserving a file that would keep firing for this project anyway. + it.each(['pi', 'omp', 'hermes', 'codex', 'codex-internal', 'tcodex'])('gates empty-plan %s project exclusion on dry-run and confirmation', async (tool) => { + const homeDir = path.join(tmpDir, 'home'); + const projectRoot = path.join(tmpDir, 'empty-project'); + const repoPath = path.join(projectRoot, '.teamai', 'team-repo'); + await fse.ensureDir(repoPath); + vi.stubEnv('HOME', homeDir); + vi.stubEnv('HERMES_HOME', path.join(homeDir, '.hermes')); + const teamConfig = TeamaiConfigSchema.parse({ team: 't', repo: 'https://example.invalid/t.git' }); + const localConfig = makeLocalConfig(homeDir, repoPath, { scope: 'project', projectRoot, enabledAgents: [tool] }); + mockAutoDetectInit.mockResolvedValue({ localConfig, teamConfig }); + const prompts = await import('../utils/prompt.js'); + const confirm = vi.spyOn(prompts, 'askConfirmation').mockResolvedValue(false); + try { + await uninstall({ agent: tool, dryRun: true }); + expect(mockSaveLocalConfigForScope).not.toHaveBeenCalled(); + expect(confirm).not.toHaveBeenCalled(); + expect(localConfig.disabledAgents).toBeUndefined(); + + await uninstall({ agent: tool }); + expect(confirm).toHaveBeenCalledOnce(); + expect(mockSaveLocalConfigForScope).not.toHaveBeenCalled(); + expect(localConfig.enabledAgents).toEqual([tool]); + + confirm.mockResolvedValue(true); + await uninstall({ agent: tool }); + expect(mockSaveLocalConfigForScope).toHaveBeenCalledWith(expect.objectContaining({ disabledAgents: [tool], enabledAgents: [] }), 'project', projectRoot); + } finally { + confirm.mockRestore(); + } + }); + + it('project-scope Pi uninstall preserves the global extension and removes a legacy project copy', async () => { const { homeDir, repoPath } = await setupFixture(tmpDir); const projectRoot = path.join(tmpDir, 'business-repo'); vi.stubEnv('HOME', homeDir); @@ -513,7 +540,175 @@ describe('uninstall', () => { await uninstall({ force: true, agent: 'pi' }); expect(await fse.pathExists(projectPiHook)).toBe(false); - expect(await fse.pathExists(globalPiHook)).toBe(false); + expect(await fse.readFile(globalPiHook, 'utf8')).toBe(TEAMAI_PI_HOOK); + }); + + it('keeps a tracked retired configured claudemd named teamai-context.md as a member file', async () => { + const projectRoot = path.join(tmpDir, 'member-file-project'); + const file = path.join(projectRoot, '.claude', 'teamai-context.md'); + await fse.outputFile(file, `${TEAMAI_CULTURE_START}\nold culture\n${TEAMAI_CULTURE_END}\n`); + execFileSync('git', ['init', '-q'], { cwd: projectRoot }); + execFileSync('git', ['add', '.claude/teamai-context.md'], { cwd: projectRoot }); + const localConfig = makeLocalConfig(projectRoot, path.join(projectRoot, '.teamai', 'team-repo'), { scope: 'project', projectRoot }); + const teamConfig = makeTeamConfig({ toolPaths: { claude: { + skills: '.claude/skills', rules: '.claude/rules', claudemd: '.claude/teamai-context.md', + } } }); + mockAutoDetectInit.mockResolvedValue({ localConfig, teamConfig }); + await uninstall({ force: true, agent: 'claude' }); + expect(await fse.readFile(file, 'utf8')).toBe(''); + }); + + it.each([['omp', true], ['omp', false], ['hermes', true], ['hermes', false]])( + 'project uninstall keeps %s global delivery, targeted: %s', async (tool, targeted) => { + const { homeDir, repoPath } = await setupFixture(tmpDir); + const projectRoot = path.join(tmpDir, 'business-repo'); + vi.stubEnv('HOME', homeDir); + vi.stubEnv('HERMES_HOME', path.join(homeDir, '.hermes')); + await fse.ensureDir(projectRoot); + let globalFile: string; + let configBefore: string | undefined; + if (tool === 'omp') { + const { injectOmpHooks, resolveOmpExtensionsDir, OMP_HOOK_FILE } = await import('../omp-hooks.js'); + await injectOmpHooks(); + globalFile = path.join(resolveOmpExtensionsDir(), OMP_HOOK_FILE); + } else { + const { injectHermesHooks, getInstructionsPluginDir } = await import('../hermes-hooks.js'); + await injectHermesHooks(); + globalFile = path.join(getInstructionsPluginDir(), '__init__.py'); + configBefore = await fse.readFile(path.join(homeDir, '.hermes', 'config.yaml'), 'utf8'); + } + const before = await fse.readFile(globalFile, 'utf8'); + const teamConfig = TeamaiConfigSchema.parse({ team: 't', repo: 'https://example.invalid/t.git' }); + const localConfig = makeLocalConfig(homeDir, repoPath, { scope: 'project', projectRoot }); + mockAutoDetectInit.mockResolvedValue({ localConfig, teamConfig }); + await fse.writeFile(path.join(projectRoot, 'AGENTS.md'), `${TEAMAI_CULTURE_START}\nold\n${TEAMAI_CULTURE_END}\n`); + await uninstall({ force: true, ...(targeted ? { agent: tool as string } : {}) }); + expect(await fse.readFile(globalFile, 'utf8')).toBe(before); + if (configBefore) expect(await fse.readFile(path.join(homeDir, '.hermes', 'config.yaml'), 'utf8')).toBe(configBefore); + }, + ); + + it.each(['pi', 'omp', 'hermes'])('excludes %s from a project with no local files to delete', async (tool) => { + const homeDir = path.join(tmpDir, 'home'); + const projectRoot = path.join(tmpDir, 'project'); + const repoPath = path.join(projectRoot, '.teamai', 'team-repo'); + await fse.ensureDir(repoPath); + vi.stubEnv('HOME', homeDir); + vi.stubEnv('HERMES_HOME', path.join(homeDir, '.hermes')); + const teamConfig = TeamaiConfigSchema.parse({ team: 't', repo: 'https://example.invalid/t.git' }); + const localConfig = makeLocalConfig(homeDir, repoPath, { scope: 'project', projectRoot }); + mockAutoDetectInit.mockResolvedValue({ localConfig, teamConfig }); + await uninstall({ force: true, agent: tool }); + expect(mockSaveLocalConfigForScope).toHaveBeenCalledWith(expect.objectContaining({ disabledAgents: [tool] }), 'project', projectRoot); + expect(await fse.pathExists(repoPath)).toBe(true); + }); + + it.each(['codex', 'codex-internal', 'tcodex'].flatMap((tool) => [[tool, false], [tool, true]] as const))( + 'preserves global %s hooks while excluding only the targeted project, legacy copy: %s', async (tool, legacy) => { + const homeDir = path.join(tmpDir, 'home'); + const projectRoot = path.join(tmpDir, 'project'); + const repoPath = path.join(projectRoot, '.teamai/team-repo'); + vi.stubEnv('HOME', homeDir); + const actualHooks = await vi.importActual('../hooks.js'); + mockReconcileHooks.mockImplementation(actualHooks.reconcileHooks); + const globalHooks = path.join(homeDir, `.${tool}/hooks.json`); + await actualHooks.reconcileHooks(globalHooks, tool, []); + const before = await fse.readFile(globalHooks, 'utf8'); + const localConfig = makeLocalConfig(homeDir, repoPath, { scope: 'project', projectRoot, enabledAgents: [tool] }); + await fse.outputFile(path.join(projectRoot, '.teamai/config.yaml'), 'scope: project\n'); + const teamConfig = TeamaiConfigSchema.parse({ team: 't', repo: 'https://example.invalid/t.git' }); + mockAutoDetectInit.mockResolvedValue({ localConfig, teamConfig }); + const projectHooks = path.join(projectRoot, `.${tool}/hooks.json`); + if (legacy) await actualHooks.reconcileHooks(projectHooks, tool, [], { manifestPath: path.join(projectRoot, '.teamai/managed-hooks.json') }); + + await uninstall({ force: true, agent: tool }); + + expect(await fse.readFile(globalHooks, 'utf8')).toBe(before); + expect(await fse.pathExists(path.join(projectRoot, '.teamai/config.yaml'))).toBe(true); + expect(mockSaveLocalConfigForScope).toHaveBeenCalledWith(expect.objectContaining({ disabledAgents: [tool], enabledAgents: [] }), 'project', projectRoot); + if (legacy) expect(await actualHooks.hasTeamaiHooks(projectHooks, tool)).toBe(false); + }); + + it.each(['pi', 'omp', 'hermes', 'codex'])('keeps global %s delivery and names it with the command that removes it', async (tool) => { + const homeDir = path.join(tmpDir, 'home'); + const projectRoot = path.join(tmpDir, 'only-project'); + const repoPath = path.join(projectRoot, '.teamai', 'team-repo'); + vi.stubEnv('HOME', homeDir); + vi.stubEnv('HERMES_HOME', path.join(homeDir, '.hermes')); + vi.stubEnv('SHELL', '/bin/zsh'); + await fse.ensureDir(repoPath); + const actualHooks = await vi.importActual('../hooks.js'); + mockReconcileHooks.mockImplementation(actualHooks.reconcileHooks); + let kept: string; + if (tool === 'pi') { + kept = path.join(homeDir, '.pi', 'agent', 'extensions', 'teamai-hooks.ts'); + await fse.outputFile(kept, TEAMAI_PI_HOOK); + } else if (tool === 'omp') { + const { injectOmpHooks, resolveOmpExtensionsDir, OMP_HOOK_FILE } = await import('../omp-hooks.js'); + await injectOmpHooks(); + kept = path.join(resolveOmpExtensionsDir(), OMP_HOOK_FILE); + } else if (tool === 'hermes') { + const { injectHermesHooks, getInstructionsPluginDir } = await import('../hermes-hooks.js'); + await injectHermesHooks(); + kept = getInstructionsPluginDir(); + } else { + kept = path.join(homeDir, '.codex/hooks.json'); + await actualHooks.reconcileHooks(kept, 'codex', []); + } + const teamConfig = TeamaiConfigSchema.parse({ team: 't', repo: 'https://example.invalid/t.git' }); + const localConfig = makeLocalConfig(homeDir, repoPath, { scope: 'project', projectRoot }); + mockAutoDetectInit.mockResolvedValue({ localConfig, teamConfig }); + + const printed: string[] = []; + const logSpy = vi.spyOn(console, 'log').mockImplementation((line?: unknown) => { printed.push(String(line ?? '')); }); + try { + await uninstall({ force: true }); + } finally { + logSpy.mockRestore(); + } + + expect(await fse.pathExists(kept)).toBe(true); + if (tool === 'codex') expect(await actualHooks.hasTeamaiHooks(kept, 'codex')).toBe(true); + const summary = printed.join('\n'); + expect(summary).toContain(` ${kept}`); + expect(summary).toContain('run `teamai hooks remove` here first'); + }); + + it('still removes global Codex hooks on a user-scope uninstall', async () => { + const homeDir = path.join(tmpDir, 'home'); + const repoPath = path.join(homeDir, '.teamai/team-repo'); + vi.stubEnv('HOME', homeDir); + const actualHooks = await vi.importActual('../hooks.js'); + mockReconcileHooks.mockImplementation(actualHooks.reconcileHooks); + const globalHooks = path.join(homeDir, '.codex/hooks.json'); + await actualHooks.reconcileHooks(globalHooks, 'codex', []); + const localConfig = makeLocalConfig(homeDir, repoPath, { enabledAgents: ['codex'] }); + const teamConfig = TeamaiConfigSchema.parse({ team: 't', repo: 'https://example.invalid/t.git' }); + mockAutoDetectInit.mockResolvedValue({ localConfig, teamConfig }); + await uninstall({ force: true, agent: 'codex' }); + expect(await actualHooks.hasTeamaiHooks(globalHooks, 'codex')).toBe(false); + }); + + it.each(['pi', 'omp', 'hermes', 'codex'])('keeps project state when WorkBuddy is removed and global %s remains', async (tool) => { + const homeDir = path.join(tmpDir, 'home'); + const projectRoot = path.join(tmpDir, 'project'); + const repoPath = path.join(projectRoot, '.teamai', 'team-repo'); + vi.stubEnv('HOME', homeDir); + vi.stubEnv('HERMES_HOME', path.join(homeDir, '.hermes')); + await fse.ensureDir(path.join(homeDir, `.${tool}`)); + await fse.outputFile(path.join(projectRoot, '.codebuddy/rules/teamai-context.md'), `${TEAMAI_CLAUDEMD_START}\nold prompt\n${TEAMAI_CLAUDEMD_END}\n`); + await fse.ensureDir(path.join(projectRoot, '.workbuddy')); + const configFile = path.join(projectRoot, '.teamai', 'config.yaml'); + await fse.outputFile(configFile, 'scope: project\n'); + const teamConfig = TeamaiConfigSchema.parse({ team: 't', repo: 'https://example.invalid/t.git' }); + const localConfig = makeLocalConfig(homeDir, repoPath, { scope: 'project', projectRoot, enabledAgents: ['workbuddy', tool] }); + mockAutoDetectInit.mockResolvedValue({ localConfig, teamConfig }); + + await uninstall({ force: true, agent: 'workbuddy' }); + + expect(await fse.pathExists(configFile)).toBe(true); + expect(mockSaveLocalConfigForScope).toHaveBeenCalledWith(expect.objectContaining({ enabledAgents: [tool], disabledAgents: ['workbuddy'] }), 'project', projectRoot); + expect(await fse.pathExists(path.join(projectRoot, '.codebuddy/rules/teamai-context.md'))).toBe(false); }); it('user-scope Pi uninstall removes a server-pushed agent hook even without the main extension', async () => { @@ -584,54 +779,93 @@ describe('uninstall', () => { expect(await fse.readFile(projectPiHook, 'utf8')).toBe('// user-owned extension'); }); - it('targeted Pi uninstall preserves a shared AGENTS.md used by another installed agent', async () => { + it('targeted WorkBuddy uninstall keeps the .codebuddy rule CodeBuddy still reads (#945)', async () => { const { homeDir, repoPath } = await setupFixture(tmpDir); const projectRoot = path.join(tmpDir, 'business-repo'); vi.stubEnv('HOME', homeDir); vi.stubEnv('SHELL', '/bin/zsh'); - const sharedInstructions = path.join(projectRoot, 'AGENTS.md'); - const projectPiHook = path.join(projectRoot, '.pi', 'extensions', 'teamai-hooks.ts'); - await fse.ensureDir(path.dirname(projectPiHook)); - // WorkBuddy is actually installed here (its own resource dir exists), - // not just present in toolPaths โ€” see the sibling "not installed" test. + // CodeBuddy and WorkBuddy share one project instruction file. + const sharedInstructions = path.join(projectRoot, '.codebuddy', 'rules', 'teamai-context.md'); + await fse.ensureDir(path.join(projectRoot, '.codebuddy', 'skills')); await fse.ensureDir(path.join(projectRoot, '.workbuddy', 'skills')); - // A block WorkBuddy's pull writes: the legacy rules block is kept for nobody. - await fse.writeFile(sharedInstructions, `${TEAMAI_CULTURE_START}\nteam culture\n${TEAMAI_CULTURE_END}\n`); - await fse.writeFile(projectPiHook, TEAMAI_PI_HOOK); + await fse.ensureDir(path.dirname(sharedInstructions)); + await fse.writeFile(sharedInstructions, `---\nalwaysApply: true\n---\n\n${TEAMAI_CULTURE_START}\nculture\n${TEAMAI_CULTURE_END}\n`); const teamConfig = makeTeamConfig({ toolPaths: { - pi: { - skills: '.pi/skills', - rules: '.pi/rules', - claudemd: 'AGENTS.md', - userScope: { - skills: '.pi/agent/skills', - rules: '.pi/agent/rules', - claudemd: '.pi/agent/AGENTS.md', - }, - }, - workbuddy: { - skills: '.workbuddy/skills', - rules: '.workbuddy/rules', - settings: '.workbuddy/settings.json', - claudemd: 'AGENTS.md', - }, + codebuddy: { skills: '.codebuddy/skills', rules: '.codebuddy/rules', settings: '.codebuddy/settings.json', claudemd: '.codebuddy/CODEBUDDY.md' }, + workbuddy: { skills: '.workbuddy/skills', rules: '.workbuddy/rules', settings: '.workbuddy/settings.json', claudemd: 'AGENTS.md' }, }, }); const localConfig = makeLocalConfig(homeDir, repoPath, { scope: 'project', projectRoot, - enabledAgents: ['pi', 'workbuddy'], + enabledAgents: ['codebuddy', 'workbuddy'], repo: { localPath: repoPath, remote: '', kind: 'self', businessRepoRoot: projectRoot }, }); mockAutoDetectInit.mockResolvedValue({ localConfig, teamConfig }); - await uninstall({ force: true, agent: 'pi' }); + await uninstall({ force: true, agent: 'workbuddy' }); + + expect(await fse.readFile(sharedInstructions, 'utf8')).toContain(TEAMAI_CULTURE_START); + }); + + it('targeted CodeBuddy uninstall keeps the shared rule when the team still has a rule named teamai-context (#945)', async () => { + const { homeDir, repoPath } = await setupFixture(tmpDir); + const projectRoot = path.join(tmpDir, 'business-repo'); + vi.stubEnv('HOME', homeDir); + vi.stubEnv('SHELL', '/bin/zsh'); + await fse.writeFile(path.join(repoPath, 'rules', 'teamai-context.md'), '# Old team rule\n'); + const sharedInstructions = path.join(projectRoot, '.codebuddy', 'rules', 'teamai-context.md'); + await fse.ensureDir(path.join(projectRoot, '.codebuddy', 'skills')); + await fse.ensureDir(path.join(projectRoot, '.workbuddy', 'skills')); + await fse.outputFile(sharedInstructions, `---\nalwaysApply: true\n---\n\n${TEAMAI_CULTURE_START}\nculture\n${TEAMAI_CULTURE_END}\n`); + const teamConfig = makeTeamConfig({ + toolPaths: { + codebuddy: { skills: '.codebuddy/skills', rules: '.codebuddy/rules' }, + workbuddy: { skills: '.workbuddy/skills', rules: '.workbuddy/rules' }, + }, + }); + const localConfig = makeLocalConfig(homeDir, repoPath, { + scope: 'project', + projectRoot, + enabledAgents: ['codebuddy', 'workbuddy'], + repo: { localPath: repoPath, remote: '', kind: 'self', businessRepoRoot: projectRoot }, + }); + mockAutoDetectInit.mockResolvedValue({ localConfig, teamConfig }); + + await uninstall({ force: true, agent: 'codebuddy' }); + + expect(await fse.readFile(sharedInstructions, 'utf8')).toContain(TEAMAI_CULTURE_START); + }); + + it('targeted CodeBuddy uninstall keeps the shared .codebuddy rule for a WorkBuddy entry without claudemd (#945)', async () => { + const { homeDir, repoPath } = await setupFixture(tmpDir); + const projectRoot = path.join(tmpDir, 'business-repo'); + vi.stubEnv('HOME', homeDir); + vi.stubEnv('SHELL', '/bin/zsh'); + const sharedInstructions = path.join(projectRoot, '.codebuddy', 'rules', 'teamai-context.md'); + await fse.ensureDir(path.join(projectRoot, '.codebuddy', 'skills')); + await fse.ensureDir(path.join(projectRoot, '.workbuddy', 'skills')); + await fse.ensureDir(path.dirname(sharedInstructions)); + await fse.writeFile(sharedInstructions, `---\nalwaysApply: true\n---\n\n${TEAMAI_CULTURE_START}\nculture\n${TEAMAI_CULTURE_END}\n`); + const teamConfig = makeTeamConfig({ + toolPaths: { + codebuddy: { skills: '.codebuddy/skills', rules: '.codebuddy/rules', claudemd: '.codebuddy/CODEBUDDY.md' }, + workbuddy: { skills: '.workbuddy/skills', rules: '.workbuddy/rules' }, + }, + }); + const localConfig = makeLocalConfig(homeDir, repoPath, { + scope: 'project', + projectRoot, + enabledAgents: ['codebuddy', 'workbuddy'], + repo: { localPath: repoPath, remote: '', kind: 'self', businessRepoRoot: projectRoot }, + }); + mockAutoDetectInit.mockResolvedValue({ localConfig, teamConfig }); + + await uninstall({ force: true, agent: 'codebuddy' }); - expect(await fse.pathExists(projectPiHook)).toBe(false); - expect(await fse.pathExists(sharedInstructions)).toBe(true); expect(await fse.readFile(sharedInstructions, 'utf8')).toContain(TEAMAI_CULTURE_START); }); @@ -768,7 +1002,7 @@ describe('uninstall', () => { expect(await fse.readFile(agentsMd, 'utf8')).toBe('# My notes\n'); }); - it('a project-scope uninstall leaves the project AGENTS.md to its owners with the default Codex entry', async () => { + it('a project-scope uninstall keeps the owners\' text of the project AGENTS.md and removes the teamai blocks left there (#945)', async () => { const defaults = TeamaiConfigSchema.parse({ team: 't', repo: 'owner/repo' }).toolPaths; const { projectRoot, agentsMd } = await projectFixture({ codex: defaults.codex }, ['codex']); await fse.ensureDir(path.join(projectRoot, '.codex')); @@ -777,7 +1011,7 @@ describe('uninstall', () => { await uninstall({ force: true }); - expect(await fse.readFile(agentsMd, 'utf8')).toBe(owners); + expect(await fse.readFile(agentsMd, 'utf8')).toBe('# Project notes\n'); }); async function codexLegacyFixture() { @@ -881,7 +1115,7 @@ describe('uninstall', () => { expect(await fse.pathExists(legacy('retired.md'))).toBe(false); }); - it('--agent codex with Pi still active removes the recall block and keeps Pi\'s', async () => { + it('--agent codex removes every teamai block from the project AGENTS.md, which Pi no longer reads (#945)', async () => { const { projectRoot, agentsMd } = await projectFixture({ codex: codexPaths, pi: piPaths }, ['codex', 'pi']); await fse.ensureDir(path.join(projectRoot, '.codex')); await fse.writeJson(path.join(projectRoot, '.codex', 'hooks.json'), {}); @@ -890,15 +1124,11 @@ describe('uninstall', () => { await uninstall({ force: true, agent: 'codex' }); - const content = await fse.readFile(agentsMd, 'utf8'); - expect(content).toContain('# Project notes'); - expect(content).toContain(culture); - expect(content).toContain(shared); - // Pi has no `agents`, so it never receives the recall block. - expect(content).not.toContain(TEAMAI_RECALL_RULES_START); + // Pi gets its project blocks from its extension now, so nothing retains these. + expect(await fse.readFile(agentsMd, 'utf8')).toBe('# Project notes\n'); }); - it('--agent codex keeps the culture block while another Codex-family tool maps the same file', async () => { + it('--agent codex removes the culture block another Codex-family tool no longer reads from the project AGENTS.md (#945)', async () => { const { projectRoot, agentsMd } = await projectFixture( { codex: codexPaths, tcodex: { settings: '.tcodex/hooks.json', claudemd: 'AGENTS.md' } }, ['codex', 'tcodex'], @@ -911,10 +1141,10 @@ describe('uninstall', () => { await uninstall({ force: true, agent: 'codex' }); - expect(await fse.readFile(agentsMd, 'utf8')).toContain(culture); + expect(await fse.pathExists(agentsMd)).toBe(false); }); - it('--agent workbuddy with Pi remaining removes WorkBuddy\'s recall block, which Pi never writes', async () => { + it('--agent workbuddy removes every teamai block from the project AGENTS.md, which Pi no longer reads (#945)', async () => { const { projectRoot, agentsMd } = await projectFixture({ pi: piPaths, workbuddy: workbuddyPaths }, ['pi', 'workbuddy']); await fse.ensureDir(path.join(projectRoot, '.pi', 'skills')); await fse.ensureDir(path.join(projectRoot, '.workbuddy', 'skills')); @@ -922,9 +1152,7 @@ describe('uninstall', () => { await uninstall({ force: true, agent: 'workbuddy' }); - const content = await fse.readFile(agentsMd, 'utf8'); - expect(content).toContain(culture); - expect(content).not.toContain(TEAMAI_RECALL_RULES_START); + expect(await fse.pathExists(agentsMd)).toBe(false); }); }); @@ -1590,6 +1818,125 @@ describe('uninstall', () => { expect(await fse.pathExists(path.join(projectPlugin, 'my-own-plugin.ts'))).toBe(true); }); + it.each([ + ['deleted, entry recorded', null, ['docs/style.md'], true], + ['teamai\'s, entry recorded', '\nx\n\n', ['docs/style.md'], true], + ['teamai\'s with its markers stripped, entry recorded', 'Generated text.\n', ['docs/style.md'], true], + // The member listed the entry before pull filled the file: theirs. + ['teamai\'s, entry not recorded', '\nx\n\n', ['docs/style.md', '.opencode/teamai-context.md'], false], + ['the member\'s own', '# My own notes\n', ['docs/style.md', '.opencode/teamai-context.md'], false], + ])('handles OpenCode\'s instructions entry when its context file is %s (#945)', async (_state, content, expected, recorded) => { + const projectRoot = path.join(tmpDir, 'oc-entry-project'); + const homeDir = path.join(tmpDir, 'home'); + const repoPath = path.join(projectRoot, '.teamai', 'team-repo'); + await fse.ensureDir(repoPath); + await fse.ensureDir(path.join(projectRoot, '.opencode', 'skills')); + const config = path.join(projectRoot, '.opencode', 'opencode.json'); + await fse.writeJson(config, { instructions: ['docs/style.md', '.opencode/teamai-context.md'] }); + if (content !== null) await fse.writeFile(path.join(projectRoot, '.opencode', 'teamai-context.md'), content); + vi.stubEnv('HOME', homeDir); + vi.stubEnv('SHELL', '/bin/zsh'); + + const teamConfig = makeTeamConfig({ toolPaths: { opencode: { skills: '.opencode/skills', rules: '.opencode/rules' } } }); + const localConfig = makeLocalConfig(projectRoot, repoPath, { scope: 'project', projectRoot }); + mockAutoDetectInit.mockResolvedValue({ localConfig, teamConfig }); + if (recorded) { + const { loadStateForScope, saveStateForScope } = await import('../config.js'); + const state = await loadStateForScope(localConfig); + await saveStateForScope({ ...state, opencodeContextEntries: [{ config, entry: '.opencode/teamai-context.md' }] }, localConfig); + } + + await uninstall({ force: true, agent: 'opencode' }); + + expect((await fse.readJson(config)).instructions).toEqual(expected); + }); + + it('removes only this checkout\'s recorded OpenCode entry, and forgets it (#945)', async () => { + const projectRoot = path.join(tmpDir, 'oc-own-project'); + const sibling = path.join(tmpDir, 'oc-own-sibling'); + const homeDir = path.join(tmpDir, 'home'); + const repoPath = path.join(projectRoot, '.teamai', 'team-repo'); + await fse.ensureDir(repoPath); + await fse.ensureDir(path.join(projectRoot, '.opencode', 'skills')); + // Claude keeps a team skill, so OpenCode is not the last tool. + await fse.outputFile(path.join(repoPath, 'skills', 'team-skill', 'SKILL.md'), '# Team Skill'); + await fse.outputFile(path.join(projectRoot, '.claude', 'skills', 'team-skill', 'SKILL.md'), '# Team Skill'); + const entry = '.opencode/teamai-context.md'; + const config = path.join(projectRoot, '.opencode', 'opencode.json'); + const siblingConfig = path.join(sibling, '.opencode', 'opencode.json'); + await fse.outputJson(config, { instructions: [entry] }); + await fse.outputJson(siblingConfig, { instructions: [entry] }); + await fse.writeFile(path.join(projectRoot, '.opencode', 'teamai-context.md'), 'Generated text.\n'); + vi.stubEnv('HOME', homeDir); + vi.stubEnv('SHELL', '/bin/zsh'); + + const teamConfig = makeTeamConfig({ toolPaths: { + opencode: { skills: '.opencode/skills', rules: '.opencode/rules' }, + claude: { skills: '.claude/skills', rules: '.claude/rules' }, + } }); + const localConfig = makeLocalConfig(projectRoot, repoPath, { scope: 'project', projectRoot }); + mockAutoDetectInit.mockResolvedValue({ localConfig, teamConfig }); + const { loadStateForScope, saveStateForScope } = await import('../config.js'); + await saveStateForScope({ + ...await loadStateForScope(localConfig), + opencodeContextEntries: [{ config, entry }, { config: siblingConfig, entry }], + }, localConfig); + + await uninstall({ force: true, agent: 'opencode' }); + + expect((await fse.readJson(config)).instructions).toBeUndefined(); + expect((await fse.readJson(siblingConfig)).instructions).toEqual([entry]); + expect((await loadStateForScope(localConfig)).opencodeContextEntries).toEqual([{ config: siblingConfig, entry }]); + }); + + it.each([ + ['write failure', 'other tool'], ['unreadable', 'other tool'], + ['write failure', 'last tool'], ['write failure', 'full uninstall'], ['unreadable', 'last tool'], + ])('keeps the OpenCode ownership record for a retry after %s during %s (#945)', async (failure, scenario) => { + const projectRoot = path.join(tmpDir, 'oc-retry-project'); + const repoPath = path.join(projectRoot, '.teamai', 'team-repo'); + await fse.ensureDir(path.join(projectRoot, '.opencode', 'skills')); + await fse.outputFile(path.join(repoPath, 'skills', 'team-skill', 'SKILL.md'), '# Team Skill'); + if (scenario === 'other tool') await fse.outputFile(path.join(projectRoot, '.claude', 'skills', 'team-skill', 'SKILL.md'), '# Team Skill'); + const config = path.join(projectRoot, '.opencode', 'opencode.json'); + const entry = '.opencode/teamai-context.md'; + await fse.outputJson(config, { instructions: [entry] }); + vi.stubEnv('HOME', path.join(tmpDir, 'home')); + vi.stubEnv('SHELL', '/bin/zsh'); + const teamConfig = makeTeamConfig({ toolPaths: { + opencode: { skills: '.opencode/skills', rules: '.opencode/rules' }, + claude: { skills: '.claude/skills', rules: '.claude/rules' }, + } }); + const localConfig = makeLocalConfig(projectRoot, repoPath, { scope: 'project', projectRoot }); + mockAutoDetectInit.mockResolvedValue({ localConfig, teamConfig }); + const ref = { config, entry }; + await saveStateForScope({ ...await loadStateForScope(localConfig), opencodeContextEntries: [ref] }, localConfig); + const originalRename = fse.rename.bind(fse); + const originalRead = fse.readFile.bind(fse); + if (failure === 'write failure') { + vi.spyOn(fse, 'rename').mockImplementation((...args: Parameters) => { + if (args[1] === config) return Promise.reject(new Error('EACCES')); + return originalRename(...args); + }); + } else { + vi.spyOn(fse, 'readFile').mockImplementation((...args: Parameters) => { + if (args[0] === config) return Promise.reject(new Error('EACCES')); + return originalRead(...args); + }); + } + const previousExitCode = process.exitCode; + await uninstall({ force: true, ...(scenario !== 'full uninstall' ? { agent: 'opencode' } : {}) }); + expect(process.exitCode).toBe(1); + process.exitCode = previousExitCode; + expect((await loadStateForScope(localConfig)).opencodeContextEntries).toEqual([ref]); + vi.restoreAllMocks(); + expect((await fse.readJson(config)).instructions).toEqual([entry]); + mockAutoDetectInit.mockResolvedValue({ localConfig, teamConfig }); + await uninstall({ force: true, ...(scenario !== 'full uninstall' ? { agent: 'opencode' } : {}) }); + expect((await fse.readJson(config)).instructions).toBeUndefined(); + if (scenario === 'other tool') expect((await loadStateForScope(localConfig)).opencodeContextEntries).toEqual([]); + }); + // A relocated Claude Code root (toolRoots) moves the HOME hook file, but the // legacy copy was written by a CLI that knew nothing about it โ€” // so the two targets must be looked for at different paths. diff --git a/src/builtin-rules.ts b/src/builtin-rules.ts index 89048d847..cbe19bbb6 100644 --- a/src/builtin-rules.ts +++ b/src/builtin-rules.ts @@ -31,6 +31,12 @@ export const BUILTIN_RULE_NAMES = new Set(['teamai-recall']); /** Names of previously deployed rules that should be cleaned up. */ export const LEGACY_RULE_NAMES: string[] = []; +/** + * The rule file a pull writes the culture, claudemd and recall blocks into + * for tools whose rules directory is their instruction target (#945). + */ +export const TEAMAI_CONTEXT_RULE_NAME = 'teamai-context'; + /** * Names that scanLocalForPush and stale-cleanup should skip. * Includes both current built-in rules and legacy rules (being cleaned up). @@ -38,6 +44,7 @@ export const LEGACY_RULE_NAMES: string[] = []; export const EXCLUDED_RULE_NAMES = new Set([ ...BUILTIN_RULE_NAMES, ...LEGACY_RULE_NAMES, + TEAMAI_CONTEXT_RULE_NAME, ]); /** diff --git a/src/doctor-delivery.ts b/src/doctor-delivery.ts index 400148eb8..aade871d3 100644 --- a/src/doctor-delivery.ts +++ b/src/doctor-delivery.ts @@ -2,7 +2,7 @@ import path from 'node:path'; import fs from 'node:fs'; import { isDeepStrictEqual } from 'node:util'; import { expandHome, listFilesRecursive, pathExists, readFileSafe } from './utils/fs.js'; -import { getDataHome, getMcpSharing, isAgentExcluded, managedMcpManifestKey } from './types.js'; +import { getDataHome, getMcpSharing, isAgentExcluded, managedMcpManifestKey, resolveToolBaseDir, scopedToolPaths } from './types.js'; import type { DeliveryTarget, LocalConfig, ManagedMcpManifest, ResourceItem, TeamaiConfig } from './types.js'; import type { EntryLayout, EntryResolution } from './namespaced-entries.js'; import { splitFrontmatter } from './utils/frontmatter.js'; @@ -325,7 +325,8 @@ async function buildRulesActivationChecks(ctx: DoctorContext, items: ResourceIte const opencode = await handler.opencodeInstructionsTarget(teamConfig, localConfig); if (opencode !== null) { - const instructions = await readOpencodeInstructions(opencode.configFile); + const { readOpencodeInstructionList } = await import('./resources/opencode-config.js'); + const instructions = await readOpencodeInstructionList(opencode.configFile); const active = instructions !== null && instructions.includes(opencode.glob); checks.push({ name: 'Team rules are active in opencode', @@ -435,24 +436,6 @@ async function buildCodexUserRulesChecks(ctx: DoctorContext, items: ResourceItem return checks; } -/** - * The `instructions` entries of an opencode.json, or null when the file is - * missing or is not a JSON object โ€” the two cases in which the pull leaves it - * strictly alone and the glob never lands. - */ -async function readOpencodeInstructions(configFile: string): Promise { - const raw = await readFileSafe(configFile); - if (raw === null) return null; - if (raw.trim() === '') return []; - try { - const parsed: unknown = JSON.parse(raw); - if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) return null; - const { instructions } = parsed as { instructions?: unknown }; - return Array.isArray(instructions) ? instructions : []; - } catch { - return null; - } -} /** * Build one delivery check per tool that receives agents. @@ -1161,3 +1144,84 @@ function listed(sources: string[]): string { ? sources.join(' and ') : `${sources.slice(0, -1).join(', ')} and ${sources[sources.length - 1]}`; } + +/** + * Team instructions (#945): whether each installed tool can load this + * member's culture, claudemd and recall blocks, not only whether a file was + * written. A file target must hold the current blocks, and OpenCode's must be + * listed in its `instructions`; a hook target needs teamai's extension or + * plugin as this build writes it, and room in its channel; and no file an + * earlier release wrote may still hold blocks. + */ +export async function buildInstructionDeliveryChecks(ctx: DoctorContext): Promise { + const { localConfig, teamConfig } = ctx; + if (!teamConfig) return []; + const { + holdsInstructionBlocks, hookLimitProblem, instructionHookChannel, instructionHookText, instructionTargetPath, + planInstructionFiles, resolveInstructionTargets, + } = await import('./instruction-targets.js'); + const { resolveInstructionBlocks } = await import('./pull.js'); + const { buildRolePullContext } = await import('./resources/desired.js'); + const { opencodeContextReference, readOpencodeInstructionList } = await import('./resources/opencode-config.js'); + const { blocks } = await resolveInstructionBlocks(teamConfig, localConfig, await buildRolePullContext(localConfig)); + const { targets, hooks, stale } = await resolveInstructionTargets(teamConfig, localConfig); + const pullNow = 'Run `teamai pull`.'; + const checks: Check[] = []; + + for (const target of targets) { + const plan = await planInstructionFiles([target], blocks); + checks.push({ + name: `Team instructions are current for ${target.tools.join(', ')}`, + source: 'local', + check: async () => plan.changes.length === 0 && plan.warnings.length === 0, + fix: plan.warnings.length > 0 + ? plan.warnings.join(' ') + : `${target.path} does not hold this member's current team instructions. ${pullNow}`, + }); + } + + const opencodePaths = scopedToolPaths(teamConfig, localConfig).opencode; + const opencodeFile = opencodePaths && instructionTargetPath('opencode', opencodePaths, localConfig); + // Only a file holding the blocks needs listing; pull registers it once it writes them. + if (opencodeFile && targets.some((t) => t.path === opencodeFile) && await holdsInstructionBlocks(opencodeFile)) { + const { config, entry } = opencodeContextReference(opencodeFile, localConfig.scope, resolveToolBaseDir('opencode', localConfig)); + const instructions = await readOpencodeInstructionList(config); + checks.push({ + name: 'Team instructions are listed in opencode instructions', + source: 'local', + check: async () => instructions !== null && instructions.includes(entry), + fix: instructions === null + ? `${config} could not be read as a JSON object, so the pull left it alone and OpenCode never loads ${opencodeFile}. ` + + `Fix the file or add "${entry}" to its "instructions" by hand, then run \`teamai pull\`.` + : `${config} does not list "${entry}" under "instructions", and OpenCode reads no file it is not told about. ${pullNow}`, + }); + } + + for (const hook of hooks) { + const text = instructionHookText(blocks, hook.recall); + if (!text) continue; + const channel = await instructionHookChannel(hook.tool, { teamConfig, localConfig }); + const overLimit = hookLimitProblem(hook, text); + checks.push({ + name: `${hook.tool} adds the team instructions to its prompt`, + source: 'local', + check: async () => channel.ready && overLimit === null, + fix: channel.ready ? overLimit ?? '' : channel.fix, + }); + } + + const leftovers: string[] = []; + const warnings: string[] = []; + for (const file of stale) { + const plan = await planInstructionFiles([], {}, [file]); + if (plan.changes.length > 0 || plan.warnings.length > 0) leftovers.push(file.path); + warnings.push(...plan.warnings); + } + checks.push({ + name: 'No team instruction blocks are left in files no tool loads them from', + source: 'local', + check: async () => leftovers.length === 0, + fix: [...warnings, `Earlier teamai releases left team instruction blocks in ${nameList(leftovers)}, which can carry another member's selection. ${pullNow}`].join(' '), + }); + return checks; +} diff --git a/src/doctor.ts b/src/doctor.ts index 5892c955c..a75c24e0e 100644 --- a/src/doctor.ts +++ b/src/doctor.ts @@ -26,6 +26,7 @@ import { buildDeliveryChecks, buildRulesDeliveryChecks, buildAgentsDeliveryChecks, + buildInstructionDeliveryChecks, buildNamespaceNotes, buildMcpDeliveryChecks, buildMcpGitExcludeCheck, @@ -522,6 +523,7 @@ export async function buildChecks(ctx: DoctorContext, stage: CheckStage = 'docto // them, so skipping them post-pull is what keeps the budget for the rest. ...(stage === 'doctor' ? await buildRulesDeliveryChecks(ctx) : []), ...(stage === 'doctor' ? await buildAgentsDeliveryChecks(ctx) : []), + ...(stage === 'doctor' ? await buildInstructionDeliveryChecks(ctx) : []), ...await buildAgentModelChecks(ctx, stage), ...await buildMcpDeliveryChecks(ctx), ...await buildMcpGitExcludeCheck(ctx), diff --git a/src/hermes-config.ts b/src/hermes-config.ts index 49acd0ed9..ce37f08d8 100644 --- a/src/hermes-config.ts +++ b/src/hermes-config.ts @@ -295,3 +295,47 @@ export async function removeHermesAllowlist(event: string, command: string): Pro await writeJson(filePath, { approvals: filtered }); } + +/** + * Add `name` to `plugins.enabled` in the Hermes config.yaml: Hermes loads no + * user plugin that is not listed there. A name the member put under + * `plugins.disabled` stays off. Returns whether config.yaml was written. + */ +export async function enableHermesPlugin(name: string): Promise { + const doc = await readConfigDoc(); + const disabled = doc.getIn(['plugins', 'disabled']); + if (YAML.isSeq(disabled) && (disabled.toJSON() as unknown[]).includes(name)) return false; + const enabled = doc.getIn(['plugins', 'enabled']); + const list = YAML.isSeq(enabled) ? (enabled.toJSON() as unknown[]) : []; + if (list.includes(name)) return false; + doc.setIn(['plugins', 'enabled'], [...list, name]); + await writeConfigDoc(doc); + return true; +} + +/** Remove `name` from `plugins.enabled` in the Hermes config.yaml, dropping keys left empty. */ +export async function disableHermesPlugin(name: string): Promise { + const doc = await readConfigDoc(); + const enabled = doc.getIn(['plugins', 'enabled']); + if (!YAML.isSeq(enabled)) return; + const list = enabled.toJSON() as unknown[]; + if (!list.includes(name)) return; + const rest = list.filter((entry) => entry !== name); + if (rest.length > 0) doc.setIn(['plugins', 'enabled'], rest); + else doc.deleteIn(['plugins', 'enabled']); + const plugins = doc.getIn(['plugins']); + if (YAML.isMap(plugins) && plugins.items.length === 0) doc.deleteIn(['plugins']); + await writeConfigDoc(doc); +} + +/** Whether the member put `name` in `plugins.disabled`, which enabling leaves alone. */ +export async function isHermesPluginDisabled(name: string): Promise { + const disabled = (await readConfigDoc()).getIn(['plugins', 'disabled']); + return YAML.isSeq(disabled) && (disabled.toJSON() as unknown[]).includes(name); +} + +/** Whether `name` is in `plugins.enabled` of the Hermes config.yaml. */ +export async function isHermesPluginEnabled(name: string): Promise { + const enabled = (await readConfigDoc()).getIn(['plugins', 'enabled']); + return YAML.isSeq(enabled) && (enabled.toJSON() as unknown[]).includes(name); +} diff --git a/src/hermes-hooks.ts b/src/hermes-hooks.ts index b5d9ef8dc..be3a7d5d8 100644 --- a/src/hermes-hooks.ts +++ b/src/hermes-hooks.ts @@ -10,7 +10,7 @@ import path from 'node:path'; import { chmod } from 'node:fs/promises'; -import { writeIfChanged, pathExists, remove } from './utils/fs.js'; +import { generatedFileState, writeIfChanged, pathExists, remove } from './utils/fs.js'; import { log } from './utils/logger.js'; import { getHermesHome } from './hermes-home.js'; import { @@ -18,11 +18,99 @@ import { removeHermesHookByCommand, addHermesAllowlist, removeHermesAllowlist, + enableHermesPlugin, + disableHermesPlugin, } from './hermes-config.js'; /** Hermes hook event used for session-start status reporting. */ const REPORT_EVENT = 'on_session_start'; +/** Name of teamai's Hermes plugin, also its `plugins.enabled` entry. */ +export const HERMES_INSTRUCTIONS_PLUGIN = 'teamai-instructions'; + +/** + * Characters Hermes allows one plugin prompt section. A larger section is + * skipped, so teamai reports it rather than truncating the instructions. + */ +export const HERMES_SECTION_LIMIT = 4000; + +/** Directory of teamai's Hermes plugin. */ +export function getInstructionsPluginDir(): string { + return path.join(getHermesHome(), 'plugins', HERMES_INSTRUCTIONS_PLUGIN); +} + +const PLUGIN_MARKER = '# [teamai] generated by teamai'; + +/** + * Whether teamai may write or delete the plugin directory: it does not exist + * yet, or its manifest carries teamai's marker. A same-named plugin of the + * member's is never overwritten or removed. + */ +export async function ownsInstructionsPlugin(): Promise { + const dir = getInstructionsPluginDir(); + if (!await pathExists(dir)) return true; + return await generatedFileState(path.join(dir, 'plugin.yaml'), PLUGIN_MARKER) === 'teamai'; +} + +/** Why teamai left the plugin directory alone, and what to do. */ +export function foreignInstructionsPlugin(): string { + return `${getInstructionsPluginDir()} holds a plugin teamai did not write, so teamai left it unchanged and Hermes sessions in a project ` + + 'get no team instructions. Rename or move that plugin, then run `teamai hooks inject`.'; +} + +/** + * The plugin adds the member's team instructions for the session's project + * as a cache-safe system prompt section (#945). Hermes builds the section + * once for a new session and keeps it through compression and resume. + * Outside a project `hook-dispatch instructions` prints nothing and the + * section is empty: the user-scope blocks are in SOUL.md. + */ +export function buildInstructionsPlugin(): { manifest: string; init: string } { + const manifest = [ + `${PLUGIN_MARKER}, do not edit.`, + `name: ${HERMES_INSTRUCTIONS_PLUGIN}`, + 'version: "1"', + 'description: Adds the team instructions teamai resolves for this member and project.', + '', + ].join('\n'); + const init = [ + '"""[teamai] team instructions plugin, generated by teamai, do not edit."""', + 'import json', + 'import os', + 'import shutil', + 'import subprocess', + '', + '', + 'def _instructions(session_info):', + ' cwd = session_info.get("cwd") or os.getcwd()', + ' try:', + ' result = subprocess.run(', + ' [shutil.which("teamai") or "teamai", "hook-dispatch", "instructions", "--tool", "hermes"],', + ' input=json.dumps({"cwd": cwd, "session_id": session_info.get("session_id")}),', + ' capture_output=True,', + ' text=True,', + ' cwd=cwd,', + ' timeout=15,', + ' )', + ' out = result.stdout.strip()', + ' text = json.loads(out)["hookSpecificOutput"]["additionalContext"] if out else ""', + ' except Exception:', + ' return ""', + ' return text if isinstance(text, str) else ""', + '', + '', + 'def register(ctx):', + ' ctx.register_system_prompt_section(', + ' "teamai.instructions",', + ' _instructions,', + ' position="after_memory",', + ` max_chars=${HERMES_SECTION_LIMIT},`, + ' )', + '', + ].join('\n'); + return { manifest, init }; +} + /** Absolute path to the generated status-report shell script. */ export function getReportScriptPath(): string { return path.join(getHermesHome(), 'hooks', 'teamai-status-report.sh'); @@ -61,7 +149,18 @@ export async function injectHermesHooks(): Promise { } const hookChanged = await upsertHermesHook(REPORT_EVENT, { command: scriptPath, timeout: 60 }); const allowlistChanged = await addHermesAllowlist(REPORT_EVENT, scriptPath); - if (scriptChanged || hookChanged || allowlistChanged) { + let pluginChanged = false; + if (await ownsInstructionsPlugin()) { + const plugin = buildInstructionsPlugin(); + const pluginDir = getInstructionsPluginDir(); + const manifestChanged = await writeIfChanged(path.join(pluginDir, 'plugin.yaml'), plugin.manifest); + const initChanged = await writeIfChanged(path.join(pluginDir, '__init__.py'), plugin.init); + const pluginEnabled = await enableHermesPlugin(HERMES_INSTRUCTIONS_PLUGIN); + pluginChanged = manifestChanged || initChanged || pluginEnabled; + } else { + log.warn(`Skipping the Hermes instructions plugin: ${getInstructionsPluginDir()} exists without the TeamAI marker`); + } + if (scriptChanged || hookChanged || allowlistChanged || pluginChanged) { log.success('Injected teamai Hermes hook into ' + scriptPath); } else { log.debug(`teamai Hermes hook already up-to-date in ${scriptPath}`); @@ -83,6 +182,10 @@ export async function removeHermesHooks(): Promise { if (await pathExists(scriptPath)) { await remove(scriptPath); } + if (await ownsInstructionsPlugin()) { + await disableHermesPlugin(HERMES_INSTRUCTIONS_PLUGIN); + await remove(getInstructionsPluginDir()); + } log.success('Removed teamai Hermes hook from ' + scriptPath); } diff --git a/src/hook-handlers.ts b/src/hook-handlers.ts index 4358ed31c..220dbdcf1 100644 --- a/src/hook-handlers.ts +++ b/src/hook-handlers.ts @@ -735,9 +735,10 @@ const teamRulesHandler: HookHandler = { const { loadTeamConfig } = await import('./config.js'); const teamConfig = await loadTeamConfig(config.repo.localPath); if (!teamConfig) return null; - const { sessionInstructionBlocks } = await import('./pull.js'); + const { instructionHookTextFor } = await import('./instruction-targets.js'); const { teamRulesContext } = await import('./resources/rules.js'); - const parts = await sessionInstructionBlocks(teamConfig, config, tool); + const text = await instructionHookTextFor(teamConfig, config, tool); + const parts = text ? [text] : []; const rules = await teamRulesContext(teamConfig, config); if (rules !== null) parts.push(rules); if (parts.length === 0) return null; @@ -747,12 +748,68 @@ const teamRulesHandler: HookHandler = { }, }; +/** + * `instructions`: the culture, claudemd and recall blocks for a tool whose + * extension adds them to the prompt instead of reading a file (#945). Resolved + * for the member, project and scope of the session's cwd, as a pull would. + * Nothing when the tool reads a file in that scope, or is excluded. + */ +const instructionsHandler: HookHandler = { + name: 'instructions', + async execute(_stdin, tool, config) { + if (!config) return null; + const { deliversInstructionsByHook, instructionHookTextFor } = await import('./instruction-targets.js'); + const { isAgentExcluded } = await import('./types.js'); + if (!deliversInstructionsByHook(tool, config.scope) || isAgentExcluded(config, tool)) return null; + const { loadTeamConfig } = await import('./config.js'); + const teamConfig = await loadTeamConfig(config.repo.localPath); + if (!teamConfig) return null; + const text = await instructionHookTextFor(teamConfig, config, tool); + if (!text) return null; + return JSON.stringify({ hookSpecificOutput: { hookEventName: 'SessionStart', additionalContext: text } }); + }, +}; + +/** + * The HTTP local agent's prompts from its resource cache for the session's + * project (#945), for a tool with no project file the agent could write to. + * Pi, OMP and Hermes ask through `instructions`; the Codex family gets them + * at SessionStart and SubagentStart, beside the team rules. Runs without a + * teamai config, since an HTTP-only machine has none. + */ +const localAgentInstructionsHandler: HookHandler = { + name: 'http-prompt-instructions', + async execute(stdin, tool, config) { + const { isAgentExcluded } = await import('./types.js'); + if (config && isAgentExcluded(config, tool)) return null; + const { deliversInstructionsByHook } = await import('./instruction-targets.js'); + const { getsRulesFromSessionHook } = await import('./resources/rule-format.js'); + if (!deliversInstructionsByHook(tool, 'project')) return null; + const sessionEvent = stdin.hook_event_name === 'SessionStart' || stdin.hook_event_name === 'SubagentStart'; + // Each tool through its own channel only; a resumed Codex session holds them already. + if (getsRulesFromSessionHook(tool) !== sessionEvent || stdin.source === 'resume') return null; + const { localAgentInstructionText } = await import('./local-agent.js'); + const text = await localAgentInstructionText(resolveHookCwd(stdin) ?? process.cwd(), tool); + if (!text) return null; + const hookEventName = stdin.hook_event_name === 'SubagentStart' ? 'SubagentStart' : 'SessionStart'; + return JSON.stringify({ hookSpecificOutput: { hookEventName, additionalContext: text } }); + }, +}; + /** HTTP local-agent report/sync + workspace binding prompts. */ const localAgentHandler: HookHandler = { name: 'local-agent-sync', - async execute(stdin, tool) { + async execute(stdin, tool, config) { + const { isAgentExcluded } = await import('./types.js'); + if (config && isAgentExcluded(config, tool)) return null; const { reportAndSyncFromHook } = await import('./local-agent.js'); - return reportAndSyncFromHook(stdin, tool); + const output = await reportAndSyncFromHook(stdin, tool); + // Codex's first prompt must read the cache after this sync, rather than + // racing it in another handler. SubagentStart reads the parent's cache. + if (stdin.hook_event_name === 'SessionStart') { + return await localAgentInstructionsHandler.execute(stdin, tool, config) ?? output; + } + return output; }, }; @@ -863,6 +920,9 @@ export function buildHandlerRegistry(): HandlerRegistration[] { { event: 'session-start', matcher: '*', handler: packageHintHandler, timeoutMs: FOREGROUND_HOOK_TIMEOUT_MS, requiresConfig: true }, { event: 'session-start', matcher: '*', handler: secretsHintHandler, timeoutMs: FOREGROUND_HOOK_TIMEOUT_MS, requiresConfig: true }, { event: 'session-start', matcher: '*', handler: localAgentHandler, timeoutMs: FOREGROUND_HOOK_TIMEOUT_MS }, + // Asked for by the Pi and OMP extensions and the Hermes plugin, which add the result to the prompt. + { event: 'instructions', matcher: '*', handler: instructionsHandler, timeoutMs: FOREGROUND_HOOK_TIMEOUT_MS, requiresConfig: true }, + { event: 'instructions', matcher: '*', handler: localAgentInstructionsHandler, timeoutMs: FOREGROUND_HOOK_TIMEOUT_MS }, { event: 'session-start', matcher: '*', handler: webhookHandler, timeoutMs: FOREGROUND_HOOK_TIMEOUT_MS, background: true, requiresConfig: true }, // Copilot emits SessionEnd after its final turn (not Stop), so the webhook @@ -895,6 +955,7 @@ export function buildHandlerRegistry(): HandlerRegistration[] { // โ”€โ”€โ”€ SubagentStart โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ // Codex only (SUBAGENT_START_SPEC): a fresh subagent fires no SessionStart (#938). { event: 'subagent-start', matcher: '*', handler: teamRulesHandler, timeoutMs: FOREGROUND_HOOK_TIMEOUT_MS, requiresConfig: true }, + { event: 'subagent-start', matcher: '*', handler: localAgentInstructionsHandler, timeoutMs: FOREGROUND_HOOK_TIMEOUT_MS }, // โ”€โ”€โ”€ SubagentStop โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ // A subagent can finish after the session's last Stop (a background diff --git a/src/init.ts b/src/init.ts index 26d9ba803..807066afd 100644 --- a/src/init.ts +++ b/src/init.ts @@ -790,6 +790,14 @@ async function reconcileHooksForInit( ): Promise { const reconciled = await reconcileTeamHooksForConfig(teamConfig, localConfig, { filterAgents }); if (!reconciled.ok) log.warn(describeUnappliedTeamHooks(reconciled)); + // The hooks install the extensions and plugins that add team instructions + // for Pi, OMP and Hermes; name what keeps a tool from getting them (#945). + try { + const { instructionChannelProblems } = await import('./instruction-targets.js'); + for (const problem of await instructionChannelProblems(teamConfig, localConfig)) log.warn(problem); + } catch (e) { + log.debug(`Team instruction check skipped: ${(e as Error).message}`); + } } /** diff --git a/src/instruction-targets.ts b/src/instruction-targets.ts new file mode 100644 index 000000000..83892970d --- /dev/null +++ b/src/instruction-targets.ts @@ -0,0 +1,833 @@ +import path from 'node:path'; +import { isToolInstalledForConfig } from './resources/base.js'; +import { pathExists, readFileSafe, remove, writeFile } from './utils/fs.js'; +import { getUserHome } from './utils/home.js'; +import { CODEX_TOOL_IDS } from './utils/tool-names.js'; +import { + opencodeClaudeFallback, opencodeContextReference, readOpencodeInstructionList, reconcileOpencodeInstructions, +} from './resources/opencode-config.js'; +import { gitTracking, gitTracks } from './mcp-git-exclude.js'; +import { TEAMAI_CONTEXT_RULE_NAME } from './builtin-rules.js'; +import { getHermesHome } from './hermes-home.js'; +import { getHermesSoulPath } from './hermes-config.js'; +import { HERMES_SECTION_LIMIT } from './hermes-hooks.js'; +import { + isAgentExcluded, + resolveToolBaseDir, + resolveHookScope, + scopedToolPaths, + TEAMAI_CLAUDEMD_END, + TEAMAI_CLAUDEMD_START, + TEAMAI_CULTURE_END, + TEAMAI_CULTURE_START, + TEAMAI_RECALL_RULES_END, + TEAMAI_RECALL_RULES_START, + TEAMAI_RULES_END, + TEAMAI_RULES_START, + TEAMAI_TEAM_RULES_END, + TEAMAI_TEAM_RULES_START, + type LocalConfig, + type Scope, + type TeamaiConfig, +} from './types.js'; + +/** + * Where teamai's instruction blocks (culture, claudemd, recall) go: one target + * per tool and scope, written only for tools that are installed, and never a + * file the project or another tool shares (#945). + */ + +type ToolPaths = TeamaiConfig['toolPaths'][string]; + +interface TargetEntry { + /** + * The file this tool reads the blocks from, relative to the tool's base dir + * for the scope (`resolveToolBaseDir`) or absolute. Undefined when the tool + * takes no file in this scope. + */ + readonly file: (paths: ToolPaths) => string | undefined; + /** The tool gets this scope's blocks from teamai's session hook or extension instead of a file. */ + readonly hook?: boolean; + /** The most characters the hook channel takes; the tool drops a larger text whole. */ + readonly hookLimit?: number; + /** Text teamai writes above the blocks when it creates the file, e.g. the frontmatter a rules loader needs. */ + readonly header?: string; + /** teamai owns the whole file: one it did not write is left alone, and it is deleted once its blocks are gone. */ + readonly owned?: boolean; + /** + * Files an earlier release wrote this tool's blocks to by default, relative + * to the same base dir. A pull strips teamai blocks from them once no + * installed tool targets them. A tool whose target is not its `claudemd` + * retires the team's configured `claudemd` too (`retiredInstructionFiles`). + */ + readonly retired: readonly string[]; +} + +/** The tool's `claudemd` path from the team's `toolPaths` (honors `toolRoots`). */ +const configured = (paths: ToolPaths): string | undefined => paths.claudemd; + +/** + * teamai's own always-applied file in the tool's rules directory. A team's + * `toolPaths` entry without `rules` keeps its configured `claudemd`, which is + * the member's file there, not teamai's. + */ +const contextRule = (extension: string) => (paths: ToolPaths): string | undefined => + paths.rules === undefined ? paths.claudemd : path.posix.join(paths.rules, `${TEAMAI_CONTEXT_RULE_NAME}${extension}`); + +/** + * Cursor applies an `.mdc` rule in every session only with this frontmatter; + * CodeBuddy and WorkBuddy read the same key. + */ +const ALWAYS_APPLY = '---\nalwaysApply: true\n---\n'; +const cursor: TargetEntry = { file: contextRule('.mdc'), header: ALWAYS_APPLY, owned: true, retired: [] }; + +// A team override or an earlier build could have pointed Codex at the project AGENTS.md. +const codexHook: TargetEntry = { file: () => undefined, hook: true, retired: ['AGENTS.md'] }; + +/** + * CodeBuddy and WorkBuddy both read the project's .codebuddy/rules, so they + * share one copy there; uninstalling one keeps it while the other remains. + * An entry without `rules` keeps its configured `claudemd`, as `contextRule`. + */ +const codebuddyProjectRule = (paths: ToolPaths): string | undefined => + paths.rules === undefined ? paths.claudemd : `.codebuddy/rules/${TEAMAI_CONTEXT_RULE_NAME}.md`; + +// One line per tool, so a change to one tool's target edits one line. +const USER_TARGETS: Readonly> = { + claude: { file: configured, retired: [] }, + // Cursor CLI reads ~/.cursor/rules when the session starts under $HOME. + cursor, + 'claude-internal': { file: configured, retired: [] }, + tclaude: { file: configured, retired: [] }, + // Hermes loads SOUL.md in every session; teamai's rules block is already there. + hermes: { file: () => getHermesSoulPath(), retired: ['AGENTS.md'] }, + copilot: { file: configured, retired: [] }, + // RULES.md is an always-applied rule beside OMP's single user context file, + // which ~/.omp/agent/AGENTS.md would take from ~/.agents/AGENTS.md. + omp: { file: () => '.omp/agent/RULES.md', retired: ['.omp/agent/AGENTS.md'] }, + pi: { file: configured, retired: [] }, + // WorkBuddy reads user rules from ~/.workbuddy/rules; nothing else reads them. + workbuddy: { file: contextRule('.md'), header: ALWAYS_APPLY, owned: true, retired: ['AGENTS.md'] }, + codebuddy: { file: configured, retired: [] }, + openclaw: { file: configured, retired: [] }, + // Registered in the user opencode.json `instructions`; AGENTS.md beside it stays the member's. + opencode: { file: () => '.config/opencode/teamai-context.md', owned: true, retired: [] }, +}; + +const PROJECT_TARGETS: Readonly> = { + // Claude loads every unscoped .claude/rules file from the root and any + // subdirectory, and still reads AGENTS.md and an authored CLAUDE.md as it + // chose to. CLAUDE.local.md would stop the native AGENTS.md load (#945). + claude: { file: contextRule('.md'), owned: true, retired: ['.claude/CLAUDE.md'] }, + cursor, + 'claude-internal': { file: configured, retired: [] }, + tclaude: { file: configured, retired: [] }, + // Hermes reads the project's AGENTS.md itself; teamai's Hermes plugin adds + // the blocks as a system prompt section, which holds 4,000 characters. + hermes: { file: () => undefined, hook: true, hookLimit: HERMES_SECTION_LIMIT, retired: ['AGENTS.md'] }, + copilot: { file: configured, retired: [] }, + // OMP reads project rules only from the root and keeps one context file per + // level, so .omp/AGENTS.md would hide the project's AGENTS.md: teamai's OMP + // extension adds the blocks to each turn's system prompt instead. + omp: { file: () => undefined, hook: true, retired: ['.omp/AGENTS.md'] }, + // Pi reads the project's AGENTS.md itself; teamai's Pi extension adds the + // blocks to each run's system prompt. + pi: { file: () => undefined, hook: true, retired: ['AGENTS.md'] }, + workbuddy: { file: codebuddyProjectRule, header: ALWAYS_APPLY, owned: true, retired: ['AGENTS.md'] }, + codebuddy: { file: codebuddyProjectRule, header: ALWAYS_APPLY, owned: true, retired: ['.codebuddy/CODEBUDDY.md'] }, + openclaw: { file: configured, retired: [] }, + // Codex reads no project file only it reads; its session-start and + // subagent-start hooks add the blocks (#938, #940). + codex: codexHook, + 'codex-internal': codexHook, + tcodex: codexHook, + // Registered in .opencode/opencode.json `instructions`; the root opencode.json stays the project's. + opencode: { file: () => '.opencode/teamai-context.md', owned: true, retired: [] }, +}; + +type MarkerPair = readonly [start: string, end: string, name: string]; + +const CULTURE: MarkerPair = [TEAMAI_CULTURE_START, TEAMAI_CULTURE_END, 'culture']; +const CLAUDEMD: MarkerPair = [TEAMAI_CLAUDEMD_START, TEAMAI_CLAUDEMD_END, 'claudemd']; +const RECALL: MarkerPair = [TEAMAI_RECALL_RULES_START, TEAMAI_RECALL_RULES_END, 'recall']; +/** + * The rules block releases before per-file rules wrote into instruction files. + * Hermes still writes it live in SOUL.md, which is only ever a target here, + * never a retired file, so cleanup never reaches that copy. + */ +const LEGACY_RULES: MarkerPair = [TEAMAI_RULES_START, TEAMAI_RULES_END, 'rules']; +/** The team rules a Codex-family tool reads from its own AGENTS.md in user scope. */ +const TEAM_RULES: MarkerPair = [TEAMAI_TEAM_RULES_START, TEAMAI_TEAM_RULES_END, 'team-rules']; + +/** Every teamai block a stale target can hold. */ +const STALE_BLOCKS: readonly MarkerPair[] = [CULTURE, CLAUDEMD, RECALL, LEGACY_RULES, TEAM_RULES]; + +function entryFor(tool: string, scope: Scope): TargetEntry | undefined { + return (scope === 'user' ? USER_TARGETS : PROJECT_TARGETS)[tool]; +} + +/** One instruction file and the tools that read it. */ +export interface InstructionTarget { + /** Absolute path. */ + path: string; + tools: string[]; + /** Whether every tool reading this file has the `teamai-recall` subagent, which decides the recall block it gets. */ + recall: boolean; + header?: string; + owned?: boolean; +} + +/** An installed, non-excluded tool that gets this scope's blocks from its session hook or extension. */ +export interface InstructionHook { + tool: string; + recall: boolean; + /** The most characters its channel takes, when it has a limit. */ + limit?: number; +} + +export interface InstructionTargets { + /** Targets of installed, non-excluded tools, one per file. */ + targets: InstructionTarget[]; + hooks: InstructionHook[]; + /** Files earlier releases wrote blocks to that no installed tool reads now: a pull strips teamai blocks from them. */ + stale: InstructionTarget[]; + /** Claude's user file when OpenCode reads the blocks from it, so OpenCode gets no file of its own. */ + opencodeFallback?: string | null; + /** The fallback holds blocks no installed, enabled Claude Code keeps current. */ + opencodeFallbackStale?: boolean; +} + +/** + * The block texts to deliver, each with its markers. `null` removes the block; + * an absent field leaves it as it is. + */ +export interface InstructionBlocks { + culture?: string | null; + claudemd?: string | null; + /** For a tool with the `teamai-recall` subagent. */ + recall?: string | null; + /** For a tool without it: the agent runs `teamai recall` itself. Same markers. */ + directRecall?: string | null; +} + +/** + * The instruction file `tool` reads in the active scope, relative to its base + * dir or absolute (see `TargetEntry.file`). Tools absent from the table keep + * their configured `claudemd` path. + */ +export function instructionTargetFile(tool: string, paths: ToolPaths, scope: Scope): string | undefined { + return (entryFor(tool, scope)?.file ?? configured)(paths); +} + +/** + * Files, relative to the tool's base dir or absolute, an earlier release wrote + * `tool`'s blocks to in `scope`: the defaults, and the `claudemd` the team's + * `toolPaths` gives a tool whose target moved off it. + */ +export function retiredInstructionFiles(tool: string, paths: ToolPaths, scope: Scope): readonly string[] { + const entry = entryFor(tool, scope); + if (!entry) return []; + const previous = paths.claudemd === instructionTargetFile(tool, paths, scope) ? undefined : paths.claudemd; + return previous === undefined || entry.retired.includes(previous) ? entry.retired : [...entry.retired, previous]; +} + +/** Whether `tool` gets this scope's blocks from teamai's session hook or extension rather than a file. */ +export function deliversInstructionsByHook(tool: string, scope: Scope): boolean { + return entryFor(tool, scope)?.hook === true; +} + +/** + * The text a session hook adds to the prompt: the same blocks a file target + * holds, with the recall block that matches whether the tool has the + * `teamai-recall` subagent. + */ +export function instructionHookText(blocks: InstructionBlocks, recall: boolean): string { + return [blocks.culture, blocks.claudemd, recall ? blocks.recall : blocks.directRecall] + .filter((block): block is string => typeof block === 'string') + .map(managedBlockBody) + .filter((body) => body !== '') + .join('\n\n'); +} + +/** The native project context, including legacy blocks that migration could not yet retire. */ +export async function nativeProjectInstructions(tool: string, projectRoot: string): Promise { + const preferred = CODEX_TOOL_IDS.some((id) => id === tool) ? 'AGENTS.override.md' + : tool === 'omp' ? '.omp/AGENTS.md' : undefined; + return (preferred ? await readFileSafe(path.join(projectRoot, preferred)) : null) + ?? await readFileSafe(path.join(projectRoot, 'AGENTS.md')) ?? ''; +} + +/** The text a session hook adds for `tool`, resolved for the member, project and scope in `localConfig`. */ +export async function instructionHookTextFor(teamConfig: TeamaiConfig, localConfig: LocalConfig, tool: string): Promise { + const { buildRolePullContext } = await import('./resources/desired.js'); + const { resolveInstructionBlocks } = await import('./pull.js'); + const { blocks } = await resolveInstructionBlocks(teamConfig, localConfig, await buildRolePullContext(localConfig)); + if (localConfig.scope === 'project' && localConfig.projectRoot) { + const native = await nativeProjectInstructions(tool, localConfig.projectRoot); + // Native context still supplies each retained block. Do not add a second + // member selection while another writer's replacement holds cleanup back. + if (native.includes(CULTURE[0]) || native.includes(CULTURE[1])) blocks.culture = null; + if (native.includes(CLAUDEMD[0]) || native.includes(CLAUDEMD[1])) blocks.claudemd = null; + if (native.includes(RECALL[0]) || native.includes(RECALL[1])) blocks.recall = blocks.directRecall = null; + } + return instructionHookText(blocks, Boolean(scopedToolPaths(teamConfig, localConfig)[tool]?.agents)); +} + +/** The message for hook text over its channel's limit, or null when it fits. */ +export function hookLimitProblem(hook: InstructionHook, text: string): string | null { + if (hook.limit === undefined || text.length <= hook.limit) return null; + return `${hook.tool} cannot load this project's team instructions: they are ${text.length} characters, over the ` + + `${hook.limit}-character limit of its prompt section, so ${hook.tool} skips them. Shorten culture.md or the ` + + 'claudemd/ files for this scope, or run `teamai recall disable`, which drops the recall block from them. ' + + 'teamai does not cut them or write them to AGENTS.md.'; +} + +/** + * Whether the extension or plugin that adds a hook tool's team instructions is + * installed as this build writes it, and if not, what to do. + */ +export async function instructionHookChannel( + tool: string, + context?: { teamConfig: TeamaiConfig; localConfig: LocalConfig }, +): Promise<{ ready: boolean; fix: string }> { + const rerun = 'Run `teamai hooks inject` to reinstall it; `teamai hooks remove` takes it away.'; + if (tool === 'omp' || tool === 'pi') { + const { buildOmpExtensionSource, resolveOmpExtensionsDir, OMP_HOOK_FILE } = await import('./omp-hooks.js'); + const { buildPiExtensionSource, resolvePiExtensionsDir, PI_HOOK_FILE } = await import('./pi-hooks.js'); + const file = tool === 'omp' ? path.join(resolveOmpExtensionsDir(), OMP_HOOK_FILE) : path.join(resolvePiExtensionsDir(), PI_HOOK_FILE); + const expected = tool === 'omp' ? buildOmpExtensionSource() : buildPiExtensionSource(); + return { + ready: await readFileSafe(file) === expected, + fix: `${file} is missing or out of date, so ${tool} sessions in this project get no team instructions. ${rerun}`, + }; + } + if (tool === 'hermes') { + const { + buildInstructionsPlugin, foreignInstructionsPlugin, getInstructionsPluginDir, HERMES_INSTRUCTIONS_PLUGIN, ownsInstructionsPlugin, + } = await import('./hermes-hooks.js'); + const { getHermesConfigPath, isHermesPluginEnabled } = await import('./hermes-config.js'); + if (!await ownsInstructionsPlugin()) return { ready: false, fix: foreignInstructionsPlugin() }; + const dir = getInstructionsPluginDir(); + const plugin = buildInstructionsPlugin(); + const installed = await readFileSafe(path.join(dir, '__init__.py')) === plugin.init + && await readFileSafe(path.join(dir, 'plugin.yaml')) === plugin.manifest; + if (!installed) { + return { ready: false, fix: `The Hermes plugin ${dir} is missing or out of date, so Hermes sessions in this project get no team instructions. ${rerun}` }; + } + return { + ready: await isHermesPluginEnabled(HERMES_INSTRUCTIONS_PLUGIN), + fix: `${HERMES_INSTRUCTIONS_PLUGIN} is not in plugins.enabled of ${getHermesConfigPath()}, and Hermes loads no user plugin that is not listed there. ` + + 'Add it there (or remove it from plugins.disabled), then start a new Hermes session.', + }; + } + if (CODEX_TOOL_IDS.some((id) => id === tool)) { + const { getHookStatus } = await import('./hooks.js'); + const hookScope = context && resolveHookScope(context.localConfig); + const settings = context && hookScope && scopedToolPaths(context.teamConfig, { ...context.localConfig, scope: hookScope.scope })[tool]?.settings; + const file = settings && hookScope ? path.resolve(hookScope.baseDir, settings) : undefined; + return { + ready: file !== undefined && await getHookStatus(file, tool) === 'installed', + fix: `${file ?? `${tool}'s hooks file`} is missing or out of date, so ${tool} sessions get no team instructions. ${rerun}`, + }; + } + return { ready: true, fix: '' }; +} + +/** + * Whether reconciling the hooks leaves `tool`'s channel ready. A dry run + * writes nothing, so it previews the cleanup a real pull does once it has + * installed the extension or plugin. A same-named file or plugin of the + * member's, or a plugin the member disabled, keeps the channel closed. + */ +export async function instructionHookChannelInstallable(tool: string): Promise { + if (tool === 'omp' || tool === 'pi') { + const { hasOmpHooks, resolveOmpExtensionsDir, OMP_HOOK_FILE } = await import('./omp-hooks.js'); + const { hasPiHooks, resolvePiExtensionsDir, PI_HOOK_FILE } = await import('./pi-hooks.js'); + const file = tool === 'omp' ? path.join(resolveOmpExtensionsDir(), OMP_HOOK_FILE) : path.join(resolvePiExtensionsDir(), PI_HOOK_FILE); + return !await pathExists(file) || (tool === 'omp' ? await hasOmpHooks() : await hasPiHooks()); + } + if (tool === 'hermes') { + const { HERMES_INSTRUCTIONS_PLUGIN, ownsInstructionsPlugin } = await import('./hermes-hooks.js'); + const { isHermesPluginDisabled } = await import('./hermes-config.js'); + return await ownsInstructionsPlugin() && !await isHermesPluginDisabled(HERMES_INSTRUCTIONS_PLUGIN); + } + return true; +} + +/** + * What keeps an installed hook tool from getting this member's team + * instructions in the scope: its extension or plugin, or the size of the text. + * pull and init print these after installing the hooks; doctor checks them. + */ +export async function instructionChannelProblems(teamConfig: TeamaiConfig, localConfig: LocalConfig): Promise { + const { hooks } = await resolveInstructionTargets(teamConfig, localConfig); + const problems: string[] = []; + for (const hook of hooks) { + const text = await instructionHookTextFor(teamConfig, localConfig, hook.tool); + if (!text) continue; + const channel = await instructionHookChannel(hook.tool, { teamConfig, localConfig }); + if (!channel.ready) { + problems.push(channel.fix); + continue; + } + const overLimit = hookLimitProblem(hook, text); + if (overLimit) problems.push(overLimit); + } + return problems; +} + +/** A block's text without its markers and DO NOT EDIT line, which only a file needs. */ +function managedBlockBody(block: string): string { + return block + .split('\n') + .filter((line) => !/^$/.test(line.trim())) + .join('\n') + .trim(); +} + +/** Absolute instruction file of `tool` in the active scope, or undefined when it takes none. */ +export function instructionTargetPath( + tool: string, + paths: ToolPaths, + localConfig: LocalConfig, +): string | undefined { + const file = instructionTargetFile(tool, paths, localConfig.scope); + return file === undefined ? undefined : path.resolve(resolveToolBaseDir(tool, localConfig), file); +} + +/** + * The target `tool` reads from `file` in `scope`, with the header and + * ownership its entry declares. Only teamai's `teamai-context` file takes + * them; a configured file is the member's. + */ +export function instructionTargetAt(tool: string, file: string, scope: Scope, paths: ToolPaths): InstructionTarget { + const entry = entryFor(tool, scope); + // teamai's file is the one its entry generates; the team's configured + // `claudemd` (the fallback without `rules`) is the member's, whatever its name. + const own = path.basename(file).startsWith(`${TEAMAI_CONTEXT_RULE_NAME}.`) + && instructionTargetFile(tool, paths, scope) !== paths.claudemd; + return { path: file, tools: [], recall: false, header: own ? entry?.header : undefined, owned: own ? entry?.owned : undefined }; +} + +/** + * The files earlier releases wrote instruction blocks to in the active scope. + * A tool's current target is not among them: a tool that is not installed + * here may still be installed by a teammate who shares the file (#945). + */ +function retiredTargets(toolPaths: Record, localConfig: LocalConfig): Map { + const current = new Set(Object.entries(toolPaths).map(([tool, paths]) => instructionTargetPath(tool, paths, localConfig))); + const known = new Map(); + const table = localConfig.scope === 'user' ? USER_TARGETS : PROJECT_TARGETS; + for (const tool of Object.keys(table)) { + const baseDir = resolveToolBaseDir(tool, localConfig); + for (const retired of retiredInstructionFiles(tool, toolPaths[tool] ?? {}, localConfig.scope)) { + const file = path.resolve(baseDir, retired); + if (!current.has(file) && !known.has(file)) known.set(file, { path: file, tools: [], recall: false }); + } + } + return known; +} + +/** + * Whether `tool` is installed, probed through a path under its own root. The + * instruction file is the probe only when the entry has no other path, and + * then only through its directory: a bare `AGENTS.md` is shared by several + * tools and says nothing about any one of them, so such an entry counts as + * installed, as it did before #945. + */ +export async function isInstructionToolInstalled(tool: string, paths: ToolPaths, localConfig: LocalConfig): Promise { + // Hermes lives in $HERMES_HOME, which ~/.hermes need not be. + if (tool === 'hermes') return pathExists(getHermesHome()); + // teamai installs the OMP extension only where ~/.omp exists; a project's + // own .omp/ says nothing about this member using OMP. + if (tool === 'omp') return pathExists(path.join(getUserHome(), '.omp')); + // teamai installs the Pi extension in ~/.pi/agent/extensions when ~/.pi + // exists, so a project needs no .pi/ of its own. + if (tool === 'pi' && await pathExists(path.join(getUserHome(), '.pi'))) return true; + const nestedClaudemd = paths.claudemd !== undefined && paths.claudemd.includes('/') ? paths.claudemd : undefined; + const probe = paths.skills ?? paths.rules ?? paths.agents ?? paths.settings ?? nestedClaudemd; + if (probe === undefined) return paths.claudemd !== undefined; + return isToolInstalledForConfig(tool, probe, localConfig); +} + +/** Resolve where this scope's instruction blocks go, and which files to clean. */ +export async function resolveInstructionTargets( + teamConfig: TeamaiConfig, + localConfig: LocalConfig, +): Promise { + const targets = new Map(); + // Files an installed tool reads, excluded or not: an excluded tool's file is + // left alone, not cleaned. + const inUse = new Set(); + const hooks: InstructionHook[] = []; + const toolPaths = scopedToolPaths(teamConfig, localConfig); + for (const [tool, paths] of Object.entries(toolPaths)) { + const entry = entryFor(tool, localConfig.scope); + const file = instructionTargetPath(tool, paths, localConfig); + if (isAgentExcluded(localConfig, tool)) { + if (file) inUse.add(file); + for (const retired of retiredInstructionFiles(tool, paths, localConfig.scope)) { + inUse.add(path.resolve(resolveToolBaseDir(tool, localConfig), retired)); + } + continue; + } + if (entry?.hook) { + // Codex's hooks are user-level, so a project without its own `.codex/` + // still reaches an installed Codex. + // Probe at the member's recorded root (toolRoots), as hook install does. + let probeConfig = localConfig; + let probePaths = paths; + if (entry === codexHook) { + // A project config records no tool roots; the member's are in the + // user config, which a user-scope init may have written later. + const { loadLocalConfig } = await import('./config.js'); + const toolRoots = localConfig.toolRoots ?? (await loadLocalConfig())?.toolRoots; + probeConfig = { ...localConfig, scope: 'user', toolRoots }; + probePaths = scopedToolPaths(teamConfig, probeConfig)[tool] ?? paths; + } + if (await isInstructionToolInstalled(tool, probePaths, probeConfig)) { + hooks.push({ tool, recall: Boolean(paths.agents), limit: entry.hookLimit }); + } + continue; + } + if (!file || !await isInstructionToolInstalled(tool, paths, localConfig)) continue; + inUse.add(file); + const target = targets.get(file) ?? instructionTargetAt(tool, file, localConfig.scope, paths); + // The subagent block only where every tool reading the file has the subagent. + target.recall = Boolean(paths.agents) && (target.tools.length === 0 || target.recall); + target.tools.push(tool); + targets.set(file, target); + } + const stale = [...retiredTargets(toolPaths, localConfig).values()].filter((t) => !inUse.has(t.path)); + // OpenCode reads ~/.claude/CLAUDE.md while its own user AGENTS.md does not + // exist; when Claude's blocks are there, a second copy would duplicate them. + // That holds for blocks an excluded Claude left there too: OpenCode reads + // them all the same. + const opencode = [...targets.values()].find((target) => target.tools.includes('opencode')); + const claudeFile = path.join(getUserHome(), '.claude', 'CLAUDE.md'); + const claudeHolds = !targets.has(claudeFile) && await holdsInstructionBlocks(claudeFile); + const opencodeFallback = localConfig.scope === 'user' && opencode !== undefined + ? await opencodeClaudeFallback(getUserHome(), [...targets.keys(), ...claudeHolds ? [claudeFile] : []]) + : null; + if (opencode && opencodeFallback) { + targets.delete(opencode.path); + stale.push(opencode); + } + return { targets: [...targets.values()], hooks, stale, opencodeFallback, opencodeFallbackStale: Boolean(opencodeFallback) && claudeHolds }; +} + +/** Retire a file only after every installed tool that wrote it has replacement delivery. */ +export async function retiredFilesOfReached( + teamConfig: TeamaiConfig, + localConfig: LocalConfig, + reached: readonly string[], +): Promise { + const { stale, hooks } = await resolveInstructionTargets(teamConfig, localConfig); + const writers = new Map(); + for (const [tool, paths] of Object.entries(scopedToolPaths(teamConfig, localConfig))) { + if (!hooks.some((hook) => hook.tool === tool) && !await isInstructionToolInstalled(tool, paths, localConfig)) continue; + for (const file of retiredInstructionFiles(tool, paths, localConfig.scope)) { + const absolute = path.resolve(resolveToolBaseDir(tool, localConfig), file); + writers.set(absolute, [...writers.get(absolute) ?? [], tool]); + } + } + return stale.filter((target) => (writers.get(target.path) ?? []).every((tool) => reached.includes(tool))); +} + +/** + * List teamai's OpenCode instruction file in OpenCode's `instructions` while + * it holds teamai's blocks, and drop the entry teamai recorded adding once the + * file is gone: OpenCode reads no file it is not told about. A file without + * teamai's blocks is the member's, and an entry teamai did not add stays. Returns what it did or, with + * `dryRun`, would do. + */ +export async function registerOpencodeContext( + teamConfig: TeamaiConfig, + localConfig: LocalConfig, + resolved: Pick, + dryRun: boolean, + files: readonly InstructionFileResult[], +): Promise { + const paths = scopedToolPaths(teamConfig, localConfig).opencode; + const contextFile = paths && instructionTargetPath('opencode', paths, localConfig); + if (!contextFile) return null; + const wanted = resolved.targets.some((target) => target.path === contextFile); + if (!wanted && !resolved.stale.some((target) => target.path === contextFile)) return null; + const result = files.find((file) => file.path === contextFile); + // An unsuccessful edit cannot activate a new entry or remove a working one. + if (wanted && (!result || result.status === 'blocked' || result.status === 'failed')) return null; + const ready = result?.status === 'current' || result?.status === 'written' || (dryRun && result?.status === 'would-write'); + const delivered = await holdsInstructionBlocks(contextFile) || (dryRun && result?.status === 'would-write'); + // A same-named file of the member's: its entry, if any, is theirs too. + if (!delivered && await pathExists(contextFile)) return null; + // Old or malformed blocks the plan could not replace are not this sync's. + const present = wanted && delivered && ready; + const { config, entry } = opencodeContextReference(contextFile, localConfig.scope, resolveToolBaseDir('opencode', localConfig)); + // An entry goes only if teamai recorded adding it: one the member listed + // before teamai wrote the file is theirs. + if (!present) { + const { loadStateForScope } = await import('./config.js'); + const recorded = (await loadStateForScope(localConfig)).opencodeContextEntries ?? []; + if (!recorded.some((ref) => ref.config === config && ref.entry === entry)) return null; + } + if (dryRun) { + const listed = (await readOpencodeInstructionList(config))?.includes(entry) ?? false; + if (listed === present) return null; + return `Would ${present ? 'add' : 'remove'} "${entry}" ${present ? 'to' : 'from'} the instructions of ${config}`; + } + const listed = await readOpencodeInstructionList(config); + if (present && !listed?.includes(entry) && (listed !== null || !await pathExists(config))) { + // Persist ownership before activation. If either write fails, retry can + // safely finish without claiming an entry the member already listed. + await recordOpencodeContextEntry(localConfig, { config, entry }, true); + } + const changed = await reconcileOpencodeInstructions(config, entry, present, 'team instructions'); + if (!present && ((await readOpencodeInstructionList(config))?.includes(entry) === false || !await pathExists(config))) { + await recordOpencodeContextEntry(localConfig, { config, entry }, false); + } + return changed ? `${present ? 'Added' : 'Removed'} "${entry}" ${present ? 'to' : 'from'} the instructions of ${config}` : null; +} + +/** Remember (`added`) or forget the OpenCode entry teamai wrote, for uninstall. */ +async function recordOpencodeContextEntry(localConfig: LocalConfig, ref: { config: string; entry: string }, added: boolean): Promise { + const { loadStateForScope, saveStateForScope } = await import('./config.js'); + const state = await loadStateForScope(localConfig); + const others = (state.opencodeContextEntries ?? []).filter((e) => e.config !== ref.config || e.entry !== ref.entry); + state.opencodeContextEntries = added ? [...others, ref] : others; + await saveStateForScope(state, localConfig); +} + +// โ”€โ”€โ”€ Planning file contents โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +/** A file whose content a plan changes; `content: null` deletes it. */ +export interface InstructionFileChange { + path: string; + content: string | null; + /** `write` delivers blocks to a target; `cleanup` removes them from a file no tool loads them from. */ + kind: 'write' | 'cleanup'; +} + +export interface InstructionPlan { + changes: InstructionFileChange[]; + warnings: string[]; + files: Array<{ path: string; status: 'current' | 'planned' | 'blocked' }>; +} + +export interface InstructionFileResult { + path: string; + status: 'current' | 'written' | 'removed' | 'would-write' | 'would-remove' | 'blocked' | 'failed'; +} + +type BlockEdit = { content: string } | { malformed: true }; + +/** + * Set (`block` a string) or remove (`block` null) one marker-delimited block. + * A block whose markers are not exactly one start followed by one end is + * malformed and left alone, so teamai never deletes text it cannot delimit. + */ +function editBlock(content: string, [start, end]: MarkerPair, block: string | null): BlockEdit { + const starts = content.split(start).length - 1; + const ends = content.split(end).length - 1; + if (starts === 0 && ends === 0) { + if (block === null) return { content }; + const kept = content.trimEnd(); + return { content: kept ? `${kept}\n\n${block}\n` : `${block}\n` }; + } + const startIdx = content.indexOf(start); + const endIdx = content.indexOf(end); + if (starts !== 1 || ends !== 1 || endIdx < startIdx) return { malformed: true }; + const after = content.substring(endIdx + end.length); + if (block !== null) return { content: content.substring(0, startIdx) + block + after }; + const before = content.substring(0, startIdx).replace(/\n+$/, '\n'); + // A block that opened the file leaves no blank line above what follows it. + const rest = (before + after.replace(/^\n+/, before ? '\n' : '')).trimEnd(); + return { content: rest ? `${rest}\n` : '' }; +} + +function hasTeamaiBlock(content: string): boolean { + return STALE_BLOCKS.some(([start, end]) => content.includes(start) || content.includes(end)); +} + +/** + * Whether `file` holds teamai's blocks. A same-named file teamai did not write + * is left as it is, so no tool should be told to load it. + */ +export async function holdsInstructionBlocks(file: string): Promise { + const content = await readFileSafe(file); + return content !== null && hasTeamaiBlock(content); +} + +function withoutHeader(content: string, header: string | undefined): string { + return header && content.startsWith(header) ? content.substring(header.length) : content; +} + +/** + * Whether teamai created the file: it writes a new file starting with a block + * (an older release with blank lines first), while a member's file starts + * with their own text. + */ +function createdByTeamai(content: string): boolean { + return content.trimStart().startsWith('', + '', + '## Team Knowledge Recall (teamai)', + '', + '**Before** starting a task that involves code changes, debugging,', + 'or design decisions, you **SHOULD** search the team knowledge base', + '(learnings, docs, skills, rules and the codebase wiki) by running:', + '', + '```bash', + 'teamai recall "<3-6 high-signal keywords from the task>"', + '```', + '', + 'unless one of these skip conditions applies:', + '', + '1. **User already provided context** โ€” the user referenced specific files,', + ' gave a solution, or said "the answer is in this directory/file".', + '2. **Local files have the answer** โ€” the task info is directly available', + ' from the current workspace (e.g. fixing an obvious bug in the current file).', + '3. **Trivial/local change** โ€” small modifications to known files (typo fix,', + ' parameter tweak, formatting) that need no additional knowledge.', + '4. **Task domain is outside team knowledge coverage** โ€” the task is', + ' unrelated to this team\'s systems/workflows. `teamai recall --check ""`', + ' answers `RELEVANT` or `NOT_RELEVANT` without reading anything.', + '', + 'Matching is lexical: give each domain term in every language the team', + 'writes in, and keep names, identifiers, error codes and paths as they are.', + 'Read the files recall returns for their full content. If its output contains', + '`Nothing was searched:`, show that line to the user instead of concluding the', + 'team has no knowledge on the topic.', + '', + TEAMAI_RECALL_RULES_END, + ].join('\n'); } /** @@ -1986,8 +2010,8 @@ export async function injectRecallBlockIntoTools( * involves code changes / troubleshooting / design. * 2. Declare which doc_ids were actually consulted at task completion. * - * Only injected for Tier-1 tools (those with both `agents` and `claudemd` - * paths configured) โ€” see pull.ts Step 3.8. + * Delivered to the instruction targets of tools with the subagent (an + * `agents` path) โ€” see syncManagedInstructions. */ export function compileRecallRulesBlock(): string { const lines = [ @@ -2044,47 +2068,6 @@ export function compileRecallRulesBlock(): string { return lines.join('\n'); } -/** The text of a managed block, without its markers and DO NOT EDIT line. */ -function managedBlockBody(block: string): string { - return block - .split('\n') - .filter((line) => !/^$/.test(line.trim())) - .join('\n') - .trim(); -} - -/** - * The culture, shared-instruction and recall blocks a tool whose project - * instructions come from its session-start hook (the Codex family) gets in a - * project, resolved as pull resolves them. In user scope they are in the - * tool's own instructions file instead (#945). A block another tool already - * wrote into the active project instructions file is skipped, since the tool - * reads it too. AGENTS.override.md takes precedence over AGENTS.md. - */ -export async function sessionInstructionBlocks( - teamConfig: TeamaiConfig, - localConfig: LocalConfig, - tool: string, -): Promise { - const projectAgents = localConfig.projectRoot - ? await readFileSafe(path.join(localConfig.projectRoot, 'AGENTS.override.md')) - ?? await readFileSafe(path.join(localConfig.projectRoot, 'AGENTS.md')) ?? '' - : ''; - const blocks: Array<[string, string | null]> = []; - const culture = await readFileSafe(path.join(localConfig.repo.localPath, 'culture.md')); - blocks.push([TEAMAI_CULTURE_START, culture === null ? null : compileCulture(culture)]); - const { contents } = await collectClaudemdFiles(localConfig.repo.localPath, await buildRolePullContext(localConfig)); - blocks.push([TEAMAI_CLAUDEMD_START, compileClaudemd(contents)]); - const toolPath = scopedToolPaths(teamConfig, localConfig)[tool]; - if (toolPath?.agents && isRecallEnabled(localConfig, teamConfig)) { - blocks.push([TEAMAI_RECALL_RULES_START, compileRecallRulesBlock()]); - } - return blocks - .filter((entry): entry is [string, string] => entry[1] !== null && !projectAgents.includes(entry[0])) - .map(([, block]) => managedBlockBody(block)) - .filter((body) => body !== ''); -} - /** * Auto-migrate hooks from old individual format to unified hook-dispatch format. * Runs at session start: if settings.json doesn't contain 'hook-dispatch' commands, @@ -2165,6 +2148,7 @@ export async function pull( const syncResult = { completed: false, docsSyncFailed: false, agentModelsHeld: false }; // Each scope's env, resolved once by its env stage (resolvePullEnv). const teamEnvs = new Map(); + const instructions = new Map(); // Whether HOME's settings.json still has the pre-dispatch hook format. Read now // (HOME-only, no shared clone), but the actual reinject runs later under the @@ -2266,7 +2250,7 @@ export async function pull( } else { activeUserConfig = loadedUserConfig; if (await lockScope(activeUserConfig)) { - await pullForScope(activeUserConfig, options, reported, {}, syncResult, teamEnvs); + await pullForScope(activeUserConfig, options, reported, {}, syncResult, teamEnvs, instructions); } } } else if (inheritUserScope) { @@ -2283,7 +2267,7 @@ export async function pull( if (projectConfig) { try { if (await lockScope(projectConfig)) { - await pullForScope(projectConfig, options, reported, {}, syncResult, teamEnvs); + await pullForScope(projectConfig, options, reported, {}, syncResult, teamEnvs, instructions); } } catch (e) { log.warn(`Project-scope pull error: ${(e as Error).message}`); @@ -2326,7 +2310,7 @@ export async function pull( // what self-heals new built-in hooks and applies hooks.yaml changes on every // session start. In project mode user is null, even when safe resources are // inherited, so executable hook configuration is never composed implicitly. - await reconcileHooksAllScopes(reconcileUser, reconcileProject, options); + await reconcileHooksAllScopes(reconcileUser, reconcileProject, options, instructions); // 3.6. Reconcile team MCP servers. Outside pullForScope for the same reason as // hooks. User-scope MCP remains isolated in project mode. @@ -2580,6 +2564,7 @@ async function reconcileHooksAllScopes( userConfig: LocalConfig | null, projectConfig: LocalConfig | null, options: GlobalOptions, + instructions: Map, ): Promise { // A dry run still resolves the entries, so the warnings a maintainer runs // `--dry-run` to see โ€” an unknown id, a deprecated per-entry `roles:`, a @@ -2603,6 +2588,35 @@ async function reconcileHooksAllScopes( // claim a reconcile that did not happen. log.debug(`[${localConfig.scope}] ${options.dryRun ? 'Would apply' : 'Reconciled'} ${reconciled.defs.length} team hook(s)`); } + const delivery = instructions.get(localConfig); + if (delivery) { + const { hooks } = await resolveInstructionTargets(teamConfig, localConfig); + for (const hook of hooks) { + const channel = await instructionHookChannel(hook.tool, { teamConfig, localConfig }); + // A dry run installed nothing: preview what the real pull's install leaves ready. + const ready = channel.ready + || (Boolean(options.dryRun) && reconciled.ok && await instructionHookChannelInstallable(hook.tool)); + if (ready && !hookLimitProblem(hook, instructionHookText(delivery.blocks, hook.recall))) { + delivery.reached.push(hook.tool); + } + } + const cleanup = await planInstructionFiles([], {}, await retiredFilesOfReached(teamConfig, localConfig, delivery.reached), delivery.blocks); + for (const warning of cleanup.warnings) log.warn(`[${localConfig.scope}] ${warning}`); + const applied = await applyInstructionPlan(cleanup, { dryRun: Boolean(options.dryRun) }); + for (const line of applied.report) log.info(`${options.dryRun ? '[dry-run]' : `[${localConfig.scope}]`} ${line}: replacement instructions are ready`); + for (const failure of applied.failures) log.warn(`[${localConfig.scope}] ${failure}`); + } + // The hooks install the extensions and plugins that add team + // instructions for Pi, OMP and Hermes (#945); say which cannot. The + // session-start pull is silent, and doctor reports the same. + if (!options.dryRun && !options.silent) { + try { + const { instructionChannelProblems } = await import('./instruction-targets.js'); + for (const problem of await instructionChannelProblems(teamConfig, localConfig)) log.warn(`[${localConfig.scope}] ${problem}`); + } catch (e) { + log.debug(`[${localConfig.scope}] Team instruction check skipped: ${(e as Error).message}`); + } + } } catch (e) { log.debug(`[${localConfig.scope}] Hook reconcile skipped: ${(e as Error).message}`); } diff --git a/src/recall-toggle.ts b/src/recall-toggle.ts index 1e8788a64..932efaf31 100644 --- a/src/recall-toggle.ts +++ b/src/recall-toggle.ts @@ -2,22 +2,19 @@ import path from 'node:path'; import { autoDetectInit, saveLocalConfigForScope } from './config.js'; import { log } from './utils/logger.js'; import { remove, pathExists } from './utils/fs.js'; -import { removeClaudeMdSection } from './utils/claudemd.js'; -import { isToolInstalledForConfig } from './resources/base.js'; +import { applyInstructionPlan, planInstructionFiles, registerOpencodeContext, resolveInstructionTargets } from './instruction-targets.js'; import { ALL_SUPPORTED_TOOLS, agentFileExtensionForTool, type ToolName, } from './resources/agent-format.js'; -import { ruleFileExtensionForTool, writesInstructionBlock } from './resources/rule-format.js'; +import { ruleFileExtensionForTool } from './resources/rule-format.js'; import { LEGACY_RECALL_SKILL_NAMES, builtinSkillsTarget, pruneLegacyBuiltinSkills } from './builtin-skills.js'; import { resolveToolBaseDir, isRecallEnabled, isAgentExcluded, scopedToolPaths, - TEAMAI_RECALL_RULES_START, - TEAMAI_RECALL_RULES_END, type GlobalOptions, type TeamaiConfig, type LocalConfig, @@ -65,15 +62,29 @@ async function removeRecallArtifacts(teamConfig: TeamaiConfig, localConfig: Loca } } } - - // Remove recall block from CLAUDE.md - if (toolPath.claudemd) { - const claudeMdPath = path.join(baseDir, toolPath.claudemd); - if (await removeClaudeMdSection(claudeMdPath, TEAMAI_RECALL_RULES_START, TEAMAI_RECALL_RULES_END, { deleteIfEmpty: true })) { - log.debug(`Removed recall rules block from ${tool} CLAUDE.md`); - } - } } + + // Remove the recall block from every file teamai may have written it to. + await writeRecallBlock(teamConfig, localConfig, null); +} + +/** Set or remove (`null`) the recall blocks wherever teamai delivers instruction blocks. */ +async function writeRecallBlock( + teamConfig: TeamaiConfig, + localConfig: LocalConfig, + blocks: { recall: string; directRecall: string } | null, +): Promise { + const resolved = await resolveInstructionTargets(teamConfig, localConfig); + const { targets, stale } = resolved; + // Removal also reaches files no installed tool reads any more, but leaves + // their other blocks to the next pull's cleanup. + const files = blocks === null ? [...targets, ...stale] : targets; + const plan = await planInstructionFiles(files, blocks ?? { recall: null, directRecall: null }); + for (const warning of plan.warnings) log.warn(warning); + const { report, failures, files: results } = await applyInstructionPlan(plan, { dryRun: false }); + for (const line of report) log.debug(line); + for (const failure of failures) log.warn(failure); + await registerOpencodeContext(teamConfig, localConfig, resolved, false, results); } async function deployRecallArtifacts(teamConfig: TeamaiConfig, localConfig: LocalConfig): Promise { @@ -85,29 +96,8 @@ async function deployRecallArtifacts(teamConfig: TeamaiConfig, localConfig: Loca await deployBuiltinAgents(teamConfig, localConfig, { skipRecall: false }); await deployBuiltinSkills(teamConfig, localConfig); - // Inject recall rules block into CLAUDE.md for Tier-1 tools - const { injectClaudeMdSection } = await import('./utils/claudemd.js'); - const { compileRecallRulesBlock } = await import('./pull.js'); - const recallBlock = compileRecallRulesBlock(); - - for (const [tool, toolPath] of Object.entries(scopedToolPaths(teamConfig, localConfig))) { - if (isAgentExcluded(localConfig, tool)) continue; - if (!writesInstructionBlock(tool, toolPath, 'recall')) continue; - if (!await isToolInstalledForConfig(tool, toolPath.agents, localConfig)) continue; - - const baseDir = resolveToolBaseDir(tool, localConfig); - const claudeMdPath = path.join(baseDir, toolPath.claudemd); - try { - await injectClaudeMdSection( - claudeMdPath, - TEAMAI_RECALL_RULES_START, - TEAMAI_RECALL_RULES_END, - recallBlock, - ); - } catch { - // best-effort - } - } + const { compileDirectRecallRulesBlock, compileRecallRulesBlock } = await import('./pull.js'); + await writeRecallBlock(teamConfig, localConfig, { recall: compileRecallRulesBlock(), directRecall: compileDirectRecallRulesBlock() }); } export async function recallDisable(opts: GlobalOptions): Promise { diff --git a/src/resources/opencode-config.ts b/src/resources/opencode-config.ts index 71d39d3d4..3a8809908 100644 --- a/src/resources/opencode-config.ts +++ b/src/resources/opencode-config.ts @@ -50,6 +50,7 @@ export async function reconcileOpencodeInstructions( configFileAbs: string, glob: string, present: boolean, + purpose = 'rules activation', ): Promise { const exists = await pathExists(configFileAbs); @@ -70,12 +71,12 @@ export async function reconcileOpencodeInstructions( try { const parsed = JSON.parse(raw) as unknown; if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { - log.warn(`Could not parse ${configFileAbs} as a JSON object โ€” skipping OpenCode rules activation`); + log.warn(`Could not parse ${configFileAbs} as a JSON object โ€” skipping OpenCode ${purpose}`); return false; } data = parsed as Record; } catch { - log.warn(`Could not parse ${configFileAbs} โ€” skipping OpenCode rules activation`); + log.warn(`Could not parse ${configFileAbs} โ€” skipping OpenCode ${purpose}`); return false; } } @@ -103,6 +104,56 @@ export async function reconcileOpencodeInstructions( } await writeJsonAtomic(configFileAbs, data); - log.debug(`${present ? 'Added' : 'Removed'} teamai rules glob in ${configFileAbs}`); + log.debug(`${present ? 'Added' : 'Removed'} teamai ${purpose} entry in ${configFileAbs}`); return true; } + +// โ”€โ”€โ”€ OpenCode team instructions (#945) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +/** + * The `instructions` entries of an opencode.json, or null when the file is + * missing or is not a JSON object, the two cases in which pull leaves it alone. + */ +export async function readOpencodeInstructionList(configFile: string): Promise { + const raw = await readFileSafe(configFile); + if (raw === null) return null; + if (raw.trim() === '') return []; + try { + const parsed: unknown = JSON.parse(raw); + if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) return null; + const { instructions } = parsed as { instructions?: unknown }; + return Array.isArray(instructions) ? instructions : []; + } catch { + return null; + } +} + +/** + * Where OpenCode is told to load teamai's instruction file: the config file + * and its `instructions` entry. In user scope the user config holds the + * file's absolute path. In a project `.opencode/opencode.json` holds the path + * from the project root, which OpenCode resolves the same way from any + * subdirectory; the root `opencode.json` is left alone. + */ +export function opencodeContextReference(contextFile: string, scope: 'user' | 'project', projectRoot: string): { config: string; entry: string } { + if (scope === 'user') { + return { config: path.join(path.dirname(contextFile), 'opencode.json'), entry: contextFile }; + } + return { + config: path.join(projectRoot, '.opencode', 'opencode.json'), + entry: path.relative(projectRoot, contextFile).split(path.sep).join('/'), + }; +} + +/** + * OpenCode loads `~/.claude/CLAUDE.md` while its own user `AGENTS.md` does not + * exist. When teamai delivers the user blocks there for Claude, OpenCode + * already gets them, and a second file would add a duplicate. Returns the + * Claude file in that case, null otherwise. + */ +export async function opencodeClaudeFallback(home: string, targetPaths: readonly string[]): Promise { + const claudeFile = path.join(home, '.claude', 'CLAUDE.md'); + if (!targetPaths.includes(claudeFile)) return null; + if (await pathExists(path.join(home, '.config', 'opencode', 'AGENTS.md'))) return null; + return claudeFile; +} diff --git a/src/resources/rule-format.ts b/src/resources/rule-format.ts index 4fcabda62..754b85726 100644 --- a/src/resources/rule-format.ts +++ b/src/resources/rule-format.ts @@ -50,10 +50,10 @@ export type InstructionBlock = 'culture' | 'claudemd' | 'recall' | 'team-rules'; * Whether pull writes `block` into this tool's instructions file: culture and * shared instructions for every tool that has one, recall for a tool that * also has `agents`, the team rules for a tool with no rules format (the - * Codex family has an instructions file in user scope only). Pull's writers - * and uninstall both ask this, so what uninstall keeps for a remaining tool is - * what that tool's next pull refreshes. Whether the tool is installed is a - * separate question (`instructionFileInstallProbe`). + * Codex family has an instructions file in user scope only). The team-rules + * writer and doctor ask this; culture, shared instructions and recall follow + * the targets in instruction-targets.ts (#945). Whether the tool is installed + * is a separate question (`instructionFileInstallProbe`). */ export function writesInstructionBlock( tool: string, toolPath: ToolPath, block: 'recall', diff --git a/src/resources/rules.ts b/src/resources/rules.ts index 8434f4729..7ef6973c5 100644 --- a/src/resources/rules.ts +++ b/src/resources/rules.ts @@ -4,7 +4,7 @@ import type { ResourceItem, ResourceItemStatus, DeliveryTarget, TeamaiConfig, Lo import { listFilesRecursive, pathExists, copyFile, ensureDir, remove, fileContentEqual, getFileMtime, listDirs, readFileSafe, writeFile, pruneEmptyDirs, fileHash } from '../utils/fs.js'; import { log } from '../utils/logger.js'; import { TEAMAI_RULES_START, TEAMAI_RULES_END, TEAMAI_TEAM_RULES_START, TEAMAI_TEAM_RULES_END, resolveBaseDir, resolveToolBaseDir, resolveToolRootDir, isAgentExcluded, scopedToolPaths, SELF_KNOWLEDGE_SCAN_KEY } from '../types.js'; -import { EXCLUDED_RULE_NAMES, isDeployedRecallRule } from '../builtin-rules.js'; +import { EXCLUDED_RULE_NAMES, isDeployedRecallRule, TEAMAI_CONTEXT_RULE_NAME } from '../builtin-rules.js'; import { teamRuleToCursorMdc, mergeCursorBodyIntoTeamMd, cursorMdcBodyEqualsTeamMd } from './cursor-mdc.js'; import { copilotInstructionsBodyEqualsTeamMd, @@ -201,7 +201,9 @@ export class RulesHandler extends ResourceHandler { const files = await listFilesRecursive(rulesDir); return files - .filter((f) => f.endsWith('.md')) + // teamai-context is the file pull writes the team instructions to; a team + // rule of that name would land on it (#945). pullAllRules names it. + .filter((f) => f.endsWith('.md') && f !== `${TEAMAI_CONTEXT_RULE_NAME}.md`) .map((f) => ({ name: f.replace(/\.md$/, ''), type: 'rules' as const, @@ -297,7 +299,9 @@ export class RulesHandler extends ResourceHandler { */ private async localNameFor(teamName: string, localConfig: LocalConfig): Promise { const bareName = path.basename(teamName); - if (bareName === teamName) return teamName; + // teamai-context at the root is teamai's instruction file (#945), so a + // placed rule of that name keeps its namespaced path. + if (bareName === teamName || bareName === TEAMAI_CONTEXT_RULE_NAME) return teamName; const placed = placedResourcePath( (await loadStateForScope(localConfig)).placedRules, 'rules', bareName, ); @@ -429,6 +433,41 @@ export class RulesHandler extends ResourceHandler { return removed; } + /** + * Remove the copy of a team rule named teamai-context an earlier release + * delivered to a rules directory (#945): that path is teamai's own + * instruction file now. A copy goes only without teamai's blocks and when + * the record shows it unchanged or it matches what pull rendered for the + * team's rule; any other is kept, and the instruction sync names it. + */ + async reclaimReservedRuleCopies( + teamConfig: TeamaiConfig, + localConfig: LocalConfig, + ledger: DeliveryLedger | undefined, + ): Promise { + const { holdsInstructionBlocks } = await import('../instruction-targets.js'); + const relativePath = `rules/${TEAMAI_CONTEXT_RULE_NAME}.md`; + const rule: ResourceItem = { + name: TEAMAI_CONTEXT_RULE_NAME, type: 'rules', relativePath, sourcePath: path.join(localConfig.repo.localPath, relativePath), + }; + let deliveredRevs: readonly string[] | undefined; + for (const [tool, toolPath] of Object.entries(scopedToolPaths(teamConfig, localConfig))) { + if (!toolPath.rules || isAgentExcluded(localConfig, tool)) continue; + const file = path.join(resolveToolBaseDir(tool, localConfig), toolPath.rules, `${TEAMAI_CONTEXT_RULE_NAME}${ruleFileExtensionForTool(tool)}`); + if (!await pathExists(file) || await holdsInstructionBlocks(file)) continue; + const recorded = ledger?.previous?.[file]; + deliveredRevs ??= ( + await (await import('../pull.js')).resolveCheckoutBases(localConfig, await loadStateForScope(localConfig)) + ).revs; + const delivered = (recorded !== undefined && recorded === await fileHash(file)) + || await isDeliveredRender(tool, file, rule, localConfig.repo.localPath, deliveredRevs); + if (!delivered) continue; + await remove(file); + if (ledger) forgetDelivered(ledger.hashes, file); + log.info(`Removed ${file}, the copy of the team rule ${TEAMAI_CONTEXT_RULE_NAME} an earlier release delivered: the team instructions go there now`); + } + } + /** * Distribute rule files to each tool's rules/ directory, then update * CLAUDE.md with a lightweight reference list instead of inlining content. @@ -447,6 +486,11 @@ export class RulesHandler extends ResourceHandler { ledger?: DeliveryLedger, ): Promise { const rules = filteredRules ?? await this.scanTeamForPull(teamConfig, localConfig); + if (await pathExists(path.join(localConfig.repo.localPath, 'rules', `${TEAMAI_CONTEXT_RULE_NAME}.md`))) { + log.warn(`rules/${TEAMAI_CONTEXT_RULE_NAME}.md is not delivered: ${TEAMAI_CONTEXT_RULE_NAME} is the name of teamai's own instruction file ` + + 'in each rules directory. Rename the rule in the team repo, for example with `git mv`, and push the change.'); + } + await this.reclaimReservedRuleCopies(teamConfig, localConfig, ledger); // Hermes: inline all team rules into a teamai-managed block in SOUL.md // (user-level standing instructions). Only when Hermes is actually @@ -623,6 +667,10 @@ export class RulesHandler extends ResourceHandler { const block = await teamRulesBlock(rules); for (const [tool, toolPath] of Object.entries(scopedToolPaths(teamConfig, localConfig))) { if (!writesInstructionBlock(tool, toolPath, 'team-rules')) continue; + // Its session hook carries the rules in this scope, and a project file + // such as AGENTS.md belongs to the project (#945). + const { deliversInstructionsByHook } = await import('../instruction-targets.js'); + if (deliversInstructionsByHook(tool, localConfig.scope)) continue; const file = path.join(resolveToolBaseDir(tool, localConfig), toolPath.claudemd); const probe = instructionFileInstallProbe(tool, toolPath); const active = !isAgentExcluded(localConfig, tool) diff --git a/src/types.ts b/src/types.ts index deab01b95..9dcc42884 100644 --- a/src/types.ts +++ b/src/types.ts @@ -825,6 +825,12 @@ export const StateSchema = z.object({ * literals stay valid; the reconciler treats absent as an empty map. */ coAuthorManaged: z.record(z.string(), z.boolean()).optional(), + /** + * OpenCode configs whose `instructions` entry for teamai's context file + * teamai added, as `{ config, entry }`, so uninstall removes the entry even + * after the member stripped the markers from that file (#945). + */ + opencodeContextEntries: z.array(z.object({ config: z.string(), entry: z.string() })).optional(), lastUpdateCheck: z.string().nullable().default(null), availableUpdate: z.string().nullable().default(null), }); diff --git a/src/uninstall.ts b/src/uninstall.ts index 301fb70ac..1f5cb8804 100644 --- a/src/uninstall.ts +++ b/src/uninstall.ts @@ -33,7 +33,7 @@ import { type Scope, type ManagedMcpManifest, } from './types.js'; -import { BUILTIN_RULE_NAMES } from './builtin-rules.js'; +import { BUILTIN_RULE_NAMES, TEAMAI_CONTEXT_RULE_NAME } from './builtin-rules.js'; import { ruleStemFromFilename, writesInstructionBlock, type InstructionBlock } from './resources/rule-format.js'; import { agentStemFromFilename } from './resources/agent-format.js'; import { resolveDocsDestination } from './resources/docs.js'; @@ -41,7 +41,6 @@ import { listTeamAgentDirs } from './resources/agents.js'; import { RulesHandler } from './resources/rules.js'; import { deliveredHashes } from './pull.js'; import { isToolInstalledForConfig } from './resources/base.js'; -import { removeClaudeMdSection } from './utils/claudemd.js'; import { BUILTIN_AGENT_NAMES } from './builtin-agents.js'; import { BUILTIN_SKILL_NAMES, @@ -53,7 +52,9 @@ import { skillsGuardBase, } from './builtin-skills.js'; import { getHermesHome } from './hermes-home.js'; +import { CODEX_TOOL_IDS } from './utils/tool-names.js'; import { CODEX_TOOL, SHARED_AGENT_SKILLS_PATH } from './resources/skills.js'; +import { clearInstructionFile, instructionTargetFile, retiredInstructionFiles, resolveInstructionTargets } from './instruction-targets.js'; import { pathExists, readFileSafe, @@ -99,7 +100,9 @@ interface RemovalPlan { /** Manifest used by the primary hook injection scope. */ hookManifestPath: string; /** Instruction files (CLAUDE.md, AGENTS.md, โ€ฆ), each with the teamai blocks to strip from it. */ - claudeMdFiles: Array<{ path: string; blocks: Array<[string, string]> }>; + /** `owned`: teamai's generated file, which goes with its last block; else a member's file. */ + claudeMdFiles: Array<{ path: string; blocks: Array<[string, string]>; owned: boolean }>; + opencodeInstructions: OpencodeInstruction[]; /** * Skill directories synced from team repo, each with the base directory its * skills root hangs off: the prune refuses a link anywhere below that base. @@ -134,6 +137,10 @@ interface RemovalPlan { hermesCleanup: boolean; /** Scope being uninstalled (issue #73: surfaced to the user). */ scope: Scope; + /** Whether this removal takes the machine-wide adapters: a user-scope uninstall only. */ + globalAdapters: boolean; + /** Machine-wide adapters a project uninstall keeps for other installs; `teamai hooks remove` takes them. */ + keptGlobal: string[]; } /** Per-tool findings collected during discovery (tool-specific resources only). */ @@ -143,6 +150,12 @@ interface SkillDirEntry { baseDir: string; } +/** An entry teamai added to an OpenCode config's `instructions`. */ +interface OpencodeInstruction { + config: string; + entry: string; +} + interface ToolResources { hookFiles: Array<{ path: string; tool: string; manifestPath: string }>; openclawHookDirs: Array<{ hooksDir: string; tool: string }>; @@ -151,6 +164,12 @@ interface ToolResources { piHookFiles: string[]; dshHookFile: string | null; claudeMdFiles: string[]; + /** Files an earlier release wrote this tool's instruction blocks to; no tool reads them now (#945). */ + retiredInstructionFiles: string[]; + /** teamai's entries in OpenCode's `instructions`, whether or not their file still holds blocks (#945). */ + opencodeInstructions: OpencodeInstruction[]; + /** Machine-wide adapters this project uninstall keeps (`RemovalPlan.keptGlobal`). */ + keptGlobal: string[]; skillDirs: SkillDirEntry[]; ruleFiles: string[]; keptRuleFiles: string[]; @@ -166,6 +185,8 @@ function hasToolResources(r: ToolResources): boolean { r.piHookFiles.length > 0 || r.dshHookFile !== null || r.claudeMdFiles.length > 0 || + r.retiredInstructionFiles.length > 0 || + r.opencodeInstructions.length > 0 || r.skillDirs.length > 0 || r.ruleFiles.length > 0 || r.agentFiles.length > 0 @@ -190,13 +211,15 @@ const INSTRUCTION_BLOCK_STARTS: Record = { }; /** - * Start markers of the blocks a pull writes into a tool's `claudemd` file, - * as pull's writers decide it (`writesInstructionBlock`). Nobody writes the + * Start markers of the blocks a pull writes into a tool's instruction target + * (#945): culture, claudemd and recall always (a tool without the + * `teamai-recall` subagent gets the direct variant, under the same markers), + * and team rules where `writesInstructionBlock` says so. Nobody writes the * legacy `[teamai:rules]` block any more. */ function instructionBlocksWrittenBy(tool: string, toolPath: TeamaiConfig['toolPaths'][string]): string[] { return (Object.keys(INSTRUCTION_BLOCK_STARTS) as InstructionBlock[]) - .filter((block) => writesInstructionBlock(tool, toolPath, block)) + .filter((block) => block !== 'team-rules' || writesInstructionBlock(tool, toolPath, block)) .map((block) => INSTRUCTION_BLOCK_STARTS[block]); } @@ -309,10 +332,16 @@ async function discoverToolResources( * HOME forever. */ hookSettingsPath?: string, + /** + * Whether the machine-wide Pi/OMP extensions and Codex user hooks go too. + * Only a user-scope uninstall takes them: a project cannot tell whether an + * HTTP agent, a self-mode project or another checkout still uses them (#945). + */ + globalAdapters = scope === 'user', ): Promise { const res: ToolResources = { hookFiles: [], openclawHookDirs: [], opencodeHookScopes: [], ompHookFile: null, piHookFiles: [], dshHookFile: null, - claudeMdFiles: [], skillDirs: [], ruleFiles: [], keptRuleFiles: [], agentFiles: [], + claudeMdFiles: [], retiredInstructionFiles: [], opencodeInstructions: [], keptGlobal: [], skillDirs: [], ruleFiles: [], keptRuleFiles: [], agentFiles: [], }; // (a) Hooks โ€” settings.json / hooks.json @@ -352,10 +381,11 @@ async function discoverToolResources( // OMP hooks are a single teamai-managed TS extension in the user agent dir // (~/.omp/agent/extensions/teamai-hooks.ts) โ€” the adapter never writes a // project copy, so there is just the one place to look. - const { resolveOmpExtensionsDir, OMP_HOOK_FILE } = await import('./omp-hooks.js'); + const { hasOmpHooks, resolveOmpExtensionsDir, OMP_HOOK_FILE } = await import('./omp-hooks.js'); const extFile = path.join(resolveOmpExtensionsDir(), OMP_HOOK_FILE); - if (await pathExists(extFile)) { - res.ompHookFile = extFile; + if (await hasOmpHooks()) { + if (globalAdapters) res.ompHookFile = extFile; + else res.keptGlobal.push(extFile); } } else if (tool === 'pi') { const { @@ -365,14 +395,11 @@ async function discoverToolResources( resolvePiProjectExtensionsDir, PI_HOOK_FILE, } = await import('./pi-hooks.js'); - // Mirrors OMP: a targeted uninstall removes the single global extension - // outright, regardless of scope. Pi has no way to scope a shared file to - // one project โ€” the generated extension fires for every Pi session - // machine-wide โ€” so a scoped "preserve for other projects" guarantee was - // never actually enforceable, and pretending otherwise just left Pi still - // firing hooks for a project that had supposedly uninstalled it. + // Project uninstall owns only legacy project copies while other installs + // still use the single global extension and server-pushed agent hooks. if (await hasPiHooks()) { - res.piHookFiles.push(path.join(resolvePiExtensionsDir(), PI_HOOK_FILE)); + if (globalAdapters) res.piHookFiles.push(path.join(resolvePiExtensionsDir(), PI_HOOK_FILE)); + else res.keptGlobal.push(path.join(resolvePiExtensionsDir(), PI_HOOK_FILE)); } // Server-pushed agent hooks (teamai-agent-.ts) always install into // the global extension dir and can exist without the main lifecycle @@ -380,7 +407,7 @@ async function discoverToolResources( // leftover-plugin pattern so a Pi-only agent-hook install isn't missed. // Each match is marker-checked by its own slug so a same-named file a // user authored by hand is never swept up. - for (const file of await listFiles(resolvePiExtensionsDir())) { + for (const file of globalAdapters ? await listFiles(resolvePiExtensionsDir()) : []) { const base = path.basename(file); if (!base.startsWith('teamai-agent-') || !base.endsWith('.ts')) continue; const slug = base.slice('teamai-agent-'.length, -'.ts'.length); @@ -406,6 +433,12 @@ async function discoverToolResources( ? (hookSettingsPath ?? toolPath.settings) : toolPath.settings; const settingsPath = path.join(hookBaseDir, settingsRel); + // Other installs use Codex's user hooks as their instruction channel. + if (!globalAdapters && CODEX_TOOL_IDS.some((id) => id === tool) + && path.resolve(hookBaseDir) === path.resolve(getUserHome())) { + if (await pathExists(settingsPath) && await hasTeamaiHooks(settingsPath, tool, manifestPath)) res.keptGlobal.push(settingsPath); + continue; + } if (await pathExists(settingsPath) && (await hasTeamaiHooks(settingsPath, tool, manifestPath) || isEmptyHooksResidue(await readJson>(settingsPath)))) { @@ -433,13 +466,23 @@ async function discoverToolResources( } // (b) CLAUDE.md teamai section blocks - if (toolPath.claudemd) { - const claudeMdPath = path.join(baseDir, toolPath.claudemd); + const instructionFile = instructionTargetFile(tool, toolPath, scope); + if (instructionFile) { + const claudeMdPath = path.resolve(baseDir, instructionFile); const content = await readFileSafe(claudeMdPath); if (content && CLAUDEMD_MARKER_PAIRS.some(([start]) => content.includes(start))) { res.claudeMdFiles.push(claudeMdPath); } } + // OpenCode's instructions entry goes only when teamai recorded adding it + // (buildRemovalPlan): an entry the member listed is theirs, whatever the file holds. + for (const retired of retiredInstructionFiles(tool, toolPath, scope)) { + const file = path.resolve(baseDir, retired); + const content = await readFileSafe(file); + if (content && CLAUDEMD_MARKER_PAIRS.some(([start]) => content.includes(start))) { + res.retiredInstructionFiles.push(file); + } + } // (c) Skills โ€” only those matching team repo if (toolPath.skills) { @@ -492,6 +535,12 @@ async function discoverToolResources( const ruleName = ruleStemFromFilename(file); if (ruleName === null) continue; if (teamRuleNames.has(ruleName)) { + // teamai's instruction file shares the reserved name; its blocks are + // stripped above, keeping what another tool or the member still uses. + if (ruleName === TEAMAI_CONTEXT_RULE_NAME) { + const text = await readFileSafe(path.join(rulesDir, file)); + if (text && CLAUDEMD_MARKER_PAIRS.some(([start]) => text.includes(start))) continue; + } res.ruleFiles.push(path.join(rulesDir, file)); } } @@ -578,6 +627,7 @@ async function buildRemovalPlan( const hookToolPaths = scopedToolPaths(teamConfig, { ...localConfig, scope: primaryHookScope.scope }); const toolPaths = scopedToolPaths(teamConfig, localConfig); const perTool = new Map(); + const globalAdapters = localConfig.scope === 'user'; for (const [tool, toolPath] of Object.entries(toolPaths)) { perTool.set( tool, @@ -593,10 +643,32 @@ async function buildRemovalPlan( standaloneHookManifestPath, localConfig.scope, hookToolPaths[tool]?.settings, + globalAdapters, ), ); } + // OpenCode's instructions entry is teamai's only when pull recorded adding + // it, whatever the context file holds now. No release before #945 added + // this entry, so there are no unrecorded teamai entries to migrate. + const opencodeRes = perTool.get('opencode'); + if (opencodeRes) { + const { loadStateForScope } = await import('./config.js'); + const { opencodeContextReference, readOpencodeInstructionList } = await import('./resources/opencode-config.js'); + const contextFile = toolPaths.opencode && instructionTargetFile('opencode', toolPaths.opencode, localConfig.scope); + // Worktrees share state.json: only this checkout's own record counts. + const own = contextFile + ? opencodeContextReference(path.resolve(resolveToolBaseDir('opencode', localConfig), contextFile), localConfig.scope, resolveToolBaseDir('opencode', localConfig)) + : undefined; + const recorded = (await loadStateForScope(localConfig)).opencodeContextEntries ?? []; + if (own && recorded.some((ref) => ref.config === own.config && ref.entry === own.entry)) { + const listed = await readOpencodeInstructionList(own.config); + if (listed?.includes(own.entry) || (listed === null && await pathExists(own.config))) { + opencodeRes.opencodeInstructions.push(own); + } + } + } + // (d) continued: the copies a release made in a tool's legacy rules // directory, before its rules moved into its instructions file, which a pull // may not have reclaimed yet. A name is no proof there: only the copies a @@ -617,7 +689,9 @@ async function buildRemovalPlan( // tool that was never enabled or set up. The probe path must be a // tool-specific root (skills/rules/settings), never `claudemd`: that's // exactly the shared, ambiguous path this check exists to disambiguate. - const activeTools = new Set(); + const { hooks: instructionHooks } = await resolveInstructionTargets(teamConfig, localConfig); + const hookTools = new Set(instructionHooks.map((hook) => hook.tool)); + const activeTools = new Set(hookTools); for (const [tool, toolPath] of Object.entries(toolPaths)) { if (isAgentExcluded(localConfig, tool)) continue; const probePath = toolPath.skills ?? toolPath.rules ?? toolPath.settings ?? toolPath.claudemd; @@ -635,11 +709,13 @@ async function buildRemovalPlan( const targetHasResources = targetRes ? hasToolResources(targetRes) : false; // Other tools still have teamai resources โ†’ keep shared resources. const othersHaveResources = [...perTool.entries()] - .some(([t, r]) => t !== agentFilter && activeTools.has(t) && hasToolResources(r)); + .some(([t, r]) => t !== agentFilter && activeTools.has(t) && (hasToolResources(r) || hookTools.has(t))); // Remove shared resources only when the target itself has resources AND is // the last tool using teamai. Targeting a tool with no teamai resources is a // no-op for shared resources (plan will be empty โ†’ "Nothing to uninstall"). includeShared = targetHasResources && !othersHaveResources; + // Keep this project's config so the global hook can read its exclusion. + if (!globalAdapters && CODEX_TOOL_IDS.some((id) => id === agentFilter)) includeShared = false; } else { toolsToMerge = [...perTool.keys()]; includeShared = true; @@ -654,6 +730,7 @@ async function buildRemovalPlan( dshHookFile: null, hookManifestPath: hookTargets[0].manifestPath, claudeMdFiles: [], + opencodeInstructions: [], skillDirs: [], ruleFiles: [], keptRuleFiles: [], @@ -666,12 +743,14 @@ async function buildRemovalPlan( teamaiHomeExists: includeShared && await pathExists(teamaiHome), unpublishedQueues: includeShared ? await listQueuesIn(teamaiHome) : [], includeShared, - hermesCleanup: toolsToMerge.includes('hermes'), + hermesCleanup: globalAdapters && toolsToMerge.includes('hermes'), scope: localConfig.scope, + globalAdapters, + keptGlobal: [], }; - // A single instruction file can be the native target for several agents - // (for example project `AGENTS.md` is shared by Pi, Hermes, WorkBuddy, Codex). + // A single instruction file can be the target of several agents (for + // example CodeBuddy and WorkBuddy share `.codebuddy/rules/teamai-context.md`). // Keep a TeamAI block when another enabled, installed agent that maps the // same file would write that block; the rest go, since no remaining agent's // pull would ever refresh or remove them. @@ -696,13 +775,27 @@ async function buildRemovalPlan( if (res.ompHookFile) plan.ompHookFile = res.ompHookFile; plan.piHookFiles.push(...res.piHookFiles); if (res.dshHookFile) plan.dshHookFile = res.dshHookFile; + plan.opencodeInstructions.push(...res.opencodeInstructions); + plan.keptGlobal.push(...res.keptGlobal); for (const file of res.claudeMdFiles) { if (plan.claudeMdFiles.some((entry) => entry.path === file)) continue; const content = await readFileSafe(file) ?? ''; const kept = retainedBlocks.get(file); const blocks = CLAUDEMD_MARKER_PAIRS .filter(([start]) => content.includes(start) && !kept?.has(start)); - if (blocks.length > 0) plan.claudeMdFiles.push({ path: file, blocks }); + // The configured `claudemd` (no `rules`) is the member's, whatever its name. + const owned = instructionTargetFile(tool, toolPaths[tool], localConfig.scope) !== toolPaths[tool].claudemd; + if (blocks.length > 0) plan.claudeMdFiles.push({ path: file, blocks, owned }); + } + // A retired file keeps only the blocks a remaining tool still writes + // there, which a team's toolPaths can make it. + for (const file of res.retiredInstructionFiles) { + if (plan.claudeMdFiles.some((entry) => entry.path === file)) continue; + const content = await readFileSafe(file) ?? ''; + const kept = retainedBlocks.get(file); + const blocks = CLAUDEMD_MARKER_PAIRS.filter(([start]) => content.includes(start) && !kept?.has(start)); + // Retired paths are configured member files, whatever their basename. + if (blocks.length > 0) plan.claudeMdFiles.push({ path: file, blocks, owned: false }); } plan.skillDirs.push(...res.skillDirs); plan.ruleFiles.push(...res.ruleFiles); @@ -710,6 +803,12 @@ async function buildRemovalPlan( plan.agentFiles.push(...res.agentFiles); } + // Hermes' plugin is machine-wide too: a project uninstall names it as kept. + if (!globalAdapters && toolsToMerge.includes('hermes')) { + const { getInstructionsPluginDir, ownsInstructionsPlugin } = await import('./hermes-hooks.js'); + if (await pathExists(getInstructionsPluginDir()) && await ownsInstructionsPlugin()) plan.keptGlobal.push(getInstructionsPluginDir()); + } + if (includeShared) { // (d3) teamai-managed MCP servers, tracked in managed-mcp.json (same // ownership model as hooks). Project scope reads THIS worktree's own @@ -797,6 +896,7 @@ function isPlanEmpty(plan: RemovalPlan): boolean { plan.piHookFiles.length === 0 && plan.dshHookFile === null && plan.claudeMdFiles.length === 0 && + plan.opencodeInstructions.length === 0 && plan.skillDirs.length === 0 && plan.ruleFiles.length === 0 && plan.agentFiles.length === 0 && @@ -845,6 +945,12 @@ function printSummary(plan: RemovalPlan, agentFilter?: string): void { console.log(''); } + if (plan.opencodeInstructions.length > 0) { + console.log(' OpenCode instructions entries:'); + for (const { config, entry } of plan.opencodeInstructions) console.log(` ${entry} in ${config}`); + console.log(''); + } + if (plan.ompHookFile !== null) { console.log(' OMP Hook (extension):'); console.log(` ${plan.ompHookFile}`); @@ -938,6 +1044,13 @@ function printSummary(plan: RemovalPlan, agentFilter?: string): void { console.log(' Run `teamai pull` to publish them first, or copy them somewhere safe.'); console.log(''); } + + if (plan.keptGlobal.length > 0) { + console.log('โ„น Kept for other teamai installs on this machine (user scope, HTTP agent or other projects):'); + for (const file of plan.keptGlobal) console.log(` ${file}`); + console.log(' If none of them uses these, cancel and run `teamai hooks remove` here first: it removes them.'); + console.log(''); + } } // โ”€โ”€โ”€ Execution โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ @@ -956,7 +1069,8 @@ async function teardownPlugins(): Promise { } } -async function executeRemoval(plan: RemovalPlan): Promise { +async function executeRemoval(plan: RemovalPlan): Promise { + const pendingOpencode: RemovalPlan['opencodeInstructions'] = []; // (a) Remove hooks from tool settings (built-in A + team B via the manifest). // Each settings entry carries the manifest for its own location (HOME/user // or a legacy /project copy), so team hooks are stripped at the @@ -1033,24 +1147,36 @@ async function executeRemoval(plan: RemovalPlan): Promise { // heavy dependency graph out of uninstall's static import chain. Best-effort. try { const { removeAllAgentHooks } = await import('./local-agent.js'); - await removeAllAgentHooks(); + if (plan.globalAdapters) await removeAllAgentHooks(); } catch (e) { log.warn(`Failed to remove agent hooks: ${(e as Error).message}`); } // (b) Clean CLAUDE.md teamai section blocks - for (const { path: claudeMdPath, blocks } of plan.claudeMdFiles) { + for (const { path: claudeMdPath, blocks, owned } of plan.claudeMdFiles) { try { // A file teamai created goes with its last block; a member's file, // even an empty one, stays. - for (const [startMarker, endMarker] of blocks) { - await removeClaudeMdSection(claudeMdPath, startMarker, endMarker, { deleteIfEmpty: true }); - } - log.success(`Cleaned ${claudeMdPath}`); + const { changed, warnings } = await clearInstructionFile(claudeMdPath, blocks.map(([start]) => start), owned); + for (const warning of warnings) log.warn(warning); + if (changed) log.success(`Cleaned ${claudeMdPath}`); } catch (e) { log.warn(`Failed to clean ${claudeMdPath}: ${(e as Error).message}`); } } + // OpenCode loads its file through an `instructions` entry teamai added: it + // goes even when the member's text keeps the file, or the file is gone. + for (const { config, entry } of plan.opencodeInstructions) { + try { + const { reconcileOpencodeInstructions } = await import('./resources/opencode-config.js'); + if (await reconcileOpencodeInstructions(config, entry, false, 'team instructions')) log.success(`Removed "${entry}" from the instructions of ${config}`); + } catch (e) { + log.warn(`Failed to remove "${entry}" from the instructions of ${config}: ${(e as Error).message}`); + } + const { readOpencodeInstructionList } = await import('./resources/opencode-config.js'); + const listed = await readOpencodeInstructionList(config); + if (listed === null || listed.includes(entry)) pendingOpencode.push({ config, entry }); + } // (c) Remove synced skills. // @@ -1157,7 +1283,7 @@ async function executeRemoval(plan: RemovalPlan): Promise { } // (g) Remove ~/.teamai/ directory (last โ€” earlier steps read from it) - if (plan.teamaiHomeExists) { + if (plan.teamaiHomeExists && pendingOpencode.length === 0) { // Tear down plugins first: their manifest/config live under ~/.teamai/local-agent. await teardownPlugins(); try { @@ -1181,10 +1307,19 @@ async function executeRemoval(plan: RemovalPlan): Promise { log.debug(`Hermes uninstall cleanup skipped: ${(e as Error).message}`); } } + return pendingOpencode; } // โ”€โ”€โ”€ Public API โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +async function excludeUninstalledAgent(config: LocalConfig, agent: string): Promise { + // Keep an absent whitelist meaning "all other tools". + if (config.enabledAgents) config.enabledAgents = config.enabledAgents.filter((tool) => tool !== agent); + config.disabledAgents = [...new Set([...config.disabledAgents ?? [], agent])]; + if (config.scope === 'project') await saveLocalConfigForScope(config, config.scope, config.projectRoot); + else await saveLocalConfig(config); +} + export async function uninstall(opts: UninstallOptions): Promise { let localConfig: LocalConfig | null = null; let teamConfig: TeamaiConfig | null = null; @@ -1222,12 +1357,15 @@ export async function uninstall(opts: UninstallOptions): Promise { ); } - if (isPlanEmpty(plan)) { + const exclusionOnly = isPlanEmpty(plan) && agentKey && localConfig.scope === 'project' + && ['pi', 'omp', 'hermes', ...CODEX_TOOL_IDS].includes(agentKey); + if (isPlanEmpty(plan) && !exclusionOnly) { log.info('Nothing to uninstall'); return; } printSummary(plan, agentKey); + if (exclusionOnly) log.info(`Exclude ${agentKey} from this project; keep its global delivery channel.`); if (opts.dryRun) { log.info('Dry run โ€” no changes made'); @@ -1242,6 +1380,13 @@ export async function uninstall(opts: UninstallOptions): Promise { } } + if (exclusionOnly) { + // Exclusion is a config write even when there are no local files to delete. + await excludeUninstalledAgent(localConfig, agentKey!); + log.success(`Excluded ${agentKey} from this project; its global delivery channel is kept for other teamai installs on this machine. If none uses it, run \`teamai hooks remove\` to remove it.`); + return; + } + // Model profiles are machine-global, independent of a project's resources. // Only removal of the user-scope TeamAI home may restore them. Run this // gate before MCP cleanup so a model conflict cannot partially uninstall @@ -1326,32 +1471,38 @@ export async function uninstall(opts: UninstallOptions): Promise { } } - await executeRemoval(plan); + const pendingOpencode = await executeRemoval(plan); + + // The OpenCode entries uninstall removed are no longer teamai's to track; + // one still listed (the write failed) stays recorded for the next try. + if (plan.opencodeInstructions.length > 0 && (!plan.includeShared || pendingOpencode.length > 0)) { + const { loadStateForScope, saveStateForScope } = await import('./config.js'); + const state = await loadStateForScope(localConfig!); + if (state.opencodeContextEntries) { + const removed = plan.opencodeInstructions.filter((ref) => !pendingOpencode.some( + (pending) => pending.config === ref.config && pending.entry === ref.entry, + )); + state.opencodeContextEntries = state.opencodeContextEntries.filter( + (ref) => !removed.some((e) => e.config === ref.config && e.entry === ref.entry), + ); + await saveStateForScope(state, localConfig!); + } + } // Persist the exclusion so the next pull (or another tool's session-start // hook) does not resurrect this tool's resources. Only meaningful when the // shared ~/.teamai home survives (non-last-tool uninstall); on a last-tool // uninstall the home is deleted and there is nothing to persist. - if (agentKey && !plan.includeShared) { - const cfg = localConfig!; - // Only prune an existing whitelist. Leaving `enabledAgents` undefined - // (meaning "all tools") as-is is important: collapsing it to [] would be - // read by the hook path as "whitelist nothing" and stop hook sync for the - // remaining tools too. The disabledAgents exclusion below is what actually - // keeps the uninstalled tool out on the next pull. - if (cfg.enabledAgents) { - cfg.enabledAgents = cfg.enabledAgents.filter((t) => t !== agentKey); - } - const prevDisabled = cfg.disabledAgents ?? []; - cfg.disabledAgents = [...new Set([...prevDisabled, agentKey])]; - if (cfg.scope === 'project') { - await saveLocalConfigForScope(cfg, cfg.scope, cfg.projectRoot); - } else { - await saveLocalConfig(cfg); - } + if (agentKey && (!plan.includeShared || pendingOpencode.length > 0)) { + await excludeUninstalledAgent(localConfig, agentKey); } - log.success('teamai uninstalled'); + if (pendingOpencode.length > 0) { + log.warn(`Uninstall incomplete: kept ${plan.teamaiHome} and OpenCode ownership so removal can be retried. Repair permissions or JSON in ${pendingOpencode.map((ref) => ref.config).join(', ')}, then run the same uninstall command again.`); + process.exitCode = 1; + } else { + log.success('teamai uninstalled'); + } } else { // Minimal uninstall โ€” just try to remove ~/.teamai/ if (opts.agent) { diff --git a/src/utils/fs.ts b/src/utils/fs.ts index f298c83cc..d6f8d79f7 100644 --- a/src/utils/fs.ts +++ b/src/utils/fs.ts @@ -337,6 +337,20 @@ export async function pathExists(p: string): Promise { return fse.pathExists(expandHome(p)); } +/** + * Who wrote a file teamai generates into another tool's directory: nobody yet, + * teamai (its content carries `marker`), or someone else. teamai writes and + * removes only `absent` and `teamai` files, so a same-named file of the + * user's is never overwritten or deleted. + */ +export type GeneratedFileState = 'absent' | 'teamai' | 'foreign'; + +export async function generatedFileState(file: string, marker: string): Promise { + const content = await readFileSafe(file); + if (content === null) return await pathExists(file) ? 'foreign' : 'absent'; + return content.includes(marker) ? 'teamai' : 'foreign'; +} + /** * Remove a file or directory */