diff --git a/CHANGELOG.md b/CHANGELOG.md index fd5fc26f3..7ae3ad7d5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -45,6 +45,24 @@ All notable changes to this project will be documented in this file. See [standa ### ๐Ÿ› Bug Fixes +- Kiro and Qoder (and Qoder CN) now get each rule in their own rules format. Kiro ignores `paths:`, so the verbatim copy pull wrote applied every rule everywhere, and Qoder documents `paths:` only for its CLI, not for Desktop. Kiro steering files get `inclusion: fileMatch` with a `fileMatchPattern` list, or `inclusion: always`; Qoder rules get `trigger: glob` with one comma-separated `glob:` line, `{a,b}` expanded, or `trigger: always_on`. The first pull after upgrading rewrites a copy still holding what teamai delivered, even when the team repo has not moved; a copy you edited is kept and named. `teamai push` sends back only the body, and a steering or Qoder rule file with no matching team rule is the member's own: pull no longer deletes it, and push no longer offers it as a new team rule. `teamai doctor` compares each copy with the new render and its fix names the fields that tool scopes by (for [#946](https://github.com/Tencent/teamai-cli/issues/946)). +- Kiro now gets a namespaced rule as a flat steering file, `rules/fe/style.md` as `fe.style.md`, as Oh My Pi does: Kiro reads only the top level of `.kiro/steering` and `~/.kiro/steering` ([kirodotdev/Kiro#10448](https://github.com/kirodotdev/Kiro/issues/10448)), so the nested copy pull wrote was never read. `teamai push` sends an edit of the flat file back to `rules/fe/style.md`, and the flat-name collision, tombstone, `remove`, `uninstall` and `doctor` handling follows. A removed dotted root rule cannot delete a live namespaced flat copy. Push and its pre-sync require a delivery record before treating a flat-name file as a team copy. A targeted uninstall names only the edited flat copies of its target tool. The first pull after upgrading writes the flat file and removes the old nested copy unless you edited it (then it names it), even when the team repo has not moved and with no delivery record (for [#946](https://github.com/Tencent/teamai-cli/issues/946)). +- Oh My Pi now gets each rule in its own rules format, in `.omp/rules` and `~/.omp/agent/rules`. A rule copied verbatim had neither `alwaysApply` nor a `description`, so OMP dropped every one, and a namespaced rule in a subdirectory was never read, since OMP reads only the top level. An unscoped rule gets `alwaysApply: true`; a scoped one gets `globs` and a `description` (its first heading, or a line naming its globs), so OMP lists it in its rulebook; a namespaced rule is written flat, `rules/fe/style.md` as `fe.style.md`, and `teamai push` sends an edit of it back to `rules/fe/style.md`. A namespaced rule whose flat name another rule you receive has is not written, pull names both, and `doctor` fails; a file of your own with that name is never overwritten or removed, and a flat copy you edited after delivery is kept and named. The first pull after upgrading rewrites a copy still holding what teamai delivered, writes the flat copy of a namespaced rule, and removes the old nested copy unless you edited it (then it names it), even when the team repo has not moved and with no delivery record. `remove`, tombstones, `uninstall` and `doctor` follow the flat name (for [#946](https://github.com/Tencent/teamai-cli/issues/946)). +- A rule scoped with an unquoted glob such as `paths: **/*.ts` lost its scope on every render after the first in a run, so Cursor, JoyCode or doctor could see it as always on: the frontmatter parse kept a failed attempt in gray-matter's cache. A `paths:` string is now split on its top-level commas only, so `src/{a,b}/**` stays one glob for Cursor instead of becoming `src/{a, b}/**`. Copilot read `paths: **/*.ts` as no scope at all and applied the rule to every file, and the inlined rules text (Codex, ZCode, DeepSeek Harness, Pi, Hermes, OpenClaw) dropped its `Applies to files matching` line, and the knowledge search index logged `Failed to parse frontmatter` for it on every pull and indexed the frontmatter as body text; all three now parse `paths:` the same way (for [#946](https://github.com/Tencent/teamai-cli/issues/946)). +- teamai's OpenClaw hook runs. Its handler read an `event` field OpenClaw never sends and subscribed to `session:start`, which OpenClaw does not emit; its `HOOK.md` name was not valid YAML; and OpenClaw loads a workspace hook only once `openclaw.json` enables its entry, which teamai never wrote. The handler now keys on the event's `type` and `action`, runs `session-start` on `command:new`, `command:reset`, `session:auto-reset` and `gateway:startup` and `prompt-submit` on `message:received`, and passes the event's workspace as the hook's `cwd`. init, pull and `hooks inject` add `hooks.internal.entries.teamai-status-report.enabled`, unless that would narrow OpenClaw's open-ended hook discovery or you switched it off, in which case they warn and name `openclaw hooks enable teamai-status-report` (earlier versions set `hooks.internal.enabled` when `OPENCLAW_STATE_DIR` was set, so those machines see this warning until the command is run); `hooks remove` and uninstall take the entry out. The workspace and config follow `OPENCLAW_PROFILE`, `OPENCLAW_CONFIG_PATH` and `OPENCLAW_WORKSPACE_DIR` as OpenClaw does, a server-pushed `SessionStart` or `UserPromptSubmit` agent hook subscribes to the events OpenClaw emits and gets its own entry (`teamai-agent-`) so the allowlist keeps it, and `teamai doctor` fails `OpenClaw hook enabled` while the entry is missing or off (for [#946](https://github.com/Tencent/teamai-cli/issues/946)). +- A project-scope `teamai pull` no longer rewrites Hermes' global `SOUL.md` rules block, and a project with no rules no longer erases it: only a user-scope pull writes it, `doctor` checks it only in user scope, and a project-scope `uninstall` leaves it. When no user-scope config exists, a project pull removes the stale team rules block an older project pull left behind and preserves other SOUL.md content. Hermes gets no project rules; in a project `init` and `doctor` say why (for [#946](https://github.com/Tencent/teamai-cli/issues/946)). +- OpenCode loads the user-scope team rules again. The user `opencode.json` listed `rules/*.md`, which OpenCode resolves from the session's working directory, so it loaded the project's `rules/` instead. Pull now lists the absolute `~/.config/opencode/rules/*.md` plus one glob per namespace directory a rule lands in, replaces the old relative glob, and drops a namespace's glob once its rules no longer reach you, keeping a glob you added for a directory of your own; `doctor` checks every glob and flags a stale one, and `uninstall` removes them (for [#946](https://github.com/Tencent/teamai-cli/issues/946)). +- CodeBuddy and WorkBuddy now get each rule in CodeBuddy's own format: `alwaysApply: false` with `paths:` as a block list when scoped, `alwaysApply: true` when not. CodeBuddy's frontmatter parser reads lines, so the inline `paths: ["a", "b"]` of a verbatim copy reached it as globs with the brackets in them. WorkBuddy's project rules now go into `.codebuddy/rules`, which it reads, as one copy shared with CodeBuddy: removing or excluding one tool keeps it while the other is installed, and `doctor` checks it once for both. WorkBuddy's user rules stay in `~/.workbuddy/rules`. Qoder and Qoder CN, which both read a project's `.qoder/rules` in one render, share their copies the same way. A rule file in `.codebuddy/rules` with no matching team rule is the member's own: pull no longer deletes it, and push no longer offers it as a new team rule (for [#946](https://github.com/Tencent/teamai-cli/issues/946)). +- JoyCode now gets each project rule in its own `.mdc` render instead of Cursor's. JoyCode reads the frontmatter by lines, keeps the quotes Cursor's render puts around `globs` and splits the value on every comma, so a scoped rule in `.joycode/rules` matched no file. Scoped rules now get `globs:` unquoted with `{a,b}` expanded, and `alwaysApply: false`; unscoped rules keep `alwaysApply: true`. The first pull after upgrading rewrites a copy still holding what teamai delivered, even when the team repo has not moved; a copy you edited is kept and named, and while its `globs` are still quoted pull says that JoyCode applies it to no file and how to fix it. A root rule's copy that a namespace rule replaces is now removed when it still holds what an older teamai wrote there (the team `.md` for Kiro, Qoder, CodeBuddy and Oh My Pi, Cursor's render for JoyCode), instead of being kept with a warning. `teamai doctor` compares each copy with the new render (for [#946](https://github.com/Tencent/teamai-cli/issues/946)). +- `teamai pull` reclaims the rule copies earlier releases left in a project's `.workbuddy/rules`, which WorkBuddy never read, and writes them to `.codebuddy/rules`, also when the team repo has not moved. It also reclaims the team rules WorkBuddy's one-time migration copied from `~/.codebuddy/rules` into `~/.workbuddy/rules`: an unedited one is re-rendered while still delivered and removed otherwise, and an edited one is kept and named. The reclaim of old rule copies now runs on every rules sync, and its warnings say why each tool does not read the copy (for [#946](https://github.com/Tencent/teamai-cli/issues/946)). +- OpenCode loads namespaced project rules. The root `opencode.json` listed `.opencode/rules/*.md`, which matched no rule in a namespace directory. Pull now lists `.opencode/rules/**/*.md` in `.opencode/opencode.json`, beside the team instructions entry, and removes the old glob from the root `opencode.json`, leaving its other keys. The first pull after upgrading moves the globs in both scopes even when the team repo has not moved; `doctor` checks `.opencode/opencode.json`, and `uninstall` removes the glob from both files, deleting a `.opencode/opencode.json` left empty (for [#946](https://github.com/Tencent/teamai-cli/issues/946)). +- ZCode, DeepSeek Harness, OpenClaw, Pi and JoyCode get the team rules in user scope, in a file only that tool reads: a team-rules block in `~/.zcode/AGENTS.md`, `$DSH_HOME/AGENTS.md` (`~/.dsh` by default), the OpenClaw workspace `AGENTS.md` (resolved as OpenClaw's hook resolves it), `~/.pi/agent/AGENTS.md` and `~/.joycode/rules.txt`, the always-on text Codex already gets in `~/.codex/AGENTS.md`. ZCode and DeepSeek Harness got no rules before, and OpenClaw, Pi and JoyCode got copies in `.openclaw/rules`, `~/.pi/agent/rules` and `~/.joycode/rules`, which they never read. A pull, also at an unchanged team revision, removes those copies when unedited and names the edited ones, and `uninstall` removes the block and the copies. A project pull writes none of these files; OpenClaw gets no project rules, and its culture and instruction blocks now follow the resolved workspace too, so a project's `.openclaw/workspace/AGENTS.md` is no longer written. `doctor` checks the block in each tool's file, and init and doctor say why OpenClaw gets no project rules (for [#946](https://github.com/Tencent/teamai-cli/issues/946)). +- In a project, ZCode and DeepSeek Harness get the project's team rules from teamai's session-start hook, as Codex does, and Pi from teamai's Pi extension, which adds them to each run's system prompt after the team instructions. None of the three gets a user rule there, so with teamai in both scopes each rule reaches the tool once. Pi no longer gets rule copies in `.pi/rules`, which it never read: a pull, also at an unchanged team revision, removes the unedited ones and names the edited ones, and `uninstall` removes them too. `doctor` checks that ZCode's `SessionStart` hook is registered with `hooks.enabled`, that the DeepSeek Harness patch and hook config exist, and that the Pi extension is current; init and doctor say that ZCode and DeepSeek Harness drop the hook text when they compact a session, that DeepSeek Harness needs `--patch` and can miss the first request. teamai now finds DeepSeek Harness where `DSH_HOME` points (`~/.dsh` when unset) for its hook bridge and its user-scope `AGENTS.md`; skills still go to `~/.dsh/skills` (for [#946](https://github.com/Tencent/teamai-cli/issues/946)). +- `teamai doctor` no longer passes rules delivered to a directory the tool never reads. A team `toolPaths` entry replaces teamai's default for that tool whole, so one written before this release still sends rules to `.pi/rules`, `~/.pi/agent/rules`, `.openclaw/rules`, `~/.joycode/rules`, a project's `.workbuddy/rules` or `.codex/rules`; `Rules delivered to ` now fails there, says why the tool does not read it and what to change in the entry (for [#946](https://github.com/Tencent/teamai-cli/issues/946)). +- When `openclaw.json` is not plain JSON (OpenClaw reads JSON5), teamai cannot tell which workspace OpenClaw uses, so a user pull no longer writes OpenClaw's team rules and instruction blocks to the default workspace. It warns with the file, the parse error and the fix, and `doctor` fails `Team rules are inlined in OpenClaw workspace AGENTS.md` with the same text (for [#946](https://github.com/Tencent/teamai-cli/issues/946)). +- A user pull now repairs Hermes' `SOUL.md` rules block also when the team repo has not moved, so a block an older release's project pull overwrote is fixed by the first pull after upgrading; `doctor` says `teamai pull` for it (for [#946](https://github.com/Tencent/teamai-cli/issues/946)). +- `teamai init` prints the Codex trust reminder when it installs hooks for the public Codex, and the reminder says that until the hooks are trusted a project's team rules do not reach Codex (for [#946](https://github.com/Tencent/teamai-cli/issues/946)). +- When no team rule reaches a member any more, pull also removes a copy an older release wrote before the tool got its own rules format (a verbatim Kiro, Qoder, CodeBuddy, WorkBuddy or Oh My Pi copy, a Cursor render in JoyCode), not only one in the current format (for [#946](https://github.com/Tencent/teamai-cli/issues/946)). - 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)). diff --git a/README.ja.md b/README.ja.md index 6f5e30863..9dbe32d96 100644 --- a/README.ja.md +++ b/README.ja.md @@ -123,24 +123,27 @@ Git ใ‚’ๅŸบ็›คใซใ€3 ๅฑคใฎ่ƒฝๅŠ›ใ‚’ๆง‹็ฏ‰ใ—ใพใ™๏ผš Claude Codeโœ“โœ“โœ“โœ“โœ“โœ“โœ“โœ“โœ“โœ“โœ“โœ“โœ“โœ“ - Codexโœ“โœ“โœ“โœ“โœ“โœ“โœ“โœ“โœ“โœ“โœ“โœ“โœ“โœ“ + Codexโœ“โœ“*โœ“โœ“โœ“โœ“โœ“โœ“โœ“โœ“โœ“โœ“โœ“โœ“ Cursorโœ“โœ“โœ“โœ“โœ“โœ“โœ“โ€”โœ“โœ“โœ“โœ“โœ“โœ“ GitHub Copilot CLIโœ“โœ“โœ“โœ“โœ“โœ“โœ“โ€”โœ“โœ“โœ“โœ“โœ“โœ“ CodeBuddyโœ“โœ“โœ“โœ“โœ“โœ“โœ“โœ“โœ“โœ“โœ“โœ“โœ“โœ“ WorkBuddyโœ“โœ“โœ“โœ“โ€”โœ“โœ“โœ“โœ“โœ“โœ“โœ“โœ“โœ“ - OpenCodeโœ“โœ“โœ“โœ“โœ“โœ“โœ“โœ“โœ“โœ“โœ“โ€”โ€”โ€” - Pi Coding Agentโœ“โœ“โœ“โ€”โ€”โœ“โœ“โœ“โœ“โœ“โœ“โ€”โ€”โ€” - OpenClawโœ“โœ“โœ“โœ“โ€”โ€”โ€”โ€”โœ“โœ“โœ“โ€”โ€”โ€” - Hermesโœ“โ€”โœ“โœ“โ€”โ€”โ€”โ€”โœ“โœ“โœ“โ€”โ€”โ€” - DeepSeek Harnessโœ“โ€”โœ“โ€”โ€”โ€”โ€”โ€”โœ“โœ“โœ“โ€”โ€”โ€” + OpenCodeโœ“โœ“*โœ“โœ“โœ“โœ“โœ“โœ“โœ“โœ“โœ“โ€”โ€”โ€” + Pi Coding Agentโœ“โœ“*โœ“โ€”โ€”โœ“โœ“โœ“โœ“โœ“โœ“โ€”โ€”โ€” + OpenClawโœ“โœ“*โœ“โœ“โ€”โ€”โ€”โ€”โœ“โœ“โœ“โ€”โ€”โ€” + Hermesโœ“โœ“*โœ“โœ“โ€”โ€”โ€”โ€”โœ“โœ“โœ“โ€”โ€”โ€” + DeepSeek Harnessโœ“โœ“*โœ“โ€”โ€”โ€”โ€”โ€”โœ“โœ“โœ“โ€”โ€”โ€” Qoderโœ“โœ“โœ“โœ“โœ“โœ“โœ“โ€”โœ“โœ“โœ“โœ“โœ“โœ“ Qoder CNโœ“โœ“โœ“โœ“โœ“โœ“โœ“โ€”โœ“โœ“โœ“โœ“โœ“โœ“ Kiroโœ“โœ“โœ“โœ“โœ“โœ“โœ“โ€”โœ“โœ“โœ“โœ“โœ“โœ“ - ZCodeโœ“โ€”โœ“โ€”โœ“โœ“โœ“โ€”โœ“โœ“โœ“โœ“โœ“โœ“ + ZCodeโœ“โœ“*โœ“โ€”โœ“โœ“โœ“โ€”โœ“โœ“โœ“โœ“โœ“โœ“ Oh My Piโœ“โœ“โœ“โœ“โœ“โœ“โœ“โ€”โœ“โœ“โœ“โ€”โ€”โ€” + JoyCodeโœ“โœ“โœ“โœ“โœ“โ€”โ€”โ€”โœ“โœ“โœ“โ€”โ€”โ€” +โœ“* rules ใฏใƒ„ใƒผใƒซใซๅฑŠใใพใ™ใŒๅธธใซๆœ‰ๅŠนใงใ™ใ€‚ใƒ‘ใ‚นใซใ‚ˆใ‚‹ใ‚นใ‚ณใƒผใƒ—ใฏ้ฉ็”จใ•ใ‚Œใพใ›ใ‚“๏ผˆใƒ‘ใ‚นๆŒ‡ๅฎšใฎใ‚ใ‚‹ใ‚คใƒณใƒฉใ‚คใƒณใƒซใƒผใƒซใซใฏใƒขใƒ‡ใƒซๅ‘ใ‘ใฎใƒ‘ใ‚นใฎใƒ’ใƒณใƒˆใŒไป˜ใใพใ™๏ผ‰ใ€‚ + ## ่ฉณ็ดฐๆƒ…ๅ ฑ - [Usage Guide](docs/usage-guide.md) โ€” setup, onboarding, daily workflows, and commands diff --git a/README.ko.md b/README.ko.md index adffb37a3..0b9d15a13 100644 --- a/README.ko.md +++ b/README.ko.md @@ -123,24 +123,27 @@ Git์„ ๊ธฐ๋ฐ˜์œผ๋กœ ์„ธ ์ธต์˜ ์—ญ๋Ÿ‰์„ ๊ตฌ์ถ•ํ•ฉ๋‹ˆ๋‹ค: Claude Codeโœ“โœ“โœ“โœ“โœ“โœ“โœ“โœ“โœ“โœ“โœ“โœ“โœ“โœ“ - Codexโœ“โœ“โœ“โœ“โœ“โœ“โœ“โœ“โœ“โœ“โœ“โœ“โœ“โœ“ + Codexโœ“โœ“*โœ“โœ“โœ“โœ“โœ“โœ“โœ“โœ“โœ“โœ“โœ“โœ“ Cursorโœ“โœ“โœ“โœ“โœ“โœ“โœ“โ€”โœ“โœ“โœ“โœ“โœ“โœ“ GitHub Copilot CLIโœ“โœ“โœ“โœ“โœ“โœ“โœ“โ€”โœ“โœ“โœ“โœ“โœ“โœ“ CodeBuddyโœ“โœ“โœ“โœ“โœ“โœ“โœ“โœ“โœ“โœ“โœ“โœ“โœ“โœ“ WorkBuddyโœ“โœ“โœ“โœ“โ€”โœ“โœ“โœ“โœ“โœ“โœ“โœ“โœ“โœ“ - OpenCodeโœ“โœ“โœ“โœ“โœ“โœ“โœ“โœ“โœ“โœ“โœ“โ€”โ€”โ€” - Pi Coding Agentโœ“โœ“โœ“โ€”โ€”โœ“โœ“โœ“โœ“โœ“โœ“โ€”โ€”โ€” - OpenClawโœ“โœ“โœ“โœ“โ€”โ€”โ€”โ€”โœ“โœ“โœ“โ€”โ€”โ€” - Hermesโœ“โ€”โœ“โœ“โ€”โ€”โ€”โ€”โœ“โœ“โœ“โ€”โ€”โ€” - DeepSeek Harnessโœ“โ€”โœ“โ€”โ€”โ€”โ€”โ€”โœ“โœ“โœ“โ€”โ€”โ€” + OpenCodeโœ“โœ“*โœ“โœ“โœ“โœ“โœ“โœ“โœ“โœ“โœ“โ€”โ€”โ€” + Pi Coding Agentโœ“โœ“*โœ“โ€”โ€”โœ“โœ“โœ“โœ“โœ“โœ“โ€”โ€”โ€” + OpenClawโœ“โœ“*โœ“โœ“โ€”โ€”โ€”โ€”โœ“โœ“โœ“โ€”โ€”โ€” + Hermesโœ“โœ“*โœ“โœ“โ€”โ€”โ€”โ€”โœ“โœ“โœ“โ€”โ€”โ€” + DeepSeek Harnessโœ“โœ“*โœ“โ€”โ€”โ€”โ€”โ€”โœ“โœ“โœ“โ€”โ€”โ€” Qoderโœ“โœ“โœ“โœ“โœ“โœ“โœ“โ€”โœ“โœ“โœ“โœ“โœ“โœ“ Qoder CNโœ“โœ“โœ“โœ“โœ“โœ“โœ“โ€”โœ“โœ“โœ“โœ“โœ“โœ“ Kiroโœ“โœ“โœ“โœ“โœ“โœ“โœ“โ€”โœ“โœ“โœ“โœ“โœ“โœ“ - ZCodeโœ“โ€”โœ“โ€”โœ“โœ“โœ“โ€”โœ“โœ“โœ“โœ“โœ“โœ“ + ZCodeโœ“โœ“*โœ“โ€”โœ“โœ“โœ“โ€”โœ“โœ“โœ“โœ“โœ“โœ“ Oh My Piโœ“โœ“โœ“โœ“โœ“โœ“โœ“โ€”โœ“โœ“โœ“โ€”โ€”โ€” + JoyCodeโœ“โœ“โœ“โœ“โœ“โ€”โ€”โ€”โœ“โœ“โœ“โ€”โ€”โ€” +โœ“* rules๊ฐ€ ๋„๊ตฌ์— ์ „๋‹ฌ๋˜์ง€๋งŒ ํ•ญ์ƒ ์ ์šฉ๋ฉ๋‹ˆ๋‹ค. ๊ฒฝ๋กœ๋ณ„๋กœ ๋ฒ”์œ„๋ฅผ ํ•œ์ •ํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค(๊ฒฝ๋กœ๊ฐ€ ์ง€์ •๋œ ์ธ๋ผ์ธ ๊ทœ์น™์—๋Š” ๋ชจ๋ธ์„ ์œ„ํ•œ ๊ฒฝ๋กœ ํžŒํŠธ๊ฐ€ ํฌํ•จ๋ฉ๋‹ˆ๋‹ค). + ## ์ž์„ธํžˆ ์•Œ์•„๋ณด๊ธฐ - [Usage Guide](docs/usage-guide.md) โ€” setup, onboarding, daily workflows, and commands diff --git a/README.md b/README.md index 343a45c73..0fffd361a 100644 --- a/README.md +++ b/README.md @@ -123,24 +123,27 @@ Three layers of capability, built on Git: Claude Codeโœ“โœ“โœ“โœ“โœ“โœ“โœ“โœ“โœ“โœ“โœ“โœ“โœ“โœ“ - Codexโœ“โœ“โœ“โœ“โœ“โœ“โœ“โœ“โœ“โœ“โœ“โœ“โœ“โœ“ + Codexโœ“โœ“*โœ“โœ“โœ“โœ“โœ“โœ“โœ“โœ“โœ“โœ“โœ“โœ“ Cursorโœ“โœ“โœ“โœ“โœ“โœ“โœ“โ€”โœ“โœ“โœ“โœ“โœ“โœ“ GitHub Copilot CLIโœ“โœ“โœ“โœ“โœ“โœ“โœ“โ€”โœ“โœ“โœ“โœ“โœ“โœ“ CodeBuddyโœ“โœ“โœ“โœ“โœ“โœ“โœ“โœ“โœ“โœ“โœ“โœ“โœ“โœ“ WorkBuddyโœ“โœ“โœ“โœ“โ€”โœ“โœ“โœ“โœ“โœ“โœ“โœ“โœ“โœ“ - OpenCodeโœ“โœ“โœ“โœ“โœ“โœ“โœ“โœ“โœ“โœ“โœ“โ€”โ€”โ€” - Pi Coding Agentโœ“โœ“โœ“โ€”โ€”โœ“โœ“โœ“โœ“โœ“โœ“โ€”โ€”โ€” - OpenClawโœ“โœ“โœ“โœ“โ€”โ€”โ€”โ€”โœ“โœ“โœ“โ€”โ€”โ€” - Hermesโœ“โ€”โœ“โœ“โ€”โ€”โ€”โ€”โœ“โœ“โœ“โ€”โ€”โ€” - DeepSeek Harnessโœ“โ€”โœ“โ€”โ€”โ€”โ€”โ€”โœ“โœ“โœ“โ€”โ€”โ€” + OpenCodeโœ“โœ“*โœ“โœ“โœ“โœ“โœ“โœ“โœ“โœ“โœ“โ€”โ€”โ€” + Pi Coding Agentโœ“โœ“*โœ“โ€”โ€”โœ“โœ“โœ“โœ“โœ“โœ“โ€”โ€”โ€” + OpenClawโœ“โœ“*โœ“โœ“โ€”โ€”โ€”โ€”โœ“โœ“โœ“โ€”โ€”โ€” + Hermesโœ“โœ“*โœ“โœ“โ€”โ€”โ€”โ€”โœ“โœ“โœ“โ€”โ€”โ€” + DeepSeek Harnessโœ“โœ“*โœ“โ€”โ€”โ€”โ€”โ€”โœ“โœ“โœ“โ€”โ€”โ€” Qoderโœ“โœ“โœ“โœ“โœ“โœ“โœ“โ€”โœ“โœ“โœ“โœ“โœ“โœ“ Qoder CNโœ“โœ“โœ“โœ“โœ“โœ“โœ“โ€”โœ“โœ“โœ“โœ“โœ“โœ“ Kiroโœ“โœ“โœ“โœ“โœ“โœ“โœ“โ€”โœ“โœ“โœ“โœ“โœ“โœ“ - ZCodeโœ“โ€”โœ“โ€”โœ“โœ“โœ“โ€”โœ“โœ“โœ“โœ“โœ“โœ“ + ZCodeโœ“โœ“*โœ“โ€”โœ“โœ“โœ“โ€”โœ“โœ“โœ“โœ“โœ“โœ“ Oh My Piโœ“โœ“โœ“โœ“โœ“โœ“โœ“โ€”โœ“โœ“โœ“โ€”โ€”โ€” + JoyCodeโœ“โœ“โœ“โœ“โœ“โ€”โ€”โ€”โœ“โœ“โœ“โ€”โ€”โ€” +โœ“* The rules reach the tool, but always on: it does not scope them by path (scoped inline rules include path hints for the model). + ## Learn More - [Usage Guide](docs/usage-guide.md) ([ไธญๆ–‡็‰ˆ](docs/usage-guide.zh-CN.md)) โ€” setup, onboarding, daily workflows, and commands diff --git a/README.th.md b/README.th.md index 05838c6f8..d40724be8 100644 --- a/README.th.md +++ b/README.th.md @@ -123,24 +123,27 @@ teamai init https://github.com/your-org/your-repo --scope user Claude Codeโœ“โœ“โœ“โœ“โœ“โœ“โœ“โœ“โœ“โœ“โœ“โœ“โœ“โœ“ - Codexโœ“โœ“โœ“โœ“โœ“โœ“โœ“โœ“โœ“โœ“โœ“โœ“โœ“โœ“ + Codexโœ“โœ“*โœ“โœ“โœ“โœ“โœ“โœ“โœ“โœ“โœ“โœ“โœ“โœ“ Cursorโœ“โœ“โœ“โœ“โœ“โœ“โœ“โ€”โœ“โœ“โœ“โœ“โœ“โœ“ GitHub Copilot CLIโœ“โœ“โœ“โœ“โœ“โœ“โœ“โ€”โœ“โœ“โœ“โœ“โœ“โœ“ CodeBuddyโœ“โœ“โœ“โœ“โœ“โœ“โœ“โœ“โœ“โœ“โœ“โœ“โœ“โœ“ WorkBuddyโœ“โœ“โœ“โœ“โ€”โœ“โœ“โœ“โœ“โœ“โœ“โœ“โœ“โœ“ - OpenCodeโœ“โœ“โœ“โœ“โœ“โœ“โœ“โœ“โœ“โœ“โœ“โ€”โ€”โ€” - Pi Coding Agentโœ“โœ“โœ“โ€”โ€”โœ“โœ“โœ“โœ“โœ“โœ“โ€”โ€”โ€” - OpenClawโœ“โœ“โœ“โœ“โ€”โ€”โ€”โ€”โœ“โœ“โœ“โ€”โ€”โ€” - Hermesโœ“โ€”โœ“โœ“โ€”โ€”โ€”โ€”โœ“โœ“โœ“โ€”โ€”โ€” - DeepSeek Harnessโœ“โ€”โœ“โ€”โ€”โ€”โ€”โ€”โœ“โœ“โœ“โ€”โ€”โ€” + OpenCodeโœ“โœ“*โœ“โœ“โœ“โœ“โœ“โœ“โœ“โœ“โœ“โ€”โ€”โ€” + Pi Coding Agentโœ“โœ“*โœ“โ€”โ€”โœ“โœ“โœ“โœ“โœ“โœ“โ€”โ€”โ€” + OpenClawโœ“โœ“*โœ“โœ“โ€”โ€”โ€”โ€”โœ“โœ“โœ“โ€”โ€”โ€” + Hermesโœ“โœ“*โœ“โœ“โ€”โ€”โ€”โ€”โœ“โœ“โœ“โ€”โ€”โ€” + DeepSeek Harnessโœ“โœ“*โœ“โ€”โ€”โ€”โ€”โ€”โœ“โœ“โœ“โ€”โ€”โ€” Qoderโœ“โœ“โœ“โœ“โœ“โœ“โœ“โ€”โœ“โœ“โœ“โœ“โœ“โœ“ Qoder CNโœ“โœ“โœ“โœ“โœ“โœ“โœ“โ€”โœ“โœ“โœ“โœ“โœ“โœ“ Kiroโœ“โœ“โœ“โœ“โœ“โœ“โœ“โ€”โœ“โœ“โœ“โœ“โœ“โœ“ - ZCodeโœ“โ€”โœ“โ€”โœ“โœ“โœ“โ€”โœ“โœ“โœ“โœ“โœ“โœ“ + ZCodeโœ“โœ“*โœ“โ€”โœ“โœ“โœ“โ€”โœ“โœ“โœ“โœ“โœ“โœ“ Oh My Piโœ“โœ“โœ“โœ“โœ“โœ“โœ“โ€”โœ“โœ“โœ“โ€”โ€”โ€” + JoyCodeโœ“โœ“โœ“โœ“โœ“โ€”โ€”โ€”โœ“โœ“โœ“โ€”โ€”โ€” +โœ“* rules เธชเนˆเธ‡เธ–เธถเธ‡เน€เธ„เธฃเธทเนˆเธญเธ‡เธกเธทเธญ เนเธ•เนˆเธกเธตเธœเธฅเน€เธชเธกเธญ: เน€เธ„เธฃเธทเนˆเธญเธ‡เธกเธทเธญเน„เธกเนˆเธˆเธณเธเธฑเธ”เธ‚เธญเธšเน€เธ‚เธ•เธ•เธฒเธก path (เธเธŽเนเธšเธš inline เธ—เธตเนˆเธฃเธฐเธšเธธ path เธกเธตเธ„เธณเนเธ™เธฐเธ™เธณเธ”เน‰เธฒเธ™ path เนƒเธซเน‰เน‚เธกเน€เธ”เธฅ) + ## เน€เธฃเธตเธขเธ™เธฃเธนเน‰เน€เธžเธดเนˆเธกเน€เธ•เธดเธก - [Usage Guide](docs/usage-guide.md) โ€” setup, onboarding, daily workflows, and commands diff --git a/README.zh-CN.md b/README.zh-CN.md index 2af3f6d25..aa9a9eab2 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -129,24 +129,27 @@ teamai init https://github.com/your-org/your-repo --scope user Claude Codeโœ“โœ“โœ“โœ“โœ“โœ“โœ“โœ“โœ“โœ“โœ“โœ“โœ“โœ“ - Codexโœ“โœ“โœ“โœ“โœ“โœ“โœ“โœ“โœ“โœ“โœ“โœ“โœ“โœ“ + Codexโœ“โœ“*โœ“โœ“โœ“โœ“โœ“โœ“โœ“โœ“โœ“โœ“โœ“โœ“ Cursorโœ“โœ“โœ“โœ“โœ“โœ“โœ“โ€”โœ“โœ“โœ“โœ“โœ“โœ“ GitHub Copilot CLIโœ“โœ“โœ“โœ“โœ“โœ“โœ“โ€”โœ“โœ“โœ“โœ“โœ“โœ“ CodeBuddyโœ“โœ“โœ“โœ“โœ“โœ“โœ“โœ“โœ“โœ“โœ“โœ“โœ“โœ“ WorkBuddyโœ“โœ“โœ“โœ“โ€”โœ“โœ“โœ“โœ“โœ“โœ“โœ“โœ“โœ“ - OpenCodeโœ“โœ“โœ“โœ“โœ“โœ“โœ“โœ“โœ“โœ“โœ“โ€”โ€”โ€” - Pi Coding Agentโœ“โœ“โœ“โ€”โ€”โœ“โœ“โœ“โœ“โœ“โœ“โ€”โ€”โ€” - OpenClawโœ“โœ“โœ“โœ“โ€”โ€”โ€”โ€”โœ“โœ“โœ“โ€”โ€”โ€” - Hermesโœ“โ€”โœ“โœ“โ€”โ€”โ€”โ€”โœ“โœ“โœ“โ€”โ€”โ€” - DeepSeek Harnessโœ“โ€”โœ“โ€”โ€”โ€”โ€”โ€”โœ“โœ“โœ“โ€”โ€”โ€” + OpenCodeโœ“โœ“*โœ“โœ“โœ“โœ“โœ“โœ“โœ“โœ“โœ“โ€”โ€”โ€” + Pi Coding Agentโœ“โœ“*โœ“โ€”โ€”โœ“โœ“โœ“โœ“โœ“โœ“โ€”โ€”โ€” + OpenClawโœ“โœ“*โœ“โœ“โ€”โ€”โ€”โ€”โœ“โœ“โœ“โ€”โ€”โ€” + Hermesโœ“โœ“*โœ“โœ“โ€”โ€”โ€”โ€”โœ“โœ“โœ“โ€”โ€”โ€” + DeepSeek Harnessโœ“โœ“*โœ“โ€”โ€”โ€”โ€”โ€”โœ“โœ“โœ“โ€”โ€”โ€” Qoderโœ“โœ“โœ“โœ“โœ“โœ“โœ“โ€”โœ“โœ“โœ“โœ“โœ“โœ“ Qoder CNโœ“โœ“โœ“โœ“โœ“โœ“โœ“โ€”โœ“โœ“โœ“โœ“โœ“โœ“ Kiroโœ“โœ“โœ“โœ“โœ“โœ“โœ“โ€”โœ“โœ“โœ“โœ“โœ“โœ“ - ZCodeโœ“โ€”โœ“โ€”โœ“โœ“โœ“โ€”โœ“โœ“โœ“โœ“โœ“โœ“ + ZCodeโœ“โœ“*โœ“โ€”โœ“โœ“โœ“โ€”โœ“โœ“โœ“โœ“โœ“โœ“ Oh My Piโœ“โœ“โœ“โœ“โœ“โœ“โœ“โ€”โœ“โœ“โœ“โ€”โ€”โ€” + JoyCodeโœ“โœ“โœ“โœ“โœ“โ€”โ€”โ€”โœ“โœ“โœ“โ€”โ€”โ€” +โœ“* rules ่ƒฝ้€่พพ่ฏฅๅทฅๅ…ท๏ผŒไฝ†ๅง‹็ปˆ็”Ÿๆ•ˆ๏ผšๅฎƒไธๆŒ‰่ทฏๅพ„้™ๅฎšไฝœ็”จ่Œƒๅ›ด๏ผˆๅธฆ่ทฏๅพ„่Œƒๅ›ด็š„ๅ†…่”่ง„ๅˆ™ไผšๅ‘ๆจกๅž‹ๆไพ›่ทฏๅพ„ๆ็คบ๏ผ‰ใ€‚ + ## ไบ†่งฃๆ›ดๅคš - [ไฝฟ็”จๆŒ‡ๅ—](docs/usage-guide.zh-CN.md)๏ผˆ[English](docs/usage-guide.md)๏ผ‰โ€” ๅฎ‰่ฃ…ใ€ๆˆๅ‘˜ๆŽฅๅ…ฅใ€ๆ—ฅๅธธๆต็จ‹ไธŽๅ‘ฝไปคๅ‚่€ƒ diff --git a/docs/designs/data-directory-layout.md b/docs/designs/data-directory-layout.md index 11d9c5744..ba93bfe88 100644 --- a/docs/designs/data-directory-layout.md +++ b/docs/designs/data-directory-layout.md @@ -104,9 +104,9 @@ resets nothing, since a checkout recorded at an older revision already misses the fast path. `push` needs that entry too: before scanning, it syncs each rule and skill the member never edited, and "never edited" means equal to the version at a revision *this* checkout synced, not the shared `lastPullRev` -another checkout may have moved (#812). Cursor and Copilot rules compare bodies -against those revisions, ignoring derived frontmatter, and render refreshed -copies in the tool's native format. Rule sync uses the same tool root as the +another checkout may have moved (#812). Rules of a tool with its own rules +format (every tool in `RULE_FORMATS`, `rule-format.ts`) compare bodies against those revisions, ignoring derived frontmatter, +and render refreshed copies in the tool's native format. Rule sync uses the same tool root as the scanner, including `COPILOT_HOME` for user-scope Copilot instructions. It checks `isAgentExcluded` before installation detection, so retained tool directories do not authorize writes to rules excluded by the local configuration. @@ -175,6 +175,24 @@ older CLI's render, such as Claude extras in a Qoder copy). Without a `delivered` entry for the copy nothing tells that render from an edit, so it is left alone. +Rules get the same treatment for a render change (#946): when a CLI upgrade +gives a tool its own rules format (a `RuleFormat` with `previousRenders`: Kiro, +Qoder, CodeBuddy, WorkBuddy, Oh My Pi, and JoyCode, which had Cursor's render), the fast +path rewrites each rule copy that still has the bytes `delivered` records but +is not the current render, records the new bytes, and of a copy without an +entry rewrites only one that is the team rule verbatim, which is what the older +CLI wrote. A copy the member changed is kept and named when teamai would now +deliver other bytes there. A destination that moved (WorkBuddy's project rules, +from `.workbuddy/rules` to `.codebuddy/rules`) has no entry at its new path, so +the fast path first reclaims the old copies (`LEGACY_RULE_DIRS`), writes the +new path where it is missing, and records the change. The flat names of Oh My Pi +and Kiro (`RuleFormat.flat`: `/.md` became `..md`, in the +same directory) work the same +way through `movedFrom`: the copy at the old path is what lets the fast path +write the new one, with or without a `delivered` file; the old copy goes while +it has the recorded bytes or, with no entry, the team rule verbatim, and is +named otherwise, since the tool does not read it. + ### Why the main worktree, not `git-common-dir` (verified) `projectAnchor` uses the first entry of `git worktree list --porcelain` rather than diff --git a/docs/usage-guide.md b/docs/usage-guide.md index 029152495..cca293eb5 100644 --- a/docs/usage-guide.md +++ b/docs/usage-guide.md @@ -730,7 +730,7 @@ With role-based skills enabled, `pull`'s skill sync source becomes the contents - A skill replaces the root skill of the same directory name, including a root skill you receive through a tag. The install removes the files of the version it replaces. Files that no team version of the skill has stay. - An agent replaces the root agent of the same file stem. -- A rule replaces the root rule of the same first-level file name: `rules//.md` replaces `rules/.md`, in Hermes' `SOUL.md` block and the rules Codex's session-start hook adds too. Deeper paths such as `rules///.md` replace nothing, and neither does a namespace rule your tag subscriptions leave out. In rule directories you share with rules of your own (JoyCode, OMP, Pi, Copilot), the replaced root rule's copy is removed only while it is what teamai delivered (the current root rule, or the one of your last pull); an edited copy stays, and each pull names it, since the tool loads it beside the namespace rule. +- A rule replaces the root rule of the same first-level file name: `rules//.md` replaces `rules/.md`, in Hermes' `SOUL.md` block and the rules a session-start hook or Pi's extension adds too. Deeper paths such as `rules///.md` replace nothing, and neither does a namespace rule your tag subscriptions leave out. In rule directories you share with rules of your own (every tool with a rules format of its own except Cursor: JoyCode, Copilot, Kiro, Qoder, CodeBuddy, WorkBuddy and Oh My Pi), the replaced root rule's copy is removed only while it is what teamai delivered (the current root rule, or the one of your last pull); an edited copy stays, and each pull names it, since the tool loads it beside the namespace rule. - A `claudemd//.md` file replaces `claudemd/.md` in the managed block. When the namespace stops being active, the next pull delivers the root item again. If two active namespaces define the same skill or agent name, they compete for one installed file, so pull reports an error that names both files, does not update that type in that run, and keeps what is installed (for skills, recall keeps the ones it had indexed too); the other resource types still sync. Two active namespaces with the same rule or shared-instructions name are both delivered, because each keeps its own place (`rules//` locally, its own section of the block); only the root one gives way. `push` writes an edit of a replaced item back to its namespace, never to the root, and recall indexes the skills and rules you receive rather than every one in the repo. A replacement that cannot be used replaces nothing: a skill directory without `SKILL.md` is not delivered and pull names it, and while an agent file does not parse the agent it would replace stays installed. `teamai doctor` lists each replacement as a note. Without roles or projects nothing changes: every namespace is delivered beside the root, and `doctor` lists each name the team repo defines more than once. @@ -832,7 +832,7 @@ Exclusion rules take effect after role and tag filtering. When running `teamai p ### Push local resources -Before scanning, `push` refreshes unedited old rule copies from the team repo. For Copilot, it compares Markdown bodies independently of the generated `applyTo` header and renders updates in `.instructions.md` format. Local body edits are preserved. This applies to project rules and user rules under `COPILOT_HOME`. Each copy it refreshes is recorded as delivered, so the next `teamai pull` still updates it instead of keeping it as your change. +Before scanning, `push` refreshes unedited old rule copies from the team repo. For a tool with a rules format of its own (Cursor `.mdc`, JoyCode's own `.mdc`, Copilot `.instructions.md`, Kiro steering, and the Qoder, CodeBuddy, WorkBuddy and Oh My Pi rules), it compares Markdown bodies independently of the generated header and renders updates in that tool's format. Local body edits are preserved. For Copilot this applies to project rules and user rules under `COPILOT_HOME`. Each copy it refreshes is recorded as delivered, so the next `teamai pull` still updates it instead of keeping it as your change. A new file in one of those tools' rules directories is your own rule in that tool's format, so `push` never offers it; to share a new team rule, write it as a plain `.md` in `.claude/rules/` (scoped with `paths:` if needed) and push that. When only the team's `paths` change, `push` also refreshes Copilot's `applyTo` if the local file still matches a recorded version's generated copy. A locally edited header is preserved in this case. @@ -1007,6 +1007,8 @@ With role-based skills enabled, the push target directory becomes: ### Rules +For a scoped rule, YAML comments stay outside the glob: `paths: **/*.ts # TypeScript files` scopes native rules to `**/*.ts` and gives inline channels the same path hint. This also applies to block-list entries under `paths:`. + ```bash # Create a rule cat > ~/.claude/rules/code-review-guide.md << 'EOF' @@ -1022,11 +1024,11 @@ teamai push > Admins can set enforced rules in `teamai.yaml` (`sharing.rules.enforced`), which members cannot delete. -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 only trusted hooks. teamai trusts the hooks it writes automatically; if automatic trust is disabled or fails, approve them in `/hooks` to receive the project rules. +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. ZCode, DeepSeek Harness, OpenClaw, Pi and JoyCode have no rules format either, and in user scope get the same block in a file only that tool reads: `~/.zcode/AGENTS.md`, `$DSH_HOME/AGENTS.md` (`~/.dsh/AGENTS.md` when `DSH_HOME` is unset), the OpenClaw workspace `AGENTS.md` (found the way its hook finds it), `~/.pi/agent/AGENTS.md` beside the instruction blocks, and `~/.joycode/rules.txt`. Pull writes it only for an installed tool, and a project pull writes none of these files. When `openclaw.json` is not plain JSON (OpenClaw reads JSON5), teamai cannot tell which workspace OpenClaw uses, so it writes no block there: pull warns, and `doctor` fails until the file is plain JSON. In a project, ZCode and DeepSeek Harness get the project's team rules from the same session-start hook as Codex, and Pi from teamai's Pi extension, which adds them to each run's system prompt after the team instructions; none of them gets a user rule there, so with teamai in both scopes each rule reaches the tool once. ZCode and DeepSeek Harness drop the hook's text when they compact a session, so the rules come back in the next session, and DeepSeek Harness runs the hook detached, so its first request can miss them; in a project `init` and `doctor` say so. OpenClaw gets no project rules, since its only project file is the `AGENTS.md` other tools read too; in a project `init` and `doctor` say so. Earlier releases copied rules to `.openclaw/rules`, `.pi/rules`, `~/.pi/agent/rules` and `~/.joycode/rules`, which these tools never read: the next pull, even at an unchanged team revision, removes the copies that still hold what teamai delivered and names the ones you edited. Hermes gets the same text in its `SOUL.md` block, from a user-scope pull only: `SOUL.md` is global, so a project pull leaves it as the user-scope pull wrote it. A user pull rewrites the block also when the team repo has not moved, which repairs one an older release's project pull overwrote. On a machine with no user-scope config, a project pull removes the team rules block an older project pull left behind, preserving other SOUL.md content. Hermes gets no project rules, and in a project `init` and `doctor` say why: `.hermes.md` would hide the project `AGENTS.md`, a `pre_llm_call` hook repeats the rules on every turn, and the one plugin prompt section (at most 4,000 characters) already carries the team instructions. 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 only trusted hooks. teamai trusts the hooks it writes automatically; if automatic trust is disabled or fails, approve them in `/hooks` to receive the 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. -> 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. The `codex` entry also needs `mcpProject: .codex/config.toml` for the project's team MCP servers. +> 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. The `codex` entry also needs `mcpProject: .codex/config.toml` for the project's team MCP servers. The same goes for the other tools whose rules moved in this release: an entry written before it still sends rules where the tool never reads them, and pull leaves those copies. Remove `rules` and `userScope.rules` from a Pi or OpenClaw entry, add `userScope.rules: null` to a JoyCode entry (its project `rules: .joycode/rules` stays), and set a WorkBuddy entry's `rules` to `.codebuddy/rules` with `userScope.rules: .workbuddy/rules`. `teamai doctor` fails `Rules delivered to ` for such an entry and names the change. > Upgrading from a release that copied rules to `.codex/rules/`: the next `pull` removes the `.md` copies teamai delivered there, including `teamai-recall.md`. Cleanup follows the recorded `toolRoots` location and checks both a publisher's bare local filename and its namespaced copy. A copy you edited is kept and named in a warning, and the `*.rules` files are never touched. A copy of a rule the team has since removed is deleted only if it matches its recorded delivery hash; without that record, it is kept and named too. The same pull adds `additionalContextLimit: 0` and a `SubagentStart` entry to the teamai hooks in `hooks.json`, which teamai then trusts again in the public Codex (see [Hooks](#hooks)). @@ -1724,6 +1726,7 @@ Two members of the same project can have different roles, so their shared instru | 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 | +| OpenClaw | The workspace `AGENTS.md`, found the way its hook finds it (`agents.defaults.workspace`, `OPENCLAW_WORKSPACE_DIR`, or `/workspace`), beside the team rules (unverified) | Nothing: its only project file is the shared `AGENTS.md` | | 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`. @@ -1756,6 +1759,7 @@ The pull names each file it changes: - 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` +- OpenClaw, project scope: `.openclaw/workspace/AGENTS.md`, which OpenClaw never reads - 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 @@ -2049,6 +2053,8 @@ The inject and remove commands only touch tools you actually have installed (i.e In non-self project scope, `hooks remove` removes this checkout's gated team hooks from HOME and Claude/Codex team hooks from the main checkout. Other projects' gated team hooks stay in HOME; shared built-in hooks are removed. +> **OpenClaw** โ€” teamai's hook is a workspace hook, `/hooks/teamai-status-report`. It runs `session-start` on `command:new`, `command:reset`, `session:auto-reset` and `gateway:startup`, and `prompt-submit` on `message:received`, with the event's workspace as the hook's `cwd`. OpenClaw loads a workspace hook only when `openclaw.json` enables its entry, so init, pull and `hooks inject` add `hooks.internal.entries.teamai-status-report.enabled: true`, and `hooks remove` and uninstall take it out. When OpenClaw loads every hook it discovers (`hooks.internal.enabled: true` with no named entries), that first entry would turn discovery into an allowlist and stop your other hooks, so teamai leaves the config alone and warns; it does the same when you switched the hook or internal hooks off, or when `openclaw.json` is not plain JSON. Run `openclaw hooks enable teamai-status-report` to enable it yourself. Earlier teamai versions set `hooks.internal.enabled: true` themselves when `OPENCLAW_STATE_DIR` was set, so such a machine sees this warning until you do. A server-pushed agent hook lands in `/hooks/` and gets its own entry, `teamai-agent-`. The workspace and config are found the way OpenClaw finds them: `OPENCLAW_CONFIG_PATH`, `OPENCLAW_STATE_DIR` or `OPENCLAW_PROFILE` (`~/.openclaw-`), then `agents.defaults.workspace`, `OPENCLAW_WORKSPACE_DIR`, or `/workspace`. `doctor` fails `OpenClaw hook enabled` while the entry is missing or off. + On Windows, the built-in hook dispatch commands that shell out through bash (e.g. Claude, Codex, Cursor, Copilot CLI) reference Git Bash by absolute path โ€” standard install locations first, then the `HKLM\SOFTWARE\GitForWindows` registry as fallback โ€” so they never resolve to the WSL `bash.exe` launcher; if Git Bash cannot be found they degrade to bare `bash`. Cursor also loads `~/.claude/settings.json`, and Copilot CLI loads a trusted project's `.claude/settings.json` (self mode writes hooks there; Copilot does not load `~/.claude/settings.json`). `hook-dispatch --tool claude` exits only when that other host's own teamai hooks are on disk: `~/.cursor/hooks.json` or `$CURSOR_PROJECT_DIR/.cursor/hooks.json` contains `--tool cursor`, or `$COPILOT_PROJECT_DIR/.github/hooks/teamai.json` contains `--tool copilot`. Team hook commands written for `claude` use the same check. A setup with only Claude keeps running inside Cursor, because there is no second copy. `COPILOT_CLI` is not a signal: Copilot sets it on every subprocess, including a Claude session started from its shell. Claude Code sets neither `CURSOR_VERSION` nor `COPILOT_PROJECT_DIR`. Run `teamai pull` or `teamai hooks inject` again so an already installed team hook picks up the guard. @@ -2258,7 +2264,7 @@ GitHub Copilot CLI is supported for its official custom-instructions, Rules, Ski - **Scopes.** User resources live below `$COPILOT_HOME` (default `~/.copilot`); project resources live below `/.github`. TeamAI honors `COPILOT_HOME` for detection and every user-scope read or write. - **Skills.** `teamai pull` writes user skills to `$COPILOT_HOME/skills/` and project skills to `.github/skills/`. Edits in either scope are detected by `teamai push` like other TeamAI skills. - **Custom instructions.** TeamAI injects team culture and shared instructions into `$COPILOT_HOME/copilot-instructions.md` for user scope or `.github/copilot-instructions.md` for project scope. Marker-delimited TeamAI blocks are replaced idempotently, while text outside the markers remains user-owned. `teamai uninstall` removes only the managed blocks. -- **Rules.** Team rules become native `*.instructions.md` files under `$COPILOT_HOME/instructions/` or `.github/instructions/`. TeamAI derives Copilot's required `applyTo` frontmatter from the team rule's `paths`; a rule without `paths` uses `**`. On push, only the Markdown body flows back, preserving the team-owned `paths` metadata. Unknown Copilot instruction files remain user-owned and are not uploaded or deleted. +- **Rules.** Team rules become native `*.instructions.md` files under `$COPILOT_HOME/instructions/` or `.github/instructions/`. TeamAI derives Copilot's required `applyTo` frontmatter from the team rule's `paths`; a rule without `paths` uses `**`. On push, only the Markdown body flows back, preserving the team-owned `paths` metadata. Unknown Copilot instruction files remain user-owned and are not uploaded or deleted. Copilot CLI 1.0.89 and later also reads a project's `.claude/rules`, so with Claude enabled each project rule reaches Copilot twice; teamai still writes both copies, as it does for every tool that also reads another tool's files. - **Custom agents.** Team agents become official `.agent.md` profiles under `$COPILOT_HOME/agents/` or `.github/agents/`. TeamAI maps compatible tool names onto Copilot's primary aliases, preserves Copilot-only frontmatter through `tool_extras.copilot`, and removes only profiles that match team agents or the built-in recall profile. User-authored profiles remain untouched. See [GitHub's custom-agent configuration](https://docs.github.com/en/copilot/reference/custom-agents-configuration). - **Team Context recall.** The built-in `teamai-recall.agent.md` profile receives only `execute`, `read`, and `search`. It invokes the existing `teamai recall` pipeline, so Copilot can retrieve learnings, codebase evidence, and teamwiki results without copying or creating a second knowledge store. - **Docs and Env.** Team docs sync to the configured local docs directory (`~/.teamai/docs` by default, or the project-relative equivalent in project scope). Team env values sync to the scope's managed `env.sh`; launch Copilot from a shell that has sourced that file. TeamAI does not copy environment values into Copilot configuration. @@ -2274,7 +2280,7 @@ Team hooks still come from the team's `hooks/hooks.yaml`: edit that source in th - **Scopes.** OpenCode's user config lives under `~/.config/opencode/` while its project config lives under `/.opencode/` โ€” a different prefix from every other tool. teamai writes to the correct one per `--scope`, and only ever touches OpenCode files when OpenCode is actually installed for that scope (it never creates `~/.config/opencode/` for a non-user). Hooks are the one exception โ€” they are always user-scoped, for the reason described below. - **Skills** land in `.opencode/skills/` (project) or `~/.config/opencode/skills/` (user). OpenCode also reads `.claude/skills` natively, but teamai writes the OpenCode path too so an OpenCode-only user still gets them. - **Subagents** are rendered into OpenCode's own `agents/*.md` format: frontmatter carries `description` + `mode: subagent` (plus `model` and any `tool_extras.opencode` fields such as `temperature`); the agent name comes from the filename. OpenCode does **not** read `.claude/agents`, so this native copy is required. -- **Rules** are copied into `.opencode/rules/` (or `~/.config/opencode/rules/`), but OpenCode does not auto-scan a rules directory โ€” the files are inert until referenced. teamai therefore adds a `rules/*.md` glob to the `instructions` array in `opencode.json` and removes it again when the team's last rule goes away, editing only that one key and leaving your own `instructions` entries untouched. +- **Rules** are copied into `.opencode/rules/` (or `~/.config/opencode/rules/`), but OpenCode does not auto-scan a rules directory โ€” the files are inert until referenced. teamai therefore adds globs to the `instructions` array in `opencode.json` and removes them again when the team's last rule goes away, editing only that one key and leaving your own `instructions` entries untouched. In a project that is `.opencode/rules/**/*.md` in `.opencode/opencode.json`, beside the team instructions entry; OpenCode globs a relative entry from the session's working directory and each parent up to the worktree, so it loads the namespaced rules from anywhere in the project. A pull removes the `.opencode/rules/*.md` an earlier release wrote to the root `opencode.json`, which loaded no namespaced rule, and leaves that file's other keys alone. In user scope it is the absolute `~/.config/opencode/rules/*.md` plus one glob per namespace directory a rule lands in (`~/.config/opencode/rules//*.md`): OpenCode resolves a relative entry from the session's working directory, and globs only the file name of an absolute one, so `**` never matches. A pull replaces the relative `rules/*.md` an earlier release wrote, which loaded the project's `rules/` instead, and drops a team namespace's glob once its rules no longer reach you; a glob you added for a directory of your own stays. OpenCode ignores `paths:`: it applies every rule it loads to every file. `uninstall` removes the globs, and deletes a `.opencode/opencode.json` left with nothing else in it. - **Hooks** are delivered as an OpenCode *plugin*, not a settings-file entry โ€” OpenCode has no `hooks` array; it auto-loads JS/TS plugins from **both** `~/.config/opencode/plugin/` and `/.opencode/plugin/`. A plugin present in both dirs is loaded twice and would dispatch every event twice, so teamai keeps exactly one copy: `teamai-hooks.ts` in the user dir, which covers every project. Any project-scope copy left by an earlier layout is deleted on the next sync. This matches the other tools, whose `settings.json` hooks also live in HOME and gate on the `cwd` handed to `hook-dispatch`. The plugin subscribes to OpenCode's own events and shelling out to the same `teamai hook-dispatch` entry point every other tool uses. The event mapping mirrors the Claude built-in set: `session.created` โ†’ session-start, `session.idle` โ†’ stop, `chat.message` โ†’ prompt-submit, `tool.execute.after` โ†’ post-tool-use. The plugin forwards the same STDIN payload other agents send (`cwd`, `session_id`, `tool_name`, `tool_input`, `prompt`, and on post-tool-use the tool's output and status), and maps OpenCode's lowercase tool ids (`skill`, `todowrite`) back to the PascalCase matchers the handler registry expects. OpenCode cannot inject a hook's stdout back into the session, so hooks run purely for their side effects (status report / sync / update). Note that OpenCode *awaits* its named hooks (`chat.message`, `tool.execute.after`), so those dispatches briefly wait on the `teamai` subprocess before the agent continues; the errors are always swallowed so a hook can never fail the session. Server-pushed agent hooks (`teamai-agent-.ts`) install into the same user plugin dir. Upvote **adoption** runs for OpenCode from the recall log, not a transcript: the plugin's `shell.env` hook sets `TEAMAI_AGENT_SESSION_ID` in the bash tool's environment, so a `teamai recall` run there joins the session its hooks carry, and a `task` call links the subagent's child session to its parent, so a doc the parent opens after a subagent's recall is upvoted. The opt-in LLM-judge needs a transcript, which `session.idle` does not carry, so it does not run for OpenCode, and the "adopted team knowledge" summary is never shown, as hook stdout is discarded. - **MCP** servers live under the `mcp` key of the shared `opencode.json` (see the MCP section above). @@ -2282,8 +2288,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.** 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. +- **Scopes.** Project skills are written to `.pi/skills/`, user-scope skills to `~/.pi/agent/skills/`. Pi reads no rules directory, so teamai writes it no rule files: the user-scope team rules are a block in `~/.pi/agent/AGENTS.md`, and in a project the TeamAI Pi extension adds the project's team rules to each run's system prompt, both without path scoping. A pull removes the unedited copies earlier releases left in `.pi/rules/` and `~/.pi/agent/rules/` and names the ones you edited. +- **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 and the project's team rules when the session starts and adds them to the system prompt of each run; Pi rebuilds that prompt for every run, so they do not pile up. - **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. @@ -2294,12 +2300,22 @@ Team hooks still come from the team's `hooks/hooks.yaml`: edit that source in th Qoder is available as a built-in target. TeamAI deploys skills, rules, and subagents to `.qoder/skills/`, `.qoder/rules/`, and `.qoder/agents/`. Hooks and MCP servers are merged into the scope-specific `.qoder/settings.json`, preserving unrelated user settings. The paths match Qoder's user and project configuration contracts. -Qoder CN is a separate distribution that keeps its **user** directory at `~/.qoder-cn` instead of `~/.qoder`, so it is a separate built-in target (`qoder-cn`) rather than part of `qoder`. Only the user scope differs: user-scope resources go to `~/.qoder-cn/{skills,rules,agents}` and hooks/MCP to `~/.qoder-cn/settings.json`, while project-scope resources keep Qoder's `/.qoder/` layout. It reads the same Claude-compatible resource formats, so content is identical and only the user-scope root changes. Install both editions and TeamAI syncs each one to its own user directory; neither needs a symlink. +Rules are written in the form Qoder Desktop writes, which Qoder CLI also reads: a rule with `paths:` gets `trigger: glob` and one unquoted `glob:` line of comma-separated globs, with each `{a,b}` alternation expanded into separate globs because the line is split on every comma; a rule without `paths` gets `trigger: always_on`. Qoder publishes no schema for this frontmatter; the form comes from Desktop's rule files in `alibaba/tron-one-agent`. On `push`, only the Markdown body flows back, and a rule file in `.qoder/rules/` with no matching team rule is yours: `pull` leaves it and `push` never offers it as a new team rule. Copies an older teamai wrote verbatim are rewritten on the next `pull` when they still hold what teamai delivered; one you edited is kept and named. + +Qoder CN is a separate distribution that keeps its **user** directory at `~/.qoder-cn` instead of `~/.qoder`, so it is a separate built-in target (`qoder-cn`) rather than part of `qoder`. Only the user scope differs: user-scope resources go to `~/.qoder-cn/{skills,rules,agents}` and hooks/MCP to `~/.qoder-cn/settings.json`, while project-scope resources keep Qoder's `/.qoder/` layout. It reads the same Claude-compatible resource formats, so content is identical and only the user-scope root changes. Install both editions and TeamAI syncs each one to its own user directory; neither needs a symlink. In a project Qoder and Qoder CN both read `.qoder/rules/`, so they share one copy there: uninstalling one keeps it while the other is installed, and `doctor` checks it once, as `Rules delivered to qoder, qoder-cn`. ### Kiro Kiro is available as a built-in target. TeamAI deploys skills, rules, and subagents to `.kiro/skills/`, `.kiro/steering/`, and `.kiro/agents/`, matching [Kiro's documented layouts](https://kiro.dev/docs/skills/) for workspace skills, [steering](https://kiro.dev/docs/steering/), and custom agents. Subagents are rendered as JSON so they work with both Kiro CLI 2.x and 3.x. Each rendered agent preserves Kiro-specific fields and custom hooks, and adds a managed `hooks.agentSpawn` command that dispatches TeamAI's `session-start` event when that custom agent is activated in an interactive CLI session. This verified CLI 2.x hook is embedded in `.kiro/agents/*.json`, not written to the standalone `.kiro/hooks/` surface introduced for IDE 1.x and CLI 3.x; Kiro's in-memory built-in default agent cannot be modified, and `--no-interactive` does not fire `agentSpawn`. MCP servers merge into the scope-specific `.kiro/settings/mcp.json` (see the MCP section above). +Rules are steering files with Kiro's inclusion frontmatter, in `.kiro/steering/` and `~/.kiro/steering/`: a rule with `paths:` gets `inclusion: fileMatch` and `fileMatchPattern` as a list of its globs; a rule without `paths` gets `inclusion: always`. Kiro reads only the top level of a steering directory ([kirodotdev/Kiro#10448](https://github.com/kirodotdev/Kiro/issues/10448)), so a namespaced rule is written flat, as for [Oh My Pi](#oh-my-pi): `rules/fe/style.md` becomes `fe.style.md`, and `push` sends an edit of that file back to `rules/fe/style.md`. Push requires a delivery record for that flat copy; a personal file with the same name is neither refreshed before push nor offered as an edit of the team rule. A namespaced rule whose flat name another rule you receive also has is not written, and a file of your own with a team rule's flat name is never overwritten or removed. An older nested `/.md` copy is removed once its flat copy is written, unless you edited it: then it is kept and named, since Kiro does not read it. On `push`, only the Markdown body flows back, and a steering file with no matching team rule (such as Kiro's own `product.md`) is yours: `pull` leaves it and `push` never offers it as a new team rule. Copies an older teamai wrote verbatim are rewritten on the next `pull` when they still hold what teamai delivered; one you edited is kept and named. Kiro CLI loads every steering file whatever its `inclusion` ([kirodotdev/Kiro#7950](https://github.com/kirodotdev/Kiro/issues/7950)), so a scoped rule is always on there. The Kiro IDE once ignored `fileMatch` in `~/.kiro/steering` ([kirodotdev/Kiro#9176](https://github.com/kirodotdev/Kiro/issues/9176), Kiro 0.12); a maintainer reported fixes since, and the report closed without a retest. + +### CodeBuddy and WorkBuddy + +WorkBuddy runs CodeBuddy's engine, so both get rules in CodeBuddy's format: a rule with `paths:` gets `alwaysApply: false` and `paths:` as a YAML block list, one quoted glob per item; a rule without `paths` gets `alwaysApply: true`. CodeBuddy's frontmatter parser reads lines, not YAML, so the inline `paths: ["a", "b"]` a verbatim copy carried reached it as globs with the brackets in them. In a project both tools read `.codebuddy/rules/`, so that directory holds one copy of each rule for both: excluding or uninstalling one keeps the copies while the other is installed, and `doctor` checks the directory once, as `Rules delivered to codebuddy, workbuddy`. WorkBuddy counts as installed only where `.workbuddy/` exists. In a project that has `.workbuddy/` but no `.codebuddy/`, the shared copy creates `.codebuddy/`, so CodeBuddy then counts as installed there too and also gets skills, agents and hooks; if you do not use CodeBuddy, `teamai uninstall --agent codebuddy` removes them and keeps it excluded, and the shared rules stay for WorkBuddy. In user scope CodeBuddy reads `~/.codebuddy/rules/` and WorkBuddy `~/.workbuddy/rules/`. On `push`, only the Markdown body flows back, and a rule file there with no matching team rule is yours: `pull` leaves it and `push` never offers it as a new team rule. Copies an older teamai wrote verbatim are rewritten on the next `pull` when they still hold what teamai delivered; one you edited is kept and named. + +> Upgrading: earlier releases wrote WorkBuddy's project rules to `.workbuddy/rules/`, which WorkBuddy never read. The next `pull` removes the copies there that still hold what teamai delivered (the directory too, once empty) and writes the rules to `.codebuddy/rules/`, even when the team repo has not moved; a copy you edited is kept and named. WorkBuddy's one-time migration copied `~/.codebuddy/rules/` into `~/.workbuddy/rules/` (it leaves `~/.workbuddy/.migrated-from-codebuddy`), team copies included. A copied team rule that holds what teamai delivered (a render of the rule, or the bytes teamai recorded writing at `~/.codebuddy/rules/` under the same name) is rewritten in CodeBuddy's format while WorkBuddy still gets that rule, and removed otherwise; an edited one is kept and named, and your own rules there stay. + ### ZCode ZCode is available as a built-in target. Skills deploy to `.zcode/skills/` (ZCode also reads the central `~/.agents/skills/`, which the `agents` entry covers), and subagents deploy as Claude-style Markdown to `.zcode/agents/`. Hooks are merged into the shared `~/.zcode/cli/config.json`, preserving unrelated keys such as plugin state. Two ZCode specifics the writer handles for you: @@ -2308,21 +2324,29 @@ ZCode is available as a built-in target. Skills deploy to `.zcode/skills/` (ZCod - On Windows, hook entries launch through a hidden **wscript VBS launcher** (`wscript.exe `): wscript is a GUI-subsystem binary, so hook runs never flash a console window, and the launcher spools STDIN to a temp file so the payload reaches `hook-dispatch`. Timeouts are network-scale per event (180s session start, 60s stop / prompt submit, 30s post-tool-use) so a session-start dispatch carrying a repo pull is not killed mid-flight. Payloads containing multi-byte text may degrade at the launcher's ANSI-codepage spool step โ€” identity fields are salvaged so degraded dispatches stay linked to the session; uninstall removes both the entries and the script. - On POSIX, entries are plain `bash -lc ` argv vectors and the launcher is not written; on both platforms the command tail is stored verbatim as the entry's last argv element, which is what managed-entry detection and the managed-hooks manifest match against. -These paths are verified against the ZCode desktop app: profiles created in its Subagents settings page land in `~/.zcode/agents/*.md`, and files placed there (e.g. by TeamAI) show up in the page's installed list. MCP servers deploy to `~/.agents/mcp.json` (user scope, Claude `mcpServers` shape โ€” the same file ZCode's own MCP settings page reads). Project scope is not wired: ZCode stores workspace MCP under a different key (`mcp.servers` inside `.zcode/config.json`), which the Claude writer cannot emit. ZCode has no user-level rules directory convention, so rules are not synced. +These paths are verified against the ZCode desktop app: profiles created in its Subagents settings page land in `~/.zcode/agents/*.md`, and files placed there (e.g. by TeamAI) show up in the page's installed list. MCP servers deploy to `~/.agents/mcp.json` (user scope, Claude `mcpServers` shape โ€” the same file ZCode's own MCP settings page reads). Project scope is not wired: ZCode stores workspace MCP under a different key (`mcp.servers` inside `.zcode/config.json`), which the Claude writer cannot emit. ZCode reads no rules directory: in user scope the team rules are a block in `~/.zcode/AGENTS.md`, which ZCode reads as its user context, without path scoping. In a project, teamai's `SessionStart` hook in `~/.zcode/cli/config.json` adds the project's team rules to each new session (ZCode runs no project-level hooks). ZCode drops that text when it compacts a session, so the rules come back in the next session. ### 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). 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. +Rules are written in OMP's own frontmatter, in `.omp/rules/` and `~/.omp/agent/rules/`: a rule without `paths:` gets `alwaysApply: true`, so its text is in every prompt; a rule with `paths:` gets `globs` with its globs and a `description` (its first Markdown heading, or `Team rule for files matching `), so OMP lists it in the prompt's rulebook as `name (globs): description` and reads it when the work matches. OMP drops a rule with neither, which is what every verbatim copy teamai wrote before was. OMP reads only the top level of its rules directory, so a namespaced rule is written flat: `rules/fe/style.md` becomes `fe.style.md`, and `push` sends an edit of that file back to `rules/fe/style.md`. Push requires a delivery record for that flat copy; a personal file with the same name is neither refreshed before push nor offered as an edit of the team rule. A namespaced rule whose flat name another rule you receive also has is not written: a root rule such as `rules/fe.style.md` keeps the file, two namespaced rules both go without, and `pull` names them while `doctor` fails. A file of your own that has a team rule's flat name is never overwritten or removed: only the content a delivery record holds, or the exact render, makes it teamai's. A flat copy you edited after delivery is kept and named by `remove` and `uninstall`. On `push`, only the Markdown body flows back, and a rule file in an OMP rules directory with no matching team rule is yours: `pull` leaves it and `push` never offers it as a new team rule. Copies an older teamai wrote verbatim are rewritten on the next `pull` when they still hold what teamai delivered, and an older nested `/.md` copy is removed once its flat copy is written, with or without a delivery record (the record, or the team rule verbatim, proves it unedited). One you edited is kept and named, since OMP does not read it: copy your edit into the flat file to keep it. OMP reads `.omp/rules/` only in the directory the session starts in, so the project's rules reach a session started at the project root, not one started in a subdirectory. OMP also loads a project's Cursor rules (`.cursor/rules/*.mdc`, top level only) and Copilot instructions (`.github/instructions/**/*.instructions.md`) as rules, and keeps one rule per name, its own `.omp/rules` copy first (checked against OMP 18.2.1's loader). A root team rule therefore reaches OMP once with Cursor or Copilot enabled, but a namespaced one reaches it twice with Copilot: as `fe.style` from `.omp/rules` and as `style` from `.github/instructions/fe/`. OMP reads `~/.cursor/rules` only when you enable that source. + ### DeepSeek Harness -DeepSeek Harness (`dsh`) is supported for TeamAI skills and shared resources. DSH's official Claude-hook bridge is a profile plugin rather than a settings-file hook surface, so when a user-level `~/.dsh/` installation is present, `teamai init`, `teamai pull`, or `teamai hooks inject` writes a Claude-compatible hook config and a Cordis patch under `~/.teamai/dsh/`. +DeepSeek Harness (`dsh`) is supported for TeamAI skills and shared resources. DSH's official Claude-hook bridge is a profile plugin rather than a settings-file hook surface, so when dsh's home (`$DSH_HOME`, `~/.dsh/` when it is unset) exists, `teamai init`, `teamai pull`, or `teamai hooks inject` writes a Claude-compatible hook config and a Cordis patch under `~/.teamai/dsh/`. + +dsh reads no rules directory. In user scope the team rules are a block in `$DSH_HOME/AGENTS.md` (`~/.dsh/AGENTS.md` when `DSH_HOME` is unset), which dsh puts in its first request, without path scoping. As for hooks, teamai writes it only when that home exists. Skills still go to `~/.dsh/skills/`, and only while `~/.dsh/` exists, whatever `DSH_HOME` says. In a project, teamai's session-start hook adds the project's team rules, once dsh runs with the patch below. dsh runs that hook detached, so the first request can miss them, and drops them when it compacts a session. TeamAI prints the exact absolute patch path. Add that `--patch` flag to the command that starts your DSH profile, for example `dsh tui --patch ""`. This is a one-time launcher opt-in; `teamai hooks remove` and `teamai uninstall` remove the TeamAI patch while preserving other hook entries in the generated config. ### JoyCode -JoyCode is available as a built-in target. Skills, rules, and subagents are deployed to `.joycode/skills/`, `.joycode/rules/`, and `.joycode/agents/`. Rules use Cursor-compatible `.mdc` files, including the same derived frontmatter and body-only round-trip behavior described below. Subagents use Markdown with YAML frontmatter. +JoyCode is available as a built-in target. Skills, rules, and subagents are deployed to `.joycode/skills/`, `.joycode/rules/`, and `.joycode/agents/`. Subagents use Markdown with YAML frontmatter. + +Rules are `.mdc` files in JoyCode's own render. JoyCode reads the frontmatter line by line, not as YAML: it keeps the quotes Cursor's render puts around `globs` and splits the value on every comma, so a scoped rule in Cursor's form never applied. A rule with `paths:` gets `globs:` unquoted and comma-separated, with each `{a,b}` alternation expanded into separate globs, and `alwaysApply: false`; a rule without `paths` gets `alwaysApply: true`. On `push`, only the Markdown body flows back, as for Cursor. Copies an older teamai wrote in Cursor's form are rewritten on the next `pull` when they still hold what teamai delivered; one you edited is kept and named, and while its `globs` are still quoted, `pull` says it applies to no file and how to fix it. `doctor` compares each copy in a project's `.joycode/rules/` with this render. + +In user scope JoyCode reads no rules directory: the team rules are a block in `~/.joycode/rules.txt`, without path scoping. A pull removes the unedited `.mdc` copies earlier releases left in `~/.joycode/rules/` and names the ones you edited. JoyCode rule cleanup is conservative: local `.mdc` and `.md` files absent from the team rule list are preserved unless an explicit team removal tombstone exists. This protects personal rules in the shared directory; an old team copy without a deletion record is retained rather than guessed to be stale. @@ -2334,7 +2358,7 @@ For canonical YAML agents, push compares each local file with the corresponding Cursor subagents deploy to `.cursor/agents/*.md` with YAML frontmatter carrying `agent_id` (the team agent's name), `description`, `tools`, and the agent's `model` when it declares one, plus any `tool_extras.cursor` fields; `reverseFromCursor` reads the same fields back, so a `pull` โ†’ `push` round-trip keeps the model. -Cursor project rules must live in `.cursor/rules/` as **`.mdc`** files with YAML frontmatter โ€” a plain `.md` file there is silently ignored by Cursor. teamai therefore writes rules to Cursor as `.mdc` (every other tool still gets a plain `.md`), deriving the frontmatter from the team rule: +Cursor project rules must live in `.cursor/rules/` as **`.mdc`** files with YAML frontmatter โ€” a plain `.md` file there is silently ignored by Cursor. teamai therefore writes rules to Cursor as `.mdc` (JoyCode, Copilot, Kiro, Qoder, Qoder CN, CodeBuddy, WorkBuddy and Oh My Pi get a format of their own, described in their sections; every other tool gets a plain `.md`), deriving the frontmatter from the team rule: - A rule scoped with a `paths:` list becomes `globs: ""` + `alwaysApply: false` (Cursor auto-attaches it when a matching file is in context). The value is quoted because a glob starting with `*` is not valid YAML unquoted. - A rule with no `paths` (a mandatory team rule) becomes `alwaysApply: true` (applied to every Cursor chat session). @@ -2370,9 +2394,13 @@ teamai remove rules --force # Skip the prompt, for scripts and CI Besides the provider, clone, config and hook checks, `doctor` verifies what reached your machine. ` is installed` fails when `enabledAgents` lists a tool that nothing would be delivered to, which is the case where a pull reports success and that tool receives nothing. It asks the same resolver the sync uses, so a tool that keeps its skills somewhere other than its tool root, as OpenClaw does with its workspace directory, is judged where the sync would actually write. It reports an installed tool as passing too, so `--json` carries one entry per enabled tool either way. The checks at the end of a pull cover the scope that pull resolved from the current directory; run `teamai doctor` in another scope to check that one. `Skills delivered to ` compares the skills your role namespaces, tag subscriptions and exclusions resolve to against what is on disk for each installed tool: it reports a skill that was never delivered separately from one that arrived unreadable โ€” `SKILL.md` missing, its frontmatter unparseable, or its `name` not matching the directory, which keeps the agent from ever discovering it. `Team docs delivered` compares the docs you receive (a docs namespace you do not have active is left out) against `sharing.docs.localDir`, which has one destination rather than one per tool; each expected document has to be a file that can be read, so a directory or a dangling link sitting on the name counts as missing. It also reports extra non-hidden local files as stale, including when the team bundle is empty. Hidden local files are preserved and do not fail this check, and neither does a local copy of a team doc in a namespace you do not have active: pull removes it when it is unchanged and names it when you edited it. `doctor` also prints notes, which are information rather than failed checks. Each note names a namespace skill, agent, rule, shared-instructions file, env variable, hook, MCP server or team model profile that replaces a root one here (`rules: "style" from rules/checkout/style.md replaces rules/style.md`). When a namespace contributes env variables, hooks, MCP servers or team model profiles, a note also counts where that type's entries come from (`env: 3 received here (2 root, 1 checkout)`). Without roles or projects, the notes name each file the team repo defines more than once instead, and each env variable, hook or MCP server name repeated in its root file. -`Rules delivered to ` and `Agents delivered to ` do the same for the other two per-tool resources, and both ask the handler where an item lands rather than deriving a path: a rule's filename and content change per tool (`.md` verbatim, `.mdc` with derived `globs`/`alwaysApply`, `.instructions.md` with `applyTo`), and an agent's destination comes from its render, with `targets:` deciding which tools are owed a copy at all. A delivered rule is compared with the bytes the handler renders for that tool, not merely read for the keys its tool needs: a `.mdc` whose `globs` no longer match the team rule's `paths:` applies to the wrong files while carrying a perfectly legal `alwaysApply`, and that reads here as `delivered from an older copy` โ€” the same label as a body that drifted, because both landed successfully and are still wrong. An agent is compared with the bytes its render produces, so a copy left behind by an older spec โ€” a plain pull skips a scope whose team repo has not changed, so it can sit there indefinitely โ€” is reported as `delivered from an older spec` rather than passing as present. `Every team agent reaches a tool` names an agent that renders for no installed tool โ€” usually a spec that does not parse, or a `targets:` list naming only tools you do not have. These two are `doctor`-only: they read every rule per tool and parse every agent, which would spend the budget the checks at the end of a pull run under. +For Oh My Pi and Kiro, `doctor` reports flat-name collisions even when every desired rule collides and no file can be written. Rename one of the rules in the team repo, then run `teamai pull`. + +`Rules delivered to ` and `Agents delivered to ` do the same for the other two per-tool resources, and both ask the handler where an item lands rather than deriving a path: a rule's filename and content change per tool (`.md` verbatim, `.mdc` with derived `globs`/`alwaysApply` (unquoted for JoyCode), `.instructions.md` with `applyTo`, Kiro's flat `.md` with `inclusion`/`fileMatchPattern`, Qoder's `.md` with `trigger`/`glob`, Oh My Pi's flat `.md` with `alwaysApply` or `globs`/`description`, CodeBuddy's `.md` with `alwaysApply`/`paths`; tools that read one copy, as CodeBuddy and WorkBuddy do in a project, get one check naming both), and an agent's destination comes from its render, with `targets:` deciding which tools are owed a copy at all. A delivered rule is compared with the bytes the handler renders for that tool, not merely read for the keys its tool needs: a `.mdc` whose `globs` no longer match the team rule's `paths:` applies to the wrong files while carrying a perfectly legal `alwaysApply`, and that reads here as `delivered from an older copy` โ€” the same label as a body that drifted, because both landed successfully and are still wrong. An agent is compared with the bytes its render produces, so a copy left behind by an older spec โ€” a plain pull skips a scope whose team repo has not changed, so it can sit there indefinitely โ€” is reported as `delivered from an older spec` rather than passing as present. `Every team agent reaches a tool` names an agent that renders for no installed tool โ€” usually a spec that does not parse, or a `targets:` list naming only tools you do not have. These two are `doctor`-only: they read every rule per tool and parse every agent, which would spend the budget the checks at the end of a pull run under. + +After the last team rule is removed, `doctor` still reports team-owned OpenCode globs or inline blocks left by failed cleanup. Run `teamai pull` to remove them. -Three tools do not read a rules directory, so a per-file check cannot speak for them and each gets one of its own. `Team rules are active in opencode` checks that `opencode.json` still lists the glob the pull owns under `instructions`: OpenCode does not auto-scan `.opencode/rules`, so without it every delivered `.md` is inert while the per-file check keeps passing. `Team rules are inlined in Hermes SOUL.md` compares the teamai-managed block of `SOUL.md` with what the team rules inline to, since Hermes reads standing instructions from that one file rather than from a directory โ€” a deleted block, or one left on an older rule set, is a tool reading the wrong rules with nothing on disk to show for it. In user scope, `Team rules are inlined in Codex AGENTS.md` compares the team-rules block of the tool's `AGENTS.md` with what the team rules inline to, and fails when an `AGENTS.override.md` beside it shadows the file. In a project, `Project rules and instructions reach whole through its session hooks` fails when the teamai `SessionStart` or `SubagentStart` entry in that tool's `hooks.json` is missing or does not set `additionalContextLimit: 0`, without which Codex keeps only the start and end of a large set. Codex has no `Rules delivered to ` check. +Several tools do not read a rules directory, so a per-file check cannot speak for them and each gets one of its own. `Team rules are active in opencode` checks that `opencode.json` (`.opencode/opencode.json` in a project) lists every glob the pull owns under `instructions`, and no stale one such as the relative `rules/*.md` an earlier release wrote: OpenCode does not auto-scan `.opencode/rules`, so without it every delivered `.md` is inert while the per-file check keeps passing. In user scope, `Team rules are inlined in Hermes SOUL.md` compares the teamai-managed block of `SOUL.md` with what the team rules inline to, since Hermes reads standing instructions from that one file rather than from a directory โ€” a deleted block, or one left on an older rule set, is a tool reading the wrong rules with nothing on disk to show for it. In user scope, `Team rules are inlined in ` (`Codex AGENTS.md`, `ZCode AGENTS.md`, `DeepSeek Harness AGENTS.md`, `OpenClaw workspace AGENTS.md`, `Pi AGENTS.md`, `JoyCode rules.txt`) compares the team-rules block of the file that tool reads with what the team rules inline to; for Codex it also fails when an `AGENTS.override.md` beside it shadows the file. In a project, `Project rules and instructions reach whole through its session hooks` fails when the teamai `SessionStart` or `SubagentStart` entry in that tool's `hooks.json` is missing or does not set `additionalContextLimit: 0`, without which Codex keeps only the start and end of a large set. In a project, `Project rules reach zcode through its SessionStart hook` fails when `~/.zcode/cli/config.json` has no teamai `SessionStart` entry or does not set `hooks.enabled: true`, and `Project rules reach dsh through its session-start hook` fails when the patch or the hook config under `~/.teamai/dsh/` is missing; doctor cannot see whether dsh runs with `--patch`. For Pi, `pi adds the team instructions and rules to its prompt` fails while teamai's Pi extension is missing or out of date. Codex, ZCode, DeepSeek Harness and Pi have no `Rules delivered to ` check. `MCP servers delivered to ` compares each server the team's `mcp.yaml` resolves for that tool against the entry in the tool's own config, and names any the reconcile skipped with its reason. The comparison is the entry, not the name: reconciliation leaves an entry teamai does not own alone, so a server of your own under a team name holds the key while the team's definition never arrives, and a stale copy is just as undelivered. Both are reported as `not the team's definition`, and only `teamai pull --force` replaces an entry teamai did not write. An unresolved `${VAR}` is reported here with the variable's name, which is otherwise said once during a pull and never again. A declared secret with no value is not a failure: doctor prints it as a note (`notes` in `--json`) with the command that sets it, and the exit code stays as it would be without it; a note also says when an entry kept for it may hold an old value, and when a key is declared as a secret and also set in `env.yaml`. An `mcp.yaml` that does not parse is not a team without MCP: it is reported as `Team MCP servers can be read` with the parse error, since it injects nothing into any tool and every run after the first is silent about it. Team hooks and team model profiles that cannot be resolved (a file that does not parse, a name defined twice in one file, or one name in two active namespaces) fail `Team hooks can be resolved` and `Team model profiles can be resolved` with the reason pull logs once; `teamai status` points here when it counts them as 0. `Env variables injected in shell profile` no longer stops at finding the marker comment: it checks that `env/env.yaml` parses and declares its variables under the `variables:` key (a plain `KEY: value` mapping parses as none, while an explicit `variables: []` is a configuration with nothing to deliver and fails nothing), that each one reached `env.sh` with the value `env.yaml` declares, or your value for this team (one set with `--from-env` is not written there) โ€” a key left over from an older value exports it to every shell and MCP server until the next pull, and the comparison reads `env.sh` back through the generator's own inverse, so a multiline value quoted across several lines is matched rather than called stale โ€” and that this scope's injected block (the one sourcing its own `env.sh`, since a profile can also carry another scope's) would actually load it โ€” an unquoted Windows path degrades to something a POSIX shell cannot read, so `source` never runs and nothing says so. `No stale env blocks left behind` is a separate check: which file `pull` prefers has changed over time (Windows Git Bash's login shell reads `.bash_profile`/`.bash_login`/`.profile`, never `.bashrc`), and a pull only ever adds a block, never migrates an old one away, so a dead block from an earlier install or platform change can sit in another candidate file indefinitely. It names every such file (checking `.zshrc`, `.bashrc`, `.bash_profile`, `.bash_login` and `.profile`, current and legacy spellings alike) and points at `teamai uninstall` to remove them โ€” separately from delivery, so a working env block never reads as broken just because an old one is still lying around. @@ -2931,9 +2959,10 @@ 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 (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) +- The teamai blocks (culture, shared instructions, recall) 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) +- The team-rules block in the file a tool with no rules format reads in user scope (`~/.codex/AGENTS.md`, `~/.zcode/AGENTS.md`, `$DSH_HOME/AGENTS.md`, the OpenClaw workspace `AGENTS.md`, `~/.pi/agent/AGENTS.md`, `~/.joycode/rules.txt`), and the file when teamai created it for the block alone - 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 rules, including the copies older releases left in `.codex/rules/`, a project's `.workbuddy/rules/` and `.pi/rules/`, `.openclaw/rules/`, `~/.pi/agent/rules/` and `~/.joycode/rules/`, also of rules the team has since removed. A copy in a project's `.codebuddy/rules/` stays while the other of CodeBuddy and WorkBuddy is still installed. Cleanup follows the recorded `toolRoots` location and the publisher's local filenames. A copy there you edited is kept and named in a warning. A removed rule's copy is deleted only if it matches its recorded delivery hash; without that record, it is kept and named too. Codex's `*.rules` files are kept - Team-synced custom agents and CLI built-in agents (your own agents are preserved) - The env block in your shell profile โ€” every candidate file (`.zshrc`, `.bashrc`, `.bash_profile`, `.bash_login`, `.profile`) carrying a block that sources this scope's own `env.sh` is cleaned, not only the one file `pull` would choose today; a block sourcing a different scope's `env.sh` is left alone - In a project, teamai's git hook: the `hook.teamai-post-checkout` and `hook.teamai-post-merge` entries in the repository's git config, and the marked block in `.git/hooks/post-checkout` and `post-merge` (a script left with only its shebang, the one teamai created, is deleted). Other hooks are kept diff --git a/docs/usage-guide.zh-CN.md b/docs/usage-guide.zh-CN.md index 85f0f9cfb..f749db82a 100644 --- a/docs/usage-guide.zh-CN.md +++ b/docs/usage-guide.zh-CN.md @@ -636,7 +636,7 @@ teamai pull --dry-run # ่ฏ•่ฟ่กŒ๏ผŒไธๅฎž้™…ไฟฎๆ”น - skill ๆŒ‰็›ฎๅฝ•ๅๆ›ฟๆขๆ น็›ฎๅฝ•็š„ๅŒๅ skill๏ผŒๅŒ…ๆ‹ฌไฝ ้€š่ฟ‡ๆ ‡็ญพๆ”ถๅˆฐ็š„ๆ น็›ฎๅฝ• skillใ€‚ๅฎ‰่ฃ…ๆ—ถไผšๅˆ ้™ค่ขซๆ›ฟๆข็‰ˆๆœฌ็š„ๆ–‡ไปถ๏ผ›ไปปไฝ•ๅ›ข้˜Ÿ็‰ˆๆœฌ้ƒฝๆฒกๆœ‰็š„ๆ–‡ไปถไผšไฟ็•™ใ€‚ - agent ๆŒ‰ๆ–‡ไปถๅ๏ผˆไธๅซๆ‰ฉๅฑ•ๅ๏ผ‰ๆ›ฟๆขๆ น็›ฎๅฝ•็š„ๅŒๅ agentใ€‚ -- rule ๆŒ‰็ฌฌไธ€ๅฑ‚ๆ–‡ไปถๅๆ›ฟๆข๏ผš`rules//.md` ๆ›ฟๆข `rules/.md`๏ผŒHermes ็š„ `SOUL.md` ๅŒบๅ—ๅ’Œ Codex ็š„ session-start hook ๆทปๅŠ ็š„ rule ๅŒๆ ทๅฆ‚ๆญคใ€‚ๆ›ดๆทฑ็š„่ทฏๅพ„๏ผˆๅฆ‚ `rules///.md`๏ผ‰ไธๆ›ฟๆขไปปไฝ•ๆ–‡ไปถ๏ผŒ่ขซไฝ ็š„ๆ ‡็ญพ่ฎข้˜…ๆŽ’้™ค็š„ namespace rule ไนŸไธๆ›ฟๆขใ€‚ๅœจไธŽไฝ ่‡ชๅทฑ็š„ rule ๅ…ฑ็”จ็š„็›ฎๅฝ•ไธญ๏ผˆJoyCodeใ€OMPใ€Piใ€Copilot๏ผ‰๏ผŒ่ขซๆ›ฟๆข็š„ๆ น rule ๅ‰ฏๆœฌๅชๅœจไปๆ˜ฏ teamai ๆ‰€ไธ‹ๅ‘็š„ๅ†…ๅฎน๏ผˆๅฝ“ๅ‰็š„ๆ น rule๏ผŒๆˆ–ไฝ ไธŠๆฌก pull ๆ—ถ็š„็‰ˆๆœฌ๏ผ‰ๆ—ถๅˆ ้™ค๏ผ›ไฝ ๆ”น่ฟ‡็š„ๅ‰ฏๆœฌไผšไฟ็•™๏ผŒไธ”ๆฏๆฌก pull ้ƒฝไผš็‚นๅๅฎƒ๏ผŒๅ› ไธบๅทฅๅ…ทไผšๆŠŠๅฎƒไธŽ namespace rule ไธ€่ตทๅŠ ่ฝฝใ€‚ +- rule ๆŒ‰็ฌฌไธ€ๅฑ‚ๆ–‡ไปถๅๆ›ฟๆข๏ผš`rules//.md` ๆ›ฟๆข `rules/.md`๏ผŒHermes ็š„ `SOUL.md` ๅŒบๅ—ไปฅๅŠ session-start hook ๆˆ– Pi ๆ‰ฉๅฑ•ๆทปๅŠ ็š„ rule ๅŒๆ ทๅฆ‚ๆญคใ€‚ๆ›ดๆทฑ็š„่ทฏๅพ„๏ผˆๅฆ‚ `rules///.md`๏ผ‰ไธๆ›ฟๆขไปปไฝ•ๆ–‡ไปถ๏ผŒ่ขซไฝ ็š„ๆ ‡็ญพ่ฎข้˜…ๆŽ’้™ค็š„ namespace rule ไนŸไธๆ›ฟๆขใ€‚ๅœจไธŽไฝ ่‡ชๅทฑ็š„ rule ๅ…ฑ็”จ็š„็›ฎๅฝ•ไธญ๏ผˆ้™ค Cursor ๅค–ๆฏไธชๆœ‰่‡ชๆœ‰ rules ๆ ผๅผ็š„ๅทฅๅ…ท๏ผšJoyCodeใ€Copilotใ€Kiroใ€Qoderใ€CodeBuddyใ€WorkBuddy ๅ’Œ Oh My Pi๏ผ‰๏ผŒ่ขซๆ›ฟๆข็š„ๆ น rule ๅ‰ฏๆœฌๅชๅœจไปๆ˜ฏ teamai ๆ‰€ไธ‹ๅ‘็š„ๅ†…ๅฎน๏ผˆๅฝ“ๅ‰็š„ๆ น rule๏ผŒๆˆ–ไฝ ไธŠๆฌก pull ๆ—ถ็š„็‰ˆๆœฌ๏ผ‰ๆ—ถๅˆ ้™ค๏ผ›ไฝ ๆ”น่ฟ‡็š„ๅ‰ฏๆœฌไผšไฟ็•™๏ผŒไธ”ๆฏๆฌก pull ้ƒฝไผš็‚นๅๅฎƒ๏ผŒๅ› ไธบๅทฅๅ…ทไผšๆŠŠๅฎƒไธŽ namespace rule ไธ€่ตทๅŠ ่ฝฝใ€‚ - `claudemd//.md` ๅœจๆ‰˜็ฎกๅŒบๅ—ไธญๆ›ฟๆข `claudemd/.md`ใ€‚ ่ฏฅ namespace ไธๅ†ๆดป่ทƒๅŽ๏ผŒไธ‹ไธ€ๆฌก pull ไผš้‡ๆ–ฐไธ‹ๅ‘ๆ น็›ฎๅฝ•ๆก็›ฎใ€‚ไธคไธชๆดป่ทƒ namespace ๅฎšไน‰ๅŒๅ skill ๆˆ– agent ๆ—ถ๏ผŒๅฎƒไปฌไผšไบ‰็”จๅŒไธ€ไธชๅฎ‰่ฃ…ๆ–‡ไปถ๏ผŒๅ› ๆญค pull ไผšๆŠฅ้”™ๅนถๅˆ—ๅ‡บไธคไธชๆ–‡ไปถ๏ผŒๆœฌๆฌก่ฟ่กŒไธๆ›ดๆ–ฐ่ฏฅ็ฑปๅž‹๏ผŒๅทฒๅฎ‰่ฃ…็š„ๅ†…ๅฎนไฟๆŒไธๅ˜๏ผˆskills ๅœจ recall ไธญๅทฒๆœ‰็š„็ดขๅผ•ไนŸไฟๆŒไธๅ˜๏ผ‰๏ผ›ๅ…ถไป–่ต„ๆบ็ฑปๅž‹็…งๅธธๅŒๆญฅใ€‚ไธคไธชๆดป่ทƒ namespace ๅฎšไน‰ๅŒๅ rule ๆˆ–ๅ…ฑไบซๆŒ‡ไปคๆ—ถ๏ผŒไธค่€…้ƒฝไผšไธ‹ๅ‘๏ผŒๅ› ไธบๅฎƒไปฌๅ„ๆœ‰่‡ชๅทฑ็š„ไฝ็ฝฎ๏ผˆๆœฌๅœฐ็š„ `rules//`ใ€ๅŒบๅ—ไธญๅ„่‡ช็š„ไธ€ๆฎต๏ผ‰๏ผ›ๅชๆœ‰ๆ น็›ฎๅฝ•็š„้‚ฃไธ€ไปฝไผš่ฎฉไฝใ€‚`push` ไผšๆŠŠ่ขซๆ›ฟๆขๆก็›ฎ็š„ไฟฎๆ”นๅ†™ๅ›žๅ…ถ namespace๏ผŒ่€Œไธไผšๅ†™ๅˆฐๆ น็›ฎๅฝ•๏ผ›recall ๅช็ดขๅผ•ไฝ ๅฎž้™…ๆ”ถๅˆฐ็š„ skills ๅ’Œ rules๏ผŒ่€Œไธๆ˜ฏไป“ๅบ“ไธญ็š„ๅ…จ้ƒจๅ†…ๅฎนใ€‚ๆ— ๆณ•ไฝฟ็”จ็š„ๆ›ฟๆข้กนไธไผšๆ›ฟๆขไปปไฝ•ๅ†…ๅฎน๏ผšๆฒกๆœ‰ `SKILL.md` ็š„ skill ็›ฎๅฝ•ไธไผšไธ‹ๅ‘๏ผŒpull ไผš็‚นๅๆ็คบ๏ผ›agent ๆ–‡ไปถๆ— ๆณ•่งฃๆžๆ—ถ๏ผŒๅฎƒๅŽŸๆœฌ่ฆๆ›ฟๆข็š„ agent ไฟๆŒๅฎ‰่ฃ…ใ€‚`teamai doctor` ไผšไปฅๆ็คบ็š„ๅฝขๅผๅˆ—ๅ‡บๆฏไธ€ๅค„ๆ›ฟๆขใ€‚ๆœช้…็ฝฎ่ง’่‰ฒๆˆ–้กน็›ฎๆ—ถ่กŒไธบไธๅ˜๏ผšๆ‰€ๆœ‰ namespace ไธŽๆ น็›ฎๅฝ•ๅนถๅˆ—ไธ‹ๅ‘๏ผŒ`doctor` ไผšๅˆ—ๅ‡บๅ›ข้˜Ÿไป“ๅบ“ไธญ้‡ๅคๅฎšไน‰็š„ๆฏไธชๅ็งฐใ€‚ @@ -738,7 +738,7 @@ excludedSkills: ### ๆŽจ้€ๆœฌๅœฐ่ต„ๆบ -ๆ‰ซๆๅ‰๏ผŒ`push` ไผš็”จๅ›ข้˜Ÿไป“ๅบ“็š„ๆ–ฐ็‰ˆๅˆทๆ–ฐๆœชไฟฎๆ”น็š„ๆ—ง่ง„ๅˆ™ๅ‰ฏๆœฌใ€‚ๅฏนไบŽ Copilot๏ผŒไผšๅ•็‹ฌๆฏ”่พƒ Markdown ๆญฃๆ–‡๏ผŒๅฟฝ็•ฅ่‡ชๅŠจ็”Ÿๆˆ็š„ `applyTo` ๅคด๏ผŒๅนถไปฅ `.instructions.md` ๆ ผๅผๅ†™ๅ…ฅๆ›ดๆ–ฐ๏ผ›ๆœฌๅœฐๆญฃๆ–‡็ผ–่พ‘ไผšไฟ็•™ใ€‚ๆญค่กŒไธบ้€‚็”จไบŽ้กน็›ฎ่ง„ๅˆ™ๅ’Œ `COPILOT_HOME` ไธ‹็š„็”จๆˆท่ง„ๅˆ™ใ€‚ๅฎƒๅˆทๆ–ฐ็š„ๆฏไปฝๅ‰ฏๆœฌ้ƒฝไผš่ฎฐๅฝ•ไธบ teamai ๅ†™ๅ…ฅ็š„ๅ†…ๅฎน๏ผŒๅ› ๆญคไธ‹ไธ€ๆฌก `teamai pull` ไปไผšๆ›ดๆ–ฐๅฎƒ๏ผŒ่€Œไธไผšๅฝ“ไฝœไฝ ็š„ไฟฎๆ”นไฟ็•™ใ€‚ +ๆ‰ซๆๅ‰๏ผŒ`push` ไผš็”จๅ›ข้˜Ÿไป“ๅบ“็š„ๆ–ฐ็‰ˆๅˆทๆ–ฐๆœชไฟฎๆ”น็š„ๆ—ง่ง„ๅˆ™ๅ‰ฏๆœฌใ€‚ๅฏนไบŽๆœ‰่‡ชๆœ‰่ง„ๅˆ™ๆ ผๅผ็š„ๅทฅๅ…ท๏ผˆCursor ็š„ `.mdc`ใ€JoyCode ่‡ชๅทฑ็š„ `.mdc`ใ€Copilot ็š„ `.instructions.md`ใ€Kiro steering๏ผŒไปฅๅŠ Qoderใ€CodeBuddyใ€WorkBuddy ไธŽ Oh My Pi rules๏ผ‰๏ผŒไผšๅ•็‹ฌๆฏ”่พƒ Markdown ๆญฃๆ–‡๏ผŒๅฟฝ็•ฅ่‡ชๅŠจ็”Ÿๆˆ็š„ๅคด้ƒจ๏ผŒๅนถไปฅ่ฏฅๅทฅๅ…ท็š„ๆ ผๅผๅ†™ๅ…ฅๆ›ดๆ–ฐ๏ผ›ๆœฌๅœฐๆญฃๆ–‡็ผ–่พ‘ไผšไฟ็•™ใ€‚ๅฏน Copilot๏ผŒๆญค่กŒไธบ้€‚็”จไบŽ้กน็›ฎ่ง„ๅˆ™ๅ’Œ `COPILOT_HOME` ไธ‹็š„็”จๆˆท่ง„ๅˆ™ใ€‚ๅฎƒๅˆทๆ–ฐ็š„ๆฏไปฝๅ‰ฏๆœฌ้ƒฝไผš่ฎฐๅฝ•ไธบ teamai ๅ†™ๅ…ฅ็š„ๅ†…ๅฎน๏ผŒๅ› ๆญคไธ‹ไธ€ๆฌก `teamai pull` ไปไผšๆ›ดๆ–ฐๅฎƒ๏ผŒ่€Œไธไผšๅฝ“ไฝœไฝ ็š„ไฟฎๆ”นไฟ็•™ใ€‚่ฟ™ไบ›ๅทฅๅ…ท็š„ rules ็›ฎๅฝ•ไธญๆ–ฐๅปบ็š„ๆ–‡ไปถๆ˜ฏไฝ ่‡ชๅทฑ็š„ใ€่ฏฅๅทฅๅ…ทๆ ผๅผ็š„ rule๏ผŒๅ› ๆญค `push` ไปŽไธๆไบคๅฎƒ๏ผ›่ฆๅˆ†ไบซๆ–ฐ็š„ๅ›ข้˜Ÿ rule๏ผŒ่ฏทๆŠŠๅฎƒๅ†™ๆˆ `.claude/rules/` ไธ‹็š„ๆ™ฎ้€š `.md`๏ผˆ้œ€่ฆๆ—ถ็”จ `paths:` ้™ๅฎš่Œƒๅ›ด๏ผ‰๏ผŒๅ† pushใ€‚ ๅ›ข้˜Ÿไป…ไฟฎๆ”น `paths` ๆ—ถ๏ผŒๅช่ฆๆœฌๅœฐๆ–‡ไปถไปไธŽๆŸไธชๅทฒ่ฎฐๅฝ•็‰ˆๆœฌ็š„็”Ÿๆˆๅ‰ฏๆœฌไธ€่‡ด๏ผŒ`push` ไนŸไผšๅˆทๆ–ฐ Copilot ็š„ `applyTo`๏ผ›ๆญคๆ—ถๆœฌๅœฐๆ‰‹ๅŠจไฟฎๆ”น่ฟ‡็š„ๅคด้ƒจไผšไฟ็•™ใ€‚ @@ -908,6 +908,8 @@ teamai push --role pm ### Rules๏ผˆ่ง„ๅˆ™๏ผ‰ +ๅธฆไฝœ็”จ่Œƒๅ›ด็š„ rule ไธญ๏ผŒYAML ๆณจ้‡ŠไธๅฑžไบŽ glob๏ผš`paths: **/*.ts # TypeScript files` ็š„ๅŽŸ็”Ÿ่ง„ๅˆ™ๅชๅŒน้… `**/*.ts`๏ผŒๅ†…่”ๆธ ้“ไนŸๅพ—ๅˆฐๅŒๆ ท็š„่ทฏๅพ„ๆ็คบใ€‚`paths:` ไธ‹็š„ๅ—ๅˆ—่กจๆก็›ฎๅŒๆ ทๅฆ‚ๆญคใ€‚ + ```bash # ๅˆ›ๅปบ rule cat > ~/.claude/rules/code-review-guide.md << 'EOF' @@ -923,11 +925,11 @@ teamai push > ็ฎก็†ๅ‘˜ๅฏๅœจ `teamai.yaml` ไธญ่ฎพ็ฝฎๅผบๅˆถ่ง„ๅˆ™๏ผˆ`sharing.rules.enforced`๏ผ‰๏ผŒๆˆๅ‘˜ไธๅฏๅˆ ้™คใ€‚ -ๅคงๅคšๆ•ฐๅทฅๅ…ทๅœจ่‡ชๅทฑ็š„ 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 ๅช่ฟ่กŒๅทฒไฟกไปป็š„ hookใ€‚teamai ไผš่‡ชๅŠจไฟกไปปๅฎƒๅ†™ๅ…ฅ็š„ hooks๏ผ›ๅฆ‚ๆžœ่‡ชๅŠจไฟกไปป่ขซ็ฆ็”จๆˆ–ๅคฑ่ดฅ๏ผŒ่ฏทๅœจ `/hooks` ไธญๆ‰นๅ‡†ๅฎƒไปฌไปฅ่Žทๅพ—้กน็›ฎ็š„ ruleใ€‚ +ๅคงๅคšๆ•ฐๅทฅๅ…ทๅœจ่‡ชๅทฑ็š„ 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 ๆ ผๅผ็š„ๅทฅๅ…ทไนŸไผš่ฏปๅ–ๅฎƒใ€‚ZCodeใ€DeepSeek Harnessใ€OpenClawใ€Pi ๅ’Œ JoyCode ๅŒๆ ทๆฒกๆœ‰ rules ๆ ผๅผ๏ผŒๅœจ user scope ไธ‹ๆŠŠๅŒๆ ท็š„ๅŒบๅ—ๅ†™ๅ…ฅๅชๆœ‰่ฏฅๅทฅๅ…ท่ฏปๅ–็š„ๆ–‡ไปถ๏ผš`~/.zcode/AGENTS.md`ใ€`$DSH_HOME/AGENTS.md`๏ผˆๆœช่ฎพ็ฝฎ `DSH_HOME` ๆ—ถไธบ `~/.dsh/AGENTS.md`๏ผ‰ใ€OpenClaw ๅทฅไฝœๅŒบ็š„ `AGENTS.md`๏ผˆๆŒ‰ๅ…ถ hook ็š„ๆ–นๅผๆŸฅๆ‰พ๏ผ‰ใ€ไธŽๆŒ‡ไปคๅŒบๅ—ๅนถๅˆ—็š„ `~/.pi/agent/AGENTS.md`๏ผŒไปฅๅŠ `~/.joycode/rules.txt`ใ€‚pull ๅชไธบๅทฒๅฎ‰่ฃ…็š„ๅทฅๅ…ทๅ†™ๅ…ฅ๏ผŒ้กน็›ฎ pull ไธๅ†™่ฟ™ไบ›ๆ–‡ไปถใ€‚`openclaw.json` ไธๆ˜ฏ็บฏ JSON ๆ—ถ๏ผˆOpenClaw ่ฏปๅ– JSON5๏ผ‰๏ผŒteamai ๆ— ๆณ•ๅˆคๆ–ญ OpenClaw ไฝฟ็”จๅ“ชไธชๅทฅไฝœๅŒบ๏ผŒๅ› ๆญคไธๅ†™ๅ…ฅ่ฏฅๅŒบๅ—๏ผšpull ไผš็ป™ๅ‡บ่ญฆๅ‘Š๏ผŒ`doctor` ไผšๅคฑ่ดฅ๏ผŒ็›ดๅˆฐ่ฏฅๆ–‡ไปถๆ”นไธบ็บฏ JSONใ€‚ๅœจ้กน็›ฎไธญ๏ผŒZCode ๅ’Œ DeepSeek Harness ้€š่ฟ‡ไธŽ Codex ็›ธๅŒ็š„ session-start hook ๅพ—ๅˆฐ้กน็›ฎ็š„ๅ›ข้˜Ÿ rule๏ผŒPi ๅˆ™้€š่ฟ‡ teamai ็š„ Pi ๆ‰ฉๅฑ•ๅพ—ๅˆฐ๏ผŒๆ‰ฉๅฑ•ๆŠŠๅฎƒไปฌๅŠ ๅœจๅ›ข้˜ŸๆŒ‡ไปคไน‹ๅŽใ€ๅŠ ๅ…ฅๆฏๆฌก่ฟ่กŒ็š„็ณป็ปŸๆ็คบ๏ผ›ๅฎƒไปฌๅœจ้กน็›ฎไธญ้ƒฝไธไผšๅพ—ๅˆฐ user rule๏ผŒๅ› ๆญคๅœจไธคไธช scope ้ƒฝๅฎ‰่ฃ… teamai ๆ—ถ๏ผŒๆฏๆก rule ๅช้€่พพ่ฏฅๅทฅๅ…ทไธ€ๆฌกใ€‚ZCode ๅ’Œ DeepSeek Harness ๅœจๅŽ‹็ผฉไผš่ฏๆ—ถไผšไธขๅผƒ hook ็š„ๆ–‡ๆœฌ๏ผŒrule ่ฆๅˆฐไธ‹ไธ€ไธชไผš่ฏๆ‰ๅ›žๆฅ๏ผ›DeepSeek Harness ไปฅๅˆ†็ฆปๆ–นๅผ่ฟ่กŒ่ฏฅ hook๏ผŒ็ฌฌไธ€ๆฌก่ฏทๆฑ‚ๅฏ่ƒฝ้”™่ฟ‡ๅฎƒไปฌ๏ผ›ๅœจ้กน็›ฎไธญ `init` ๅ’Œ `doctor` ไผš่ฏดๆ˜Ž่ฟ™ไธ€็‚นใ€‚OpenClaw ไธไผšๅพ—ๅˆฐ้กน็›ฎ rule๏ผŒๅ› ไธบๅฎƒๅ”ฏไธ€็š„้กน็›ฎๆ–‡ไปถๆ˜ฏๅ…ถไป–ๅทฅๅ…ทไนŸไผš่ฏปๅ–็š„ `AGENTS.md`๏ผ›ๅœจ้กน็›ฎไธญ `init` ๅ’Œ `doctor` ไผš่ฏดๆ˜Ž่ฟ™ไธ€็‚นใ€‚ๆ—ง็‰ˆๆœฌๆŠŠ rule ๅคๅˆถๅˆฐ่ฟ™ไบ›ๅทฅๅ…ทไปŽไธ่ฏปๅ–็š„ `.openclaw/rules`ใ€`.pi/rules`ใ€`~/.pi/agent/rules` ๅ’Œ `~/.joycode/rules`๏ผšไธ‹ไธ€ๆฌก pull๏ผˆๅณไฝฟๅ›ข้˜Ÿ็‰ˆๆœฌๆœชๅ˜๏ผ‰ไผšๅˆ ้™คไปๆ˜ฏ teamai ๆ‰€ไธ‹ๅ‘ๅ†…ๅฎน็š„ๅ‰ฏๆœฌ๏ผŒๅนถ็‚นๅไฝ ๆ”น่ฟ‡็š„ๅ‰ฏๆœฌใ€‚Hermes ็š„ `SOUL.md` ๅŒบๅ—ๅพ—ๅˆฐๅŒๆ ท็š„ๅ†…ๅฎน๏ผŒไธ”ๅช็”ฑ user scope ็š„ pull ๅ†™ๅ…ฅ๏ผš`SOUL.md` ๆ˜ฏๅ…จๅฑ€ๆ–‡ไปถ๏ผŒ้กน็›ฎ pull ไผš่ฎฉๅฎƒไฟๆŒ user scope pull ๅ†™ๅ…ฅๆ—ถ็š„ๆ ทๅญใ€‚ๅณไฝฟๅ›ข้˜Ÿไป“ๅบ“ๆฒกๆœ‰ๅ˜ๅŒ–๏ผŒuser pull ไนŸไผš้‡ๅ†™่ฏฅๅŒบๅ—๏ผŒไปŽ่€Œไฟฎๅคๆ—ง็‰ˆๆœฌ้กน็›ฎ pull ่ฆ†็›–่ฟ‡็š„ๅŒบๅ—ใ€‚่‹ฅๆœฌๆœบๆฒกๆœ‰ user scope ้…็ฝฎ๏ผŒ้กน็›ฎ pull ไผš็งป้™คๆ—ง็‰ˆ้กน็›ฎ pull ็•™ไธ‹็š„ๅ›ข้˜Ÿ่ง„ๅˆ™ๅŒบๅ—๏ผŒๅนถไฟ็•™ SOUL.md ไธญ็š„ๅ…ถไป–ๅ†…ๅฎนใ€‚Hermes ไธไผšๅพ—ๅˆฐ้กน็›ฎ rule๏ผŒๅœจ้กน็›ฎไธญ `init` ๅ’Œ `doctor` ไผš่ฏดๆ˜ŽๅŽŸๅ› ๏ผš`.hermes.md` ไผš้ฎ่”ฝ้กน็›ฎ `AGENTS.md`๏ผŒ`pre_llm_call` hook ไผšๅœจๆฏไธ€่ฝฎ้‡ๅคๆทปๅŠ  rule๏ผŒ่€Œๅ”ฏไธ€็š„ๆ’ไปถๆ็คบๆฎต่ฝ๏ผˆๆœ€ๅคš 4,000 ไธชๅญ—็ฌฆ๏ผ‰ๅทฒ็”จไบŽๅ›ข้˜ŸๆŒ‡ไปคใ€‚frontmatter ไผš่ขซๅŽปๆމ๏ผŒๆ‰€ไปฅๅธฆ `paths:` ็š„ rule ๅœจ่ฟ™้‡Œๅฏนๆ‰€ๆœ‰ๆ–‡ไปถ็”Ÿๆ•ˆ๏ผŒๅนถไปฅไธ€่กŒ `Applies to files matching: ` ๅผ€ๅคดใ€‚Codex ๅœจๅŽ‹็ผฉไธŠไธ‹ๆ–‡ๆˆ– clear ไน‹ๅŽไผšๅ†ๆฌก่ฟ่กŒ่ฏฅ hook๏ผ›ๆขๅคไผš่ฏๆ—ถไธๆทปๅŠ ไปปไฝ•ๅ†…ๅฎน๏ผŒๅ› ไธบไผš่ฏไธญๅทฒๅŒ…ๅซ่ฟ™ไบ› ruleใ€‚Codex ๅฏๅŠจ็š„ๅญ agent ้€š่ฟ‡ `SubagentStart` hook ่Žทๅพ—ๅฎƒไปฌใ€‚ๅ…ฌๅผ€็‰ˆ Codex ๅช่ฟ่กŒๅทฒไฟกไปป็š„ hookใ€‚teamai ไผš่‡ชๅŠจไฟกไปปๅฎƒๅ†™ๅ…ฅ็š„ hooks๏ผ›ๅฆ‚ๆžœ่‡ชๅŠจไฟกไปป่ขซ็ฆ็”จๆˆ–ๅคฑ่ดฅ๏ผŒ่ฏทๅœจ `/hooks` ไธญๆ‰นๅ‡†ๅฎƒไปฌไปฅ่Žทๅพ—้กน็›ฎ็š„ ruleใ€‚ 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` ่ทฏๅพ„๏ผŒๅฎƒๅฎ‰่ฃ…ๅœจ้‚ฃ้‡Œใ€‚`codex` ๆก็›ฎ่ฟ˜้œ€่ฆ `mcpProject: .codex/config.toml`๏ผŒ้กน็›ฎ็š„ๅ›ข้˜Ÿ MCP server ๆ‰ไผšๅ†™ๅ…ฅใ€‚ +> ๅ›ข้˜Ÿ `teamai.yaml` ไธญ็š„ `toolPaths` ไผšๆ•ดไฝ“ๆ›ฟๆขๅ†…็ฝฎ้ป˜่ฎคๅ€ผใ€‚่ฎพ็ฝฎไบ†ๅฎƒ็š„ๅ›ข้˜Ÿๅบ”ไธบๆฏไธช Codex ็ณปๆก็›ฎๅŠ ไธŠ `userScope.claudemd: .codex/AGENTS.md`๏ผˆ`.codex-internal/โ€ฆ`ใ€`.tcodex/โ€ฆ`๏ผ‰๏ผŒ็”จไบŽ user scope ็š„ rule ๅ’ŒๅŒบๅ—๏ผŒๅนถๅŽปๆމๅ…ถ `rules` ่ทฏๅพ„๏ผŒๅ› ไธบ Codex ไปŽไธ่ฏปๅ–่ฏฅ็›ฎๅฝ•ใ€‚้กถๅฑ‚็š„ `claudemd` ไผšๆŠŠๅŒบๅ—้‡ๆ–ฐๅ†™่ฟ›้กน็›ฎ `AGENTS.md`๏ผŒๆ‰€ไปฅไธ่ฆ่ฎพ็ฝฎใ€‚ๅœจ้กน็›ฎไธญ๏ผŒhook ๅช้œ€่ฆ่ฏฅๆก็›ฎ็š„ `settings` ่ทฏๅพ„๏ผŒๅฎƒๅฎ‰่ฃ…ๅœจ้‚ฃ้‡Œใ€‚`codex` ๆก็›ฎ่ฟ˜้œ€่ฆ `mcpProject: .codex/config.toml`๏ผŒ้กน็›ฎ็š„ๅ›ข้˜Ÿ MCP server ๆ‰ไผšๅ†™ๅ…ฅใ€‚ๆœฌ็‰ˆๆœฌไธญ rules ไฝ็ฝฎๅ‘็”Ÿๅ˜ๅŒ–็š„ๅ…ถไป–ๅทฅๅ…ทๅŒ็†๏ผšไน‹ๅ‰ๅ†™็š„ๆก็›ฎไปไผšๆŠŠ rules ๅ‘ๅพ€ๅทฅๅ…ทไปŽไธ่ฏปๅ–็š„็›ฎๅฝ•๏ผŒpull ไนŸไผšไฟ็•™้‚ฃไบ›ๅ‰ฏๆœฌใ€‚่ฏทไปŽ Pi ๆˆ– OpenClaw ๆก็›ฎไธญๅŽปๆމ `rules` ๅ’Œ `userScope.rules`๏ผŒ็ป™ JoyCode ๆก็›ฎๅŠ ไธŠ `userScope.rules: null`๏ผˆๅ…ถ้กน็›ฎ็บง `rules: .joycode/rules` ไฟ็•™๏ผ‰๏ผŒๅนถๆŠŠ WorkBuddy ๆก็›ฎ็š„ `rules` ่ฎพไธบ `.codebuddy/rules`ใ€`userScope.rules` ่ฎพไธบ `.workbuddy/rules`ใ€‚ๅฏน่ฟ™ๆ ท็š„ๆก็›ฎ๏ผŒ`teamai doctor` ็š„ `Rules delivered to ` ไผšๅคฑ่ดฅๅนถๆŒ‡ๅ‡บ่ฆๆ”นไป€ไนˆใ€‚ > ไปŽๆŠŠ rule ๅคๅˆถๅˆฐ `.codex/rules/` ็š„ๆ—ง็‰ˆๆœฌๅ‡็บงๅŽ๏ผŒไธ‹ไธ€ๆฌก `pull` ไผšๅˆ ้™ค teamai ๆŠ•้€’ๅˆฐ้‚ฃ้‡Œ็š„ `.md` ๅ‰ฏๆœฌ๏ผŒๅŒ…ๆ‹ฌ `teamai-recall.md`ใ€‚ๆธ…็†ไฝฟ็”จ่ฎฐๅฝ•็š„ `toolRoots` ไฝ็ฝฎ๏ผŒๅŒๆ—ถๆฃ€ๆŸฅๅ‘ๅธƒ่€…ๆœฌๅœฐ็š„ๆ— ๅ‘ฝๅ็ฉบ้—ดๆ–‡ไปถๅๅŠๅ‘ฝๅ็ฉบ้—ดๅ‰ฏๆœฌใ€‚ไฝ ๆ”น่ฟ‡็š„ๅ‰ฏๆœฌไผšไฟ็•™๏ผŒๅนถๅœจ่ญฆๅ‘Šไธญ็‚นๅ๏ผ›`*.rules` ๆ–‡ไปถไปŽไธๆ”นๅŠจใ€‚ๅ›ข้˜ŸๆญคๅŽๅทฒๅˆ ้™ค็š„ rule๏ผŒๅ…ถๅ‰ฏๆœฌๅชๆœ‰ไธŽ่ฎฐๅฝ•็š„ๆŠ•้€’ๅ“ˆๅธŒไธ€่‡ดๆ—ถๆ‰ไผšๅˆ ้™ค๏ผ›ๆฒกๆœ‰่ฏฅ่ฎฐๅฝ•ๆ—ถไนŸไผšไฟ็•™ๅนถ็‚นๅใ€‚ๅŒไธ€ๆฌก pull ไผšไธบ `hooks.json` ไธญ็š„ teamai hook ๅŠ ไธŠ `additionalContextLimit: 0` ๅ’Œไธ€ไธช `SubagentStart` ๆก็›ฎ๏ผŒ้šๅŽ teamai ไผšๅœจๅ…ฌๅผ€็‰ˆ Codex ไธญ้‡ๆ–ฐไฟกไปป่ฟ™ไบ› hook๏ผˆ่ง Hooks ็ซ ่Š‚๏ผ‰ใ€‚ @@ -1582,6 +1584,7 @@ pull ๅชๆŠŠๅ›ข้˜Ÿๆ–‡ๅŒ–ใ€ๅ…ฑไบซๆŒ‡ไปคๅ’Œ recall ๅ—ๅ†™ๅ…ฅๅทฒๅฎ‰่ฃ… AI ๅทฅๅ…ท็š„ | 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 ๆ‰ฉๅฑ•ๅŠ ๅ…ฅๆฏๆฌก่ฟ่กŒ็š„็ณป็ปŸๆ็คบ | +| OpenClaw | ๅทฅไฝœๅŒบ็š„ `AGENTS.md`๏ผŒๆŒ‰ๅ…ถ hook ็š„ๆ–นๅผๆŸฅๆ‰พ๏ผˆ`agents.defaults.workspace`ใ€`OPENCLAW_WORKSPACE_DIR` ๆˆ– `/workspace`๏ผ‰๏ผŒไธŽๅ›ข้˜Ÿ rule ๅนถๅˆ—๏ผˆๆœช้ชŒ่ฏ๏ผ‰ | ๆ— ๏ผšๅฎƒๅ”ฏไธ€็š„้กน็›ฎๆ–‡ไปถๆ˜ฏๅ…ฑไบซ็š„ `AGENTS.md` | | Hermes | `$HERMES_HOME/SOUL.md` ไธญ็š„ไธ€ไธชๅ—๏ผŒไฝไบŽๅ›ข้˜Ÿ่ง„ๅˆ™ๅ—ๆ—๏ผˆๆœช้ชŒ่ฏ๏ผ‰ | teamai ็š„ Hermes ๆ’ไปถๆไพ›็š„็ณป็ปŸๆ็คบๆฎต่ฝ๏ผˆๆœช้ชŒ่ฏ๏ผ‰ | ๅ›ข้˜Ÿ `toolPaths` ไธญๆฒกๆœ‰ `rules` ็š„ๆก็›ฎ๏ผŒClaude Codeใ€Cursorใ€CodeBuddy ๅ’Œ WorkBuddy ็ปง็ปญไฝฟ็”จๅ…ถ้…็ฝฎ็š„ `claudemd`๏ผŒๅ› ไธบๅฎƒไปฌๆฒกๆœ‰ๅฏๆ”พ็ฝฎ `teamai-context` ๆ–‡ไปถ็š„ rules ็›ฎๅฝ•ใ€‚ๅชๆœ‰ `claudemd` ็š„ๆก็›ฎๅœจ่ฏฅๆ–‡ไปถๆ‰€ๅœจ็›ฎๅฝ•ๅญ˜ๅœจๆ—ถ่ง†ไธบๅทฒๅฎ‰่ฃ…๏ผ›ๅฏน `AGENTS.md` ่ฟ™็ฑปไธๅœจ็›ฎๅฝ•ไธญ็š„ๆ–‡ไปถๅˆ™ๅง‹็ปˆ่ง†ไธบๅทฒๅฎ‰่ฃ…ใ€‚ @@ -1614,6 +1617,7 @@ pull ไผšๅˆ—ๅ‡บๆ‰€ไฟฎๆ”น็š„ๆฏไธชๆ–‡ไปถ๏ผš - Hermes๏ผš`~/AGENTS.md` - Oh My Pi๏ผš`~/.omp/agent/AGENTS.md` ๅ’Œ `.omp/AGENTS.md`ใ€‚Oh My Pi ๆฏไธ€ๅฑ‚ๅช่ฏปๅ–ไธ€ไธชไธŠไธ‹ๆ–‡ๆ–‡ไปถ๏ผŒๅ› ๆญคๅฎƒไปฌไผš้ฎ่”ฝ `~/.agents/AGENTS.md` ๅ’Œ้กน็›ฎ็š„ `AGENTS.md`ใ€‚ - Pi๏ผš้กน็›ฎ `AGENTS.md` +- OpenClaw๏ผŒ้กน็›ฎ scope๏ผšOpenClaw ไปŽไธ่ฏปๅ–็š„ `.openclaw/workspace/AGENTS.md` - Codex ็ณปๅˆ—๏ผš้กน็›ฎ `AGENTS.md`๏ผˆๅฝ“ๅ›ข้˜Ÿ็š„ `toolPaths` ๆˆ–ๆ—ฉๆœŸๆž„ๅปบๆŠŠ Codex ๆŒ‡ๅ‘้‚ฃ้‡Œๆ—ถ๏ผ‰ - ็›ฎๆ ‡ๆ–‡ไปถๅทฒๆ”นๅ˜็š„ไปปไธ€ๅทฅๅ…ท๏ผšๅ›ข้˜Ÿ `toolPaths` ไธบๅฎƒ่ฎพ็ฝฎ็š„ `claudemd` ่ทฏๅพ„๏ผŒ้™ค้ž็Žฐๅœจๅฆไธ€ไธชๅทฅๅ…ท็š„ๅ—ๅ†™ๅœจ้‚ฃ้‡Œ @@ -1894,6 +1898,8 @@ Git hook ๅฎ‰่ฃ…ๅคฑ่ดฅๆ—ถ๏ผŒ`hooks inject`ใ€`init` ๅ’Œๅ•ไป“ๅบ“่‡ชๅŠจๅˆๅง‹ๅŒ– ้ž-self ็š„ project scope ไธญ๏ผŒ`hooks remove` ไผš็งป้™ค HOME ไธญๅฝ“ๅ‰ checkout ็š„้—จๆŽงๅ›ข้˜Ÿ hooks๏ผŒไปฅๅŠไธป checkout ไธญ Claude/Codex ็š„ๅ›ข้˜Ÿ hooksใ€‚ๅ…ถไป–้กน็›ฎ็š„้—จๆŽงๅ›ข้˜Ÿ hooks ไฟ็•™ๅœจ HOME๏ผ›ๅ…ฑไบซ็š„ๅ†…็ฝฎ hooks ไผš่ขซ็งป้™คใ€‚ +> **OpenClaw** โ€” teamai ็š„ hook ๆ˜ฏไธ€ไธช workspace hook๏ผŒไฝไบŽ `/hooks/teamai-status-report`ใ€‚ๅฎƒๅœจ `command:new`ใ€`command:reset`ใ€`session:auto-reset` ๅ’Œ `gateway:startup` ๆ—ถ่ฟ่กŒ `session-start`๏ผŒๅœจ `message:received` ๆ—ถ่ฟ่กŒ `prompt-submit`๏ผŒๅนถไปฅไบ‹ไปถไธญ็š„ workspace ไฝœไธบ hook ็š„ `cwd`ใ€‚OpenClaw ๅชๆœ‰ๅœจ `openclaw.json` ๅฏ็”จไบ†ๆŸไธช workspace hook ็š„ๆก็›ฎๆ—ถๆ‰ไผšๅŠ ่ฝฝๅฎƒ๏ผŒๅ› ๆญค initใ€pull ๅ’Œ `hooks inject` ไผšๅ†™ๅ…ฅ `hooks.internal.entries.teamai-status-report.enabled: true`๏ผŒ`hooks remove` ๅ’Œ uninstall ไผšๅฐ†ๅ…ถ็งป้™คใ€‚่‹ฅ OpenClaw ๆญฃๅœจๅŠ ่ฝฝๅฎƒๅ‘็Žฐ็š„ๆ‰€ๆœ‰ hook๏ผˆ`hooks.internal.enabled: true` ไธ”ๆฒกๆœ‰ๅ…ทๅๆก็›ฎ๏ผ‰๏ผŒๆ–ฐๅขž็ฌฌไธ€ไธชๆก็›ฎไผšๆŠŠๅ‘็Žฐๆจกๅผๅ˜ๆˆ็™ฝๅๅ•๏ผŒไปŽ่€Œๅœๆމไฝ ็š„ๅ…ถไป– hook๏ผŒๆ‰€ไปฅ teamai ไธๆ”นๅŠจ้…็ฝฎ๏ผŒๅช็ป™ๅ‡บ่ญฆๅ‘Š๏ผ›ๅฝ“ไฝ ๅ…ณ้—ญไบ†่ฏฅ hook ๆˆ–ๆ•ดไธช internal hooks๏ผŒๆˆ– `openclaw.json` ไธๆ˜ฏ็บฏ JSON ๆ—ถไนŸๅŒๆ ทๅค„็†ใ€‚ๆญคๆ—ถ่ฏท่‡ช่กŒ่ฟ่กŒ `openclaw hooks enable teamai-status-report`ใ€‚ๆ—ง็‰ˆ teamai ๅœจ่ฎพ็ฝฎไบ† `OPENCLAW_STATE_DIR` ๆ—ถไผš่‡ช่กŒๅ†™ๅ…ฅ `hooks.internal.enabled: true`๏ผŒๅ› ๆญค่ฟ™็ฑปๆœบๅ™จๅœจไฝ ่ฟ่กŒ่ฏฅๅ‘ฝไปคๅ‰้ƒฝไผš็œ‹ๅˆฐ่ฟ™ๆก่ญฆๅ‘Šใ€‚ๆœๅŠก็ซฏไธ‹ๅ‘็š„ agent hook ไฝไบŽ `/hooks/`๏ผŒๅนถๆœ‰่‡ชๅทฑ็š„ๆก็›ฎ `teamai-agent-`ใ€‚workspace ไธŽ้…็ฝฎ็š„ๆŸฅๆ‰พๆ–นๅผไธŽ OpenClaw ไธ€่‡ด๏ผš`OPENCLAW_CONFIG_PATH`ใ€`OPENCLAW_STATE_DIR` ๆˆ– `OPENCLAW_PROFILE`๏ผˆ`~/.openclaw-`๏ผ‰๏ผŒ็„ถๅŽๆ˜ฏ `agents.defaults.workspace`ใ€`OPENCLAW_WORKSPACE_DIR` ๆˆ– `/workspace`ใ€‚ๆก็›ฎ็ผบๅคฑๆˆ–่ขซๅ…ณ้—ญๆ—ถ๏ผŒ`doctor` ็š„ `OpenClaw hook enabled` ๆฃ€ๆŸฅไผšๅคฑ่ดฅใ€‚ + ๅœจ Windows ไธŠ๏ผŒ็ป็”ฑ bash ๆ‰ง่กŒ็š„ๅ†…็ฝฎ hook ๆดพๅ‘ๅ‘ฝไปค๏ผˆๅฆ‚ Claudeใ€Codexใ€Cursorใ€Copilot CLI๏ผ‰ไผšไปฅ็ปๅฏน่ทฏๅพ„ๅผ•็”จ Git Bashโ€”โ€”ๅ…ˆๆŸฅๆ ‡ๅ‡†ๅฎ‰่ฃ…ไฝ็ฝฎ๏ผŒๅ†ๅ›ž้€€ๅˆฐ `HKLM\SOFTWARE\GitForWindows` ๆณจๅ†Œ่กจโ€”โ€”ไปŽ่€Œ้ฟๅ…่งฃๆžๅˆฐ WSL ็š„ `bash.exe`๏ผ›่‹ฅๆ‰พไธๅˆฐ Git Bash๏ผŒๅˆ™้€€ๅ›ž่ฃธ `bash`ใ€‚ Cursor ไนŸไผšๅŠ ่ฝฝ `~/.claude/settings.json`ใ€‚Copilot CLI ไผšๅŠ ่ฝฝๅ—ไฟกไปป้กน็›ฎ้‡Œ็š„ `.claude/settings.json`๏ผˆself mode ๆŠŠ hook ๅ†™ๅœจ้กน็›ฎ้‡Œ๏ผ›Copilot ไธๅŠ ่ฝฝ `~/.claude/settings.json`๏ผ‰ใ€‚ๅชๆœ‰ๅฆไธ€่พน็š„ teamai hook ๅทฒ็ปๅœจ็ฃ็›˜ไธŠๆ—ถ๏ผŒ`hook-dispatch --tool claude` ๆ‰ไผš้€€ๅ‡บ๏ผš`~/.cursor/hooks.json` ๆˆ– `$CURSOR_PROJECT_DIR/.cursor/hooks.json` ๅซๆœ‰ `--tool cursor`๏ผŒๆˆ– `$COPILOT_PROJECT_DIR/.github/hooks/teamai.json` ๅซๆœ‰ `--tool copilot`ใ€‚ๅ†™็ป™ `claude` ็š„ๅ›ข้˜Ÿ hook ๅ‘ฝไปค็”จๅŒไธ€ๅˆคๆ–ญใ€‚ๅชๅฏ็”จไบ† Claude ๆ—ถ๏ผŒCursor ้‡Œ่ฟ™ไปฝ hook ็…งๅธธ่ฟ่กŒ๏ผŒๅ› ไธบๆฒกๆœ‰็ฌฌไบŒไปฝๅฏไปฅๆŽฅๆ›ฟใ€‚`COPILOT_CLI` ไธ่ƒฝๅฝ“ไฟกๅท๏ผšCopilot ไผš็ป™ๆฏไธชๅญ่ฟ›็จ‹่ฎพ็ฝฎๅฎƒ๏ผŒๅŒ…ๆ‹ฌไปŽๅฎƒ็š„ shell ้‡ŒๅฏๅŠจ็š„ Claudeใ€‚Claude Code ไธไผš่ฎพ็ฝฎ `CURSOR_VERSION` ๆˆ– `COPILOT_PROJECT_DIR`ใ€‚ๅทฒ็ป่ฃ…ๅฅฝ็š„ๅ›ข้˜Ÿ hook ้œ€่ฆๅ†่ท‘ไธ€ๆฌก `teamai pull` ๆˆ– `teamai hooks inject`๏ผŒๆ‰ไผšๅธฆไธŠ่ฟ™ไธชๅˆคๆ–ญใ€‚ @@ -2101,7 +2107,7 @@ GitHub Copilot CLI ๅทฒๆ”ฏๆŒๅ…ถๅฎ˜ๆ–น่‡ชๅฎšไน‰ๆŒ‡ไปคใ€Rulesใ€Skillsใ€่‡ชๅฎš - **ไฝœ็”จๅŸŸใ€‚** ็”จๆˆท่ต„ๆบไฝไบŽ `$COPILOT_HOME`๏ผˆ้ป˜่ฎค `~/.copilot`๏ผ‰ไธ‹๏ผŒ้กน็›ฎ่ต„ๆบไฝไบŽ `/.github` ไธ‹ใ€‚TeamAI ๅœจๆฃ€ๆต‹ไปฅๅŠๆ‰€ๆœ‰็”จๆˆท็บง่ฏปๅ†™ไธญ้ƒฝไผš้ตๅพช `COPILOT_HOME`ใ€‚ - **Skillsใ€‚** `teamai pull` ๅฐ†็”จๆˆท็บง Skills ๅ†™ๅ…ฅ `$COPILOT_HOME/skills/`๏ผŒๅฐ†้กน็›ฎ็บง Skills ๅ†™ๅ…ฅ `.github/skills/`๏ผ›ไปปไธ€ไฝœ็”จๅŸŸไธญ็š„ไฟฎๆ”น้ƒฝๅฏๅƒๅ…ถไป– TeamAI Skills ไธ€ๆ ท่ขซ `teamai push` ๆฃ€ๆต‹ใ€‚ - **่‡ชๅฎšไน‰ๆŒ‡ไปคใ€‚** TeamAI ๅฐ†ๅ›ข้˜Ÿๆ–‡ๅŒ–ๅ’Œๅ…ฑไบซๆŒ‡ไปคๆณจๅ…ฅ็”จๆˆท็บง `$COPILOT_HOME/copilot-instructions.md` ๆˆ–้กน็›ฎ็บง `.github/copilot-instructions.md`ใ€‚TeamAI ๆ ‡่ฎฐๅŒ…ๅ›ด็š„ๅŒบๅ—ไผš่ขซๅน‚็ญ‰ๆ›ฟๆข๏ผŒๆ ‡่ฎฐไน‹ๅค–็š„ๆ–‡ๅญ—ๅฝ’็”จๆˆทๆ‰€ๆœ‰ใ€‚`teamai uninstall` ๅช็งป้™ค TeamAI ็ฎก็†็š„ๅŒบๅ—ใ€‚ -- **Rulesใ€‚** ๅ›ข้˜Ÿ Rules ไผš่ฝฌๆขไธบ `$COPILOT_HOME/instructions/` ๆˆ– `.github/instructions/` ไธ‹็š„ๅŽŸ็”Ÿ `*.instructions.md` ๆ–‡ไปถใ€‚TeamAI ไปŽๅ›ข้˜Ÿ Rule ็š„ `paths` ๆดพ็”Ÿ Copilot ๅฟ…้œ€็š„ `applyTo` frontmatter๏ผ›ๆฒกๆœ‰ `paths` ๆ—ถไฝฟ็”จ `**`ใ€‚Push ๆ—ถๅชๆœ‰ Markdown ๆญฃๆ–‡ๅ›žๆต๏ผŒๅ›ข้˜Ÿๆ‹ฅๆœ‰็š„ `paths` ๅ…ƒๆ•ฐๆฎไฟๆŒไธๅ˜ใ€‚ๆœช็Ÿฅ็š„ Copilot instructions ๆ–‡ไปถๅฑžไบŽ็”จๆˆท๏ผŒไธไผš่ขซไธŠไผ ๆˆ–ๅˆ ้™คใ€‚ +- **Rulesใ€‚** ๅ›ข้˜Ÿ Rules ไผš่ฝฌๆขไธบ `$COPILOT_HOME/instructions/` ๆˆ– `.github/instructions/` ไธ‹็š„ๅŽŸ็”Ÿ `*.instructions.md` ๆ–‡ไปถใ€‚TeamAI ไปŽๅ›ข้˜Ÿ Rule ็š„ `paths` ๆดพ็”Ÿ Copilot ๅฟ…้œ€็š„ `applyTo` frontmatter๏ผ›ๆฒกๆœ‰ `paths` ๆ—ถไฝฟ็”จ `**`ใ€‚Push ๆ—ถๅชๆœ‰ Markdown ๆญฃๆ–‡ๅ›žๆต๏ผŒๅ›ข้˜Ÿๆ‹ฅๆœ‰็š„ `paths` ๅ…ƒๆ•ฐๆฎไฟๆŒไธๅ˜ใ€‚ๆœช็Ÿฅ็š„ Copilot instructions ๆ–‡ไปถๅฑžไบŽ็”จๆˆท๏ผŒไธไผš่ขซไธŠไผ ๆˆ–ๅˆ ้™คใ€‚Copilot CLI 1.0.89 ๅŠๆ›ด้ซ˜็‰ˆๆœฌไนŸไผš่ฏปๅ–้กน็›ฎ็š„ `.claude/rules`๏ผŒๅ› ๆญคๅฏ็”จ Claude ๆ—ถ๏ผŒๆฏๆก้กน็›ฎ rule ไผš้€่พพ Copilot ไธคๆฌก๏ผ›teamai ไปไผšๅ†™ๅ…ฅไธคไปฝๅ‰ฏๆœฌ๏ผŒๅฏนๆฏไธชไนŸ่ฏปๅ–ๅ…ถไป–ๅทฅๅ…ทๆ–‡ไปถ็š„ๅทฅๅ…ท้ƒฝๆ˜ฏๅฆ‚ๆญคใ€‚ - **่‡ชๅฎšไน‰ Agentsใ€‚** ๅ›ข้˜Ÿ Agents ไผš่ฝฌๆขไธบ `$COPILOT_HOME/agents/` ๆˆ– `.github/agents/` ไธ‹็š„ๅฎ˜ๆ–น `.agent.md` ้…็ฝฎใ€‚TeamAI ๅฐ†ๅ…ผๅฎน็š„ๅทฅๅ…ทๅๆ˜ ๅฐ„ไธบ Copilot ไธปๅˆซๅ๏ผŒ้€š่ฟ‡ `tool_extras.copilot` ไฟ็•™ Copilot ไธ“ๅฑž frontmatter๏ผŒๅนถไธ”ๅชๅˆ ้™คไธŽๅ›ข้˜Ÿ Agent ๆˆ–ๅ†…็ฝฎ recall ้…็ฝฎๅŒน้…็š„ๆ–‡ไปถ๏ผ›็”จๆˆท่‡ชๅปบ้…็ฝฎไฟๆŒไธๅ˜ใ€‚่ฏฆ่ง [GitHub ่‡ชๅฎšไน‰ Agent ้…็ฝฎ](https://docs.github.com/zh/copilot/reference/custom-agents-configuration)ใ€‚ - **Team Context recallใ€‚** ๅ†…็ฝฎ `teamai-recall.agent.md` ๅช่Žทๅพ— `execute`ใ€`read` ๅ’Œ `search`ใ€‚ๅฎƒ่ฐƒ็”จ็Žฐๆœ‰็š„ `teamai recall` ๆต็จ‹๏ผŒ่ฎฉ Copilot ๆฃ€็ดข learningsใ€codebase ่ฏๆฎๅ’Œ teamwiki ็ป“ๆžœ๏ผŒ่€Œไธไผšๅคๅˆถๆˆ–ๅˆ›ๅปบ็ฌฌไบŒๅฅ—็Ÿฅ่ฏ†ๅบ“ใ€‚ - **Docs ๅ’Œ Envใ€‚** ๅ›ข้˜Ÿ Docs ๅŒๆญฅๅˆฐ้…็ฝฎ็š„ๆœฌๅœฐๆ–‡ๆกฃ็›ฎๅฝ•๏ผˆ้ป˜่ฎค `~/.teamai/docs`๏ผ›project scope ไฝฟ็”จ้กน็›ฎๅ†…ๅฏนๅบ”่ทฏๅพ„๏ผ‰ใ€‚ๅ›ข้˜Ÿ็Žฏๅขƒๅ˜้‡ๅŒๆญฅๅˆฐ่ฏฅไฝœ็”จๅŸŸ็”ฑ TeamAI ็ฎก็†็š„ `env.sh`๏ผ›่ฏทไปŽๅทฒ source ๆญคๆ–‡ไปถ็š„ shell ๅฏๅŠจ Copilotใ€‚TeamAI ไธไผšๆŠŠ็Žฏๅขƒๅ˜้‡ๅ€ผๅคๅˆถๅˆฐ Copilot ้…็ฝฎไธญใ€‚ @@ -2117,7 +2123,7 @@ GitHub Copilot CLI ๅทฒๆ”ฏๆŒๅ…ถๅฎ˜ๆ–น่‡ชๅฎšไน‰ๆŒ‡ไปคใ€Rulesใ€Skillsใ€่‡ชๅฎš - **ไฝœ็”จๅŸŸใ€‚** OpenCode ็š„็”จๆˆท้…็ฝฎๅœจ `~/.config/opencode/` ไธ‹๏ผŒ้กน็›ฎ้…็ฝฎๅœจ `/.opencode/` ไธ‹โ€”โ€”ๅ‰็ผ€ไธŽๅ…ถไป–ๆ‰€ๆœ‰ๅทฅๅ…ท้ƒฝไธๅŒใ€‚teamai ไผšๆŒ‰ `--scope` ๅ†™ๅ…ฅๆญฃ็กฎ็š„ไฝ็ฝฎ๏ผŒไธ”ไป…ๅœจ่ฏฅไฝœ็”จๅŸŸ็กฎๅฎžๅฎ‰่ฃ…ไบ† OpenCode ๆ—ถๆ‰็ขฐๅฎƒ็š„ๆ–‡ไปถ๏ผˆ็ปไธไผšไธบๆœชไฝฟ็”จ OpenCode ็š„็”จๆˆทๅˆ›ๅปบ `~/.config/opencode/`๏ผ‰ใ€‚Hooks ๆ˜ฏๅ”ฏไธ€็š„ไพ‹ๅค–โ€”โ€”ๅง‹็ปˆๅ†™ๅœจ็”จๆˆท็บง๏ผŒๅŽŸๅ› ่งไธ‹ใ€‚ - **Skills** ่ฝๅœจ `.opencode/skills/`๏ผˆ้กน็›ฎ๏ผ‰ๆˆ– `~/.config/opencode/skills/`๏ผˆ็”จๆˆท๏ผ‰ใ€‚OpenCode ไนŸๅŽŸ็”Ÿ่ฏปๅ– `.claude/skills`๏ผŒไฝ† teamai ไปไผšๅ†™ OpenCode ่ทฏๅพ„๏ผŒๅฅฝ่ฎฉๅช็”จ OpenCode ็š„็”จๆˆทไนŸ่ƒฝๆ‹ฟๅˆฐใ€‚ - **Subagents** ไผš่ขซๆธฒๆŸ“ๆˆ OpenCode ่‡ชๅทฑ็š„ `agents/*.md` ๆ ผๅผ๏ผšfrontmatter ๅธฆ `description` + `mode: subagent`๏ผˆไปฅๅŠ `model` ๅ’Œ `tool_extras.opencode` ไธญ็š„ๅญ—ๆฎต๏ผŒๅฆ‚ `temperature`๏ผ‰๏ผ›agent ๅๅ–่‡ชๆ–‡ไปถๅใ€‚OpenCode **ไธ**่ฏปๅ– `.claude/agents`๏ผŒๅ› ๆญค่ฟ™ไปฝๅŽŸ็”Ÿๅ‰ฏๆœฌๆ˜ฏๅฟ…้œ€็š„ใ€‚ -- **Rules** ไผš่ขซๅคๅˆถๅˆฐ `.opencode/rules/`๏ผˆๆˆ– `~/.config/opencode/rules/`๏ผ‰๏ผŒไฝ† OpenCode ไธไผš่‡ชๅŠจๆ‰ซๆ rules ็›ฎๅฝ•โ€”โ€”ๆ–‡ไปถๅœจ่ขซๅผ•็”จๅ‰ๆ˜ฏๆƒฐๆ€ง็š„ใ€‚ๅ› ๆญค teamai ไผšๅพ€ `opencode.json` ็š„ `instructions` ๆ•ฐ็ป„้‡ŒๅŠ ไธ€ๆก `rules/*.md` glob๏ผŒๅนถๅœจๅ›ข้˜Ÿๆœ€ๅŽไธ€ๆก rule ๆถˆๅคฑๆ—ถๅ†ๆŠŠๅฎƒ็งป้™ค๏ผŒไธ”ๅช็ผ–่พ‘่ฟ™ไธ€ไธช้”ฎใ€ไธๅŠจไฝ ่‡ชๅทฑ็š„ `instructions` ๆก็›ฎใ€‚ +- **Rules** ไผš่ขซๅคๅˆถๅˆฐ `.opencode/rules/`๏ผˆๆˆ– `~/.config/opencode/rules/`๏ผ‰๏ผŒไฝ† OpenCode ไธไผš่‡ชๅŠจๆ‰ซๆ rules ็›ฎๅฝ•โ€”โ€”ๆ–‡ไปถๅœจ่ขซๅผ•็”จๅ‰ๆ˜ฏๆƒฐๆ€ง็š„ใ€‚ๅ› ๆญค teamai ไผšๅพ€ `opencode.json` ็š„ `instructions` ๆ•ฐ็ป„้‡ŒๅŠ ๅ…ฅ glob๏ผŒๅนถๅœจๅ›ข้˜Ÿๆœ€ๅŽไธ€ๆก rule ๆถˆๅคฑๆ—ถๅ†ๆŠŠๅฎƒไปฌ็งป้™ค๏ผŒไธ”ๅช็ผ–่พ‘่ฟ™ไธ€ไธช้”ฎใ€ไธๅŠจไฝ ่‡ชๅทฑ็š„ `instructions` ๆก็›ฎใ€‚ๅœจ้กน็›ฎไธญๆ˜ฏ `.opencode/opencode.json` ้‡Œ็š„ `.opencode/rules/**/*.md`๏ผŒไธŽๅ›ข้˜Ÿ instructions ๆก็›ฎๅนถๅˆ—๏ผ›OpenCode ไผšไปŽไผš่ฏ็š„ๅทฅไฝœ็›ฎๅฝ•ๅŠๅ…ถ็›ดๅˆฐ worktree ็š„ๆฏไธ€็บง็ˆถ็›ฎๅฝ•ๅฏน็›ธๅฏนๆก็›ฎๅš glob๏ผŒๅ› ๆญคๅœจ้กน็›ฎไปปๆ„ไฝ็ฝฎ้ƒฝ่ƒฝๅŠ ่ฝฝ namespace ไธ‹็š„ ruleใ€‚pull ไผš็งป้™คๆ—ง็‰ˆๆœฌๅ†™ๅ…ฅๆ น็›ฎๅฝ• `opencode.json` ็š„ `.opencode/rules/*.md`๏ผˆๅฎƒๅŠ ่ฝฝไธๅˆฐไปปไฝ• namespace ไธ‹็š„ rule๏ผ‰๏ผŒไธ”ไธๅŠจ่ฏฅๆ–‡ไปถ็š„ๅ…ถไป–้”ฎใ€‚user scope ไธ‹ๆ˜ฏ็ปๅฏน่ทฏๅพ„ `~/.config/opencode/rules/*.md`๏ผŒๅค–ๅŠ  rule ๆ‰€่ฝๅ…ฅ็š„ๆฏไธช namespace ็›ฎๅฝ•ๅ„ไธ€ๆก๏ผˆ`~/.config/opencode/rules//*.md`๏ผ‰๏ผšOpenCode ไปŽไผš่ฏ็š„ๅทฅไฝœ็›ฎๅฝ•่งฃๆž็›ธๅฏนๆก็›ฎ๏ผŒๅฏน็ปๅฏนๆก็›ฎๅชๅฏนๆ–‡ไปถๅๅš glob๏ผŒๅ› ๆญค `**` ๆฐธ่ฟœไธไผšๅŒน้…ใ€‚pull ไผšๆ›ฟๆขๆ—ง็‰ˆๆœฌๅ†™ๅ…ฅ็š„็›ธๅฏน `rules/*.md`๏ผˆๅฎƒๅŠ ่ฝฝ็š„ๆ˜ฏ้กน็›ฎ็š„ `rules/`๏ผ‰๏ผŒๅนถๅœจๆŸไธชๅ›ข้˜Ÿ namespace ็š„ rule ไธๅ†้€่พพไฝ ๆ—ถ็งป้™คๅฎƒ็š„ glob๏ผ›ไฝ ไธบ่‡ชๅทฑ็š„็›ฎๅฝ•ๆทปๅŠ ็š„ glob ไผšไฟ็•™ใ€‚OpenCode ไผšๅฟฝ็•ฅ `paths:`๏ผšๅฎƒๅŠ ่ฝฝ็š„ๆฏๆก rule ้ƒฝๅฏนๆ‰€ๆœ‰ๆ–‡ไปถ็”Ÿๆ•ˆใ€‚`uninstall` ไผš็งป้™ค่ฟ™ไบ› glob๏ผŒๅนถๅˆ ้™ค้™คๆญคไน‹ๅค–ๅทฒๆ— ๅ…ถไป–ๅ†…ๅฎน็š„ `.opencode/opencode.json`ใ€‚ - **Hooks** ไปฅ OpenCode *plugin* ๅฝขๅผไบคไป˜๏ผŒ่€Œ้ž้…็ฝฎๆ–‡ไปถๆก็›ฎโ€”โ€”OpenCode ๆฒกๆœ‰ `hooks` ๆ•ฐ็ป„๏ผŒๅฎƒไผš**ๅŒๆ—ถ**ๅŠ ่ฝฝ `~/.config/opencode/plugin/` ๅ’Œ `/.opencode/plugin/` ไธ‹็š„ JS/TS ๆ’ไปถใ€‚ไธคไธช็›ฎๅฝ•้ƒฝๆœ‰ๆ’ไปถๆ—ถไผš่ขซๅŠ ่ฝฝไธคๆฌก๏ผŒๆฏไธชไบ‹ไปถไนŸๅฐฑๆดพๅ‘ไธคๆฌก๏ผŒๅ› ๆญค teamai ๅชไฟ็•™ไธ€ไปฝ๏ผšๅ†™ๅœจ็”จๆˆท็›ฎๅฝ•็š„ `teamai-hooks.ts`๏ผŒ่ฆ†็›–ๆ‰€ๆœ‰้กน็›ฎ๏ผ›ๆ—ฉๆœŸๅธƒๅฑ€ๆฎ‹็•™็š„้กน็›ฎ็บงๅ‰ฏๆœฌไผšๅœจไธ‹ๆฌกๅŒๆญฅๆ—ถ่ขซๅˆ ้™คใ€‚่ฟ™ไธŽๅ…ถไป–ๅทฅๅ…ทไธ€่‡ดโ€”โ€”ๅฎƒไปฌ็š„ `settings.json` hooks ๅŒๆ ทๆ”พๅœจ HOME๏ผŒ้ ไผ ็ป™ `hook-dispatch` ็š„ `cwd` ๅšไฝœ็”จๅŸŸๅˆคๆ–ญใ€‚ๆ’ไปถ่ฎข้˜… OpenCode ่‡ชๅทฑ็š„ไบ‹ไปถ๏ผŒๅนถ shell ๅˆฐๅ…ถไป–ๆ‰€ๆœ‰ๅทฅๅ…ทๅ…ฑ็”จ็š„ `teamai hook-dispatch` ๅ…ฅๅฃใ€‚ไบ‹ไปถๆ˜ ๅฐ„ๅฏน้ฝ Claude ๅ†…็ฝฎ้›†ๅˆ๏ผš`session.created` โ†’ session-startใ€`session.idle` โ†’ stopใ€`chat.message` โ†’ prompt-submitใ€`tool.execute.after` โ†’ post-tool-useใ€‚ๆ’ไปถไผš่ฝฌๅ‘ไธŽๅ…ถไป–ๅทฅๅ…ทไธ€่‡ด็š„ STDIN ่ดŸ่ฝฝ๏ผˆ`cwd`ใ€`session_id`ใ€`tool_name`ใ€`tool_input`ใ€`prompt`๏ผŒpost-tool-use ๆ—ถ่ฟ˜ๆœ‰ๅทฅๅ…ท่พ“ๅ‡บๅ’Œ็Šถๆ€๏ผ‰๏ผŒๅนถๆŠŠ OpenCode ็š„ๅฐๅ†™ๅทฅๅ…ท id๏ผˆ`skill`ใ€`todowrite`๏ผ‰ๆ˜ ๅฐ„ๅ›ž handler ๆณจๅ†Œ่กจๆœŸๆœ›็š„ PascalCase matcherใ€‚OpenCode ๆ— ๆณ•ๆŠŠ hook ็š„ stdout ๅ›žๆณจๅˆฐไผš่ฏ๏ผŒๅ› ๆญค hooks ๅชไธบๅ‰ฏไฝœ็”จ่ฟ่กŒ๏ผˆ็Šถๆ€ไธŠๆŠฅ / ๅŒๆญฅ / ๆ›ดๆ–ฐ๏ผ‰ใ€‚ๆณจๆ„ OpenCode ไผš **await** ๅฎƒ็š„ๅ…ทๅ hook๏ผˆ`chat.message`ใ€`tool.execute.after`๏ผ‰๏ผŒๆ‰€ไปฅ่ฟ™ไธคไธชไบ‹ไปถ็š„ๆดพๅ‘ไผš็Ÿญๆš‚็ญ‰ๅพ… `teamai` ๅญ่ฟ›็จ‹ๅŽ agent ๆ‰็ปง็ปญ๏ผ›้”™่ฏฏๅง‹็ปˆ่ขซๅžๆމ๏ผŒhook ๆฐธ่ฟœไธไผš่ฎฉไผš่ฏๅคฑ่ดฅใ€‚ๆœๅŠก็ซฏไธ‹ๅ‘็š„ agent hook๏ผˆ`teamai-agent-.ts`๏ผ‰ๅŒๆ ท่ฃ…ๅœจ่ฟ™ไธช็”จๆˆท็บง plugin ็›ฎๅฝ•ไธ‹ใ€‚upvote **้‡‡็บณ๏ผˆadoption๏ผ‰**ๅœจ OpenCode ไธŠๅŸบไบŽ recall ๆ—ฅๅฟ—่ฟ่กŒ๏ผŒไธไพ่ต– transcript๏ผšๆ’ไปถ็š„ `shell.env` hook ไผšๅœจ bash ๅทฅๅ…ท็š„็Žฏๅขƒไธญ่ฎพ็ฝฎ `TEAMAI_AGENT_SESSION_ID`๏ผŒๅ› ๆญคๅœจๅ…ถไธญ่ฟ่กŒ็š„ `teamai recall` ไผšๅฝ’ๅ…ฅๅ…ถ hooks ๆบๅธฆ็š„ๅŒไธ€ไผš่ฏ๏ผ›`task` ่ฐƒ็”จไผšๆŠŠๅญไปฃ็†็š„ๅญไผš่ฏๅ…ณ่”ๅˆฐ็ˆถไผš่ฏ๏ผŒๅ› ๆญคๅญไปฃ็† recall ไน‹ๅŽ็ˆถไผš่ฏๆ‰“ๅผ€็š„ๆ–‡ๆกฃไผš่ขซ upvoteใ€‚ๅฏ้€‰็š„ LLM-judge ้œ€่ฆ transcript๏ผŒ่€Œ `session.idle` ไธๆบๅธฆ๏ผŒๆ‰€ไปฅๅฎƒๅœจ OpenCode ไธŠไธ่ฟ่กŒ๏ผ›hook ็š„ stdout ไผš่ขซไธขๅผƒ๏ผŒๅ› ๆญค"ๆœฌๆฌกไผš่ฏ้‡‡็บณ็š„ๅ›ข้˜Ÿ็Ÿฅ่ฏ†"ๆ‘˜่ฆไนŸไธไผšๆ˜พ็คบใ€‚ - **MCP** server ไฝไบŽๅ…ฑไบซ `opencode.json` ็š„ `mcp` ้”ฎไธ‹๏ผˆ่ฏฆ่งไธŠๆ–‡ MCP ็ซ ่Š‚๏ผ‰ใ€‚ @@ -2125,8 +2131,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/`ใ€‚ -- **ๆŒ‡ไปคๆ–‡ไปถใ€‚** Pi ่ฏปๅ–้กน็›ฎ่‡ชๅทฑ็š„ `AGENTS.md`๏ผˆๆˆ– `CLAUDE.md`๏ผ‰๏ผŒTeamAI ไธไฟฎๆ”นๅฎƒใ€‚็”จๆˆท่Œƒๅ›ด็š„ๅ›ข้˜ŸๆŒ‡ไปคๅ†™ๅ…ฅ `~/.pi/agent/AGENTS.md`ใ€‚ๅœจ้กน็›ฎไธญ๏ผŒTeamAI ็š„ Pi ๆ‰ฉๅฑ•ๅœจไผš่ฏๅผ€ๅง‹ๆ—ถๅ‘ `teamai` ่Žทๅ–ๆˆๅ‘˜็š„ๅ›ข้˜ŸๆŒ‡ไปค๏ผŒๅนถๅŠ ๅ…ฅๆฏๆฌก่ฟ่กŒ็š„็ณป็ปŸๆ็คบใ€‚ +- **ไฝœ็”จๅŸŸใ€‚** ้กน็›ฎ็บง Skills ๅ†™ๅ…ฅ `.pi/skills/`๏ผŒ็”จๆˆท็บง Skills ๅ†™ๅ…ฅ `~/.pi/agent/skills/`ใ€‚Pi ไธ่ฏปๅ– rules ็›ฎๅฝ•๏ผŒๅ› ๆญค teamai ไธไธบๅฎƒๅ†™ rule ๆ–‡ไปถ๏ผšuser scope ็š„ๅ›ข้˜Ÿ rule ๆ˜ฏ `~/.pi/agent/AGENTS.md` ไธญ็š„ไธ€ไธชๅŒบๅ—๏ผŒๅœจ้กน็›ฎไธญๅˆ™็”ฑ TeamAI ็š„ Pi ๆ‰ฉๅฑ•ๆŠŠ้กน็›ฎ็š„ๅ›ข้˜Ÿ rule ๅŠ ๅ…ฅๆฏๆฌก่ฟ่กŒ็š„็ณป็ปŸๆ็คบ๏ผŒไบŒ่€…้ƒฝไธๆŒ‰่ทฏๅพ„้™ๅฎšไฝœ็”จ่Œƒๅ›ดใ€‚pull ไผšๅˆ ้™คๆ—ง็‰ˆๆœฌ็•™ๅœจ `.pi/rules/` ๅ’Œ `~/.pi/agent/rules/` ไธญๆœชไฟฎๆ”น็š„ๅ‰ฏๆœฌ๏ผŒๅนถ็‚นๅไฝ ๆ”น่ฟ‡็š„ๅ‰ฏๆœฌใ€‚ +- **ๆŒ‡ไปคๆ–‡ไปถใ€‚** Pi ่ฏปๅ–้กน็›ฎ่‡ชๅทฑ็š„ `AGENTS.md`๏ผˆๆˆ– `CLAUDE.md`๏ผ‰๏ผŒTeamAI ไธไฟฎๆ”นๅฎƒใ€‚็”จๆˆท่Œƒๅ›ด็š„ๅ›ข้˜ŸๆŒ‡ไปคๅ†™ๅ…ฅ `~/.pi/agent/AGENTS.md`ใ€‚ๅœจ้กน็›ฎไธญ๏ผŒTeamAI ็š„ Pi ๆ‰ฉๅฑ•ๅœจไผš่ฏๅผ€ๅง‹ๆ—ถๅ‘ `teamai` ่Žทๅ–ๆˆๅ‘˜็š„ๅ›ข้˜ŸๆŒ‡ไปคๅ’Œ้กน็›ฎ็š„ๅ›ข้˜Ÿ rule๏ผŒๅนถๅŠ ๅ…ฅๆฏๆฌก่ฟ่กŒ็š„็ณป็ปŸๆ็คบ๏ผ›Pi ๆฏๆฌก่ฟ่กŒ้ƒฝไผš้‡ๅปบ่ฏฅๆ็คบ๏ผŒๅ› ๆญคๅฎƒไปฌไธไผš็ดฏ็งฏใ€‚ - **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` ๅฝขๅผๅฎ‰่ฃ…ใ€‚ไธๆ”ฏๆŒ็š„็”Ÿๅ‘ฝๅ‘จๆœŸไบ‹ไปถไผš่ญฆๅ‘Šๅนถ่ทณ่ฟ‡ใ€‚ @@ -2137,12 +2143,22 @@ GitHub Copilot CLI ๅทฒๆ”ฏๆŒๅ…ถๅฎ˜ๆ–น่‡ชๅฎšไน‰ๆŒ‡ไปคใ€Rulesใ€Skillsใ€่‡ชๅฎš Qoder ๅทฒไฝœไธบๅ†…็ฝฎ็›ฎๆ ‡ๆ”ฏๆŒใ€‚TeamAI ไผšๅฐ† Skillsใ€Rules ๅ’Œ Subagents ๅˆ†ๅˆซไธ‹ๅ‘ๅˆฐ `.qoder/skills/`ใ€`.qoder/rules/` ๅ’Œ `.qoder/agents/`ใ€‚Hooks ไธŽ MCP Server ไผšๅˆๅนถ่ฟ›ๅฏนๅบ”ไฝœ็”จๅŸŸ็š„ `.qoder/settings.json`๏ผŒๅนถไฟ็•™็”จๆˆทๅทฒๆœ‰็š„ๅ…ถไป–่ฎพ็ฝฎ๏ผ›่ฟ™ไบ›่ทฏๅพ„ไธŽ Qoder ็š„็”จๆˆท็บงๅ’Œ้กน็›ฎ็บง้…็ฝฎ็บฆๅฎšไธ€่‡ดใ€‚ -Qoder CN ๆ˜ฏ็‹ฌ็ซ‹ๅ‘่กŒ็š„็‰ˆๆœฌ๏ผŒๅ…ถ**็”จๆˆท็บง**็›ฎๅฝ•ไธบ `~/.qoder-cn` ่€Œ้ž `~/.qoder`๏ผŒๅ› ๆญคๅฎƒไฝœไธบ็‹ฌ็ซ‹็š„ๅ†…็ฝฎ็›ฎๆ ‡ `qoder-cn` ๆ”ฏๆŒ๏ผŒ่€Œไธๆ˜ฏๅนถๅ…ฅ `qoder`ใ€‚ไธค่€…ไป…็”จๆˆทไฝœ็”จๅŸŸไธๅŒ๏ผš็”จๆˆท็บง็š„่ต„ๆบๅ†™ๅ…ฅ `~/.qoder-cn/{skills,rules,agents}`๏ผŒHooks ไธŽ MCP ๅ†™ๅ…ฅ `~/.qoder-cn/settings.json`๏ผ›้กน็›ฎไฝœ็”จๅŸŸๅˆ™ๆฒฟ็”จ Qoder ็š„ `/.qoder/` ๅธƒๅฑ€ใ€‚ไธค่€…่ฏปๅ–็›ธๅŒ็š„ Claude ๅ…ผๅฎน่ต„ๆบๆ ผๅผ๏ผŒๅ› ๆญคไธ‹ๅ‘ๅ†…ๅฎนไธ€่‡ด๏ผŒไป…็”จๆˆท็บงๆ น็›ฎๅฝ•ไธๅŒใ€‚ๅŒๆ—ถๅฎ‰่ฃ…ไธคไธช็‰ˆๆœฌๆ—ถ๏ผŒTeamAI ไผšๅˆ†ๅˆซๅŒๆญฅๅˆฐๅ„่‡ช็š„็”จๆˆท็›ฎๅฝ•๏ผŒๆ— ้œ€ๅ†ๅปบ่ฝฏ้“พๆŽฅใ€‚ +Rules ๆŒ‰ Qoder Desktop ๅ†™ๅ…ฅ็š„ๅฝขๅผ็”Ÿๆˆ๏ผŒQoder CLI ไนŸ่ฏปๅ–่ฟ™็งๅฝขๅผ๏ผšๅธฆ `paths:` ็š„่ง„ๅˆ™ๅ†™ๆˆ `trigger: glob` ๅŠ ไธ€่กŒไธๅธฆๅผ•ๅทใ€ไปฅ้€—ๅทๅˆ†้š”็š„ `glob:`๏ผŒ็”ฑไบŽ่ฏฅ่กŒไผšๆŒ‰ๆฏไธช้€—ๅทๅˆ‡ๅˆ†๏ผŒ`{a,b}` ๅฝขๅผ็š„้€‰ๆ‹ฉไผšๅฑ•ๅผ€ไธบๅคšไธช glob๏ผ›ๆฒกๆœ‰ `paths` ็š„่ง„ๅˆ™ๅ†™ๆˆ `trigger: always_on`ใ€‚Qoder ๆœชๅ…ฌๅผ€่ฟ™็ง frontmatter ็š„ schema๏ผŒ่ฏฅๅฝขๅผๅ–่‡ช `alibaba/tron-one-agent` ไธญ Desktop ็”Ÿๆˆ็š„่ง„ๅˆ™ๆ–‡ไปถใ€‚`push` ๆ—ถๅชๆœ‰ Markdown ๆญฃๆ–‡ๅ›žๆต๏ผ›`.qoder/rules/` ไธญๆฒกๆœ‰ๅฏนๅบ”ๅ›ข้˜Ÿ่ง„ๅˆ™็š„ๆ–‡ไปถๅฑžไบŽไฝ ่‡ชๅทฑ๏ผš`pull` ไธไผšๅˆ ้™คๅฎƒ๏ผŒ`push` ไนŸไธไผšๆŠŠๅฎƒๅฝ“ไฝœๆ–ฐ็š„ๅ›ข้˜Ÿ่ง„ๅˆ™ๆไบคใ€‚ๆ—ง็‰ˆ teamai ๅŽŸๆ ทๅ†™ๅ…ฅ็š„ๅ‰ฏๆœฌ๏ผŒ่‹ฅไปๆ˜ฏ teamai ๅ†™ๅ…ฅ็š„ๅ†…ๅฎน๏ผŒไธ‹ไธ€ๆฌก `pull` ไผšๆ”นๅ†™ไธบๆ–ฐๆ ผๅผ๏ผ›ไฝ ไฟฎๆ”น่ฟ‡็š„ๅ‰ฏๆœฌไผšไฟ็•™ๅนถ็ป™ๅ‡บๆ็คบใ€‚ + +Qoder CN ๆ˜ฏ็‹ฌ็ซ‹ๅ‘่กŒ็š„็‰ˆๆœฌ๏ผŒๅ…ถ**็”จๆˆท็บง**็›ฎๅฝ•ไธบ `~/.qoder-cn` ่€Œ้ž `~/.qoder`๏ผŒๅ› ๆญคๅฎƒไฝœไธบ็‹ฌ็ซ‹็š„ๅ†…็ฝฎ็›ฎๆ ‡ `qoder-cn` ๆ”ฏๆŒ๏ผŒ่€Œไธๆ˜ฏๅนถๅ…ฅ `qoder`ใ€‚ไธค่€…ไป…็”จๆˆทไฝœ็”จๅŸŸไธๅŒ๏ผš็”จๆˆท็บง็š„่ต„ๆบๅ†™ๅ…ฅ `~/.qoder-cn/{skills,rules,agents}`๏ผŒHooks ไธŽ MCP ๅ†™ๅ…ฅ `~/.qoder-cn/settings.json`๏ผ›้กน็›ฎไฝœ็”จๅŸŸๅˆ™ๆฒฟ็”จ Qoder ็š„ `/.qoder/` ๅธƒๅฑ€ใ€‚ไธค่€…่ฏปๅ–็›ธๅŒ็š„ Claude ๅ…ผๅฎน่ต„ๆบๆ ผๅผ๏ผŒๅ› ๆญคไธ‹ๅ‘ๅ†…ๅฎนไธ€่‡ด๏ผŒไป…็”จๆˆท็บงๆ น็›ฎๅฝ•ไธๅŒใ€‚ๅŒๆ—ถๅฎ‰่ฃ…ไธคไธช็‰ˆๆœฌๆ—ถ๏ผŒTeamAI ไผšๅˆ†ๅˆซๅŒๆญฅๅˆฐๅ„่‡ช็š„็”จๆˆท็›ฎๅฝ•๏ผŒๆ— ้œ€ๅ†ๅปบ่ฝฏ้“พๆŽฅใ€‚ๅœจ้กน็›ฎไธญ Qoder ไธŽ Qoder CN ้ƒฝ่ฏปๅ– `.qoder/rules/`๏ผŒๅ› ๆญคไธค่€…ๅ…ฑ็”จๅ…ถไธญ็š„ไธ€ไปฝๅ‰ฏๆœฌ๏ผšๅธ่ฝฝๅ…ถไธญไธ€ไธชๆ—ถ๏ผŒๅช่ฆๅฆไธ€ไธชไปๅทฒๅฎ‰่ฃ…๏ผŒๅ‰ฏๆœฌๅฐฑไผšไฟ็•™๏ผ›`doctor` ไนŸๅชๆฃ€ๆŸฅไธ€ๆฌก๏ผŒๅณ `Rules delivered to qoder, qoder-cn`ใ€‚ ### Kiro Kiro ๅทฒไฝœไธบๅ†…็ฝฎ็›ฎๆ ‡ๆ”ฏๆŒใ€‚TeamAI ไผšๅฐ† Skillsใ€Rules ๅ’Œ Subagents ๅˆ†ๅˆซไธ‹ๅ‘ๅˆฐ `.kiro/skills/`ใ€`.kiro/steering/` ๅ’Œ `.kiro/agents/`๏ผŒไธŽ Kiro ๅฎ˜ๆ–นๆ–‡ๆกฃๅฎšไน‰็š„[ๅทฅไฝœๅŒบ Skills](https://kiro.dev/docs/skills/)ใ€[Steering](https://kiro.dev/docs/steering/)ๅ’Œ่‡ชๅฎšไน‰ agents ๅธƒๅฑ€ไธ€่‡ดใ€‚Subagents ๆธฒๆŸ“ไธบ Kiro CLI 2.x ไธŽ 3.x ้ƒฝๆ”ฏๆŒ็š„ JSON๏ผ›ๆฏไธชๆ–‡ไปถ้ƒฝไผšไฟ็•™ Kiro ็งๆœ‰ๅญ—ๆฎตๅ’Œ่‡ชๅฎšไน‰ Hooks๏ผŒๅนถๅŠ ๅ…ฅ TeamAI ็ฎก็†็š„ `hooks.agentSpawn` ๅ‘ฝไปค๏ผŒๅœจไบคไบ’ๅผ CLI ไผš่ฏๆฟ€ๆดป่ฏฅ่‡ชๅฎšไน‰ agent ๆ—ถๆดพๅ‘ `session-start`ใ€‚่ฟ™ไธ€็ป้ชŒ่ฏ็š„ CLI 2.x Hook ๅ†…ๅตŒๅœจ `.kiro/agents/*.json`๏ผŒ่€Œไธๆ˜ฏๅ†™ๅ…ฅ IDE 1.x / CLI 3.x ๅผ•ๅ…ฅ็š„็‹ฌ็ซ‹ `.kiro/hooks/`๏ผ›Kiro ๅ†…ๅญ˜ไธญ็š„ๅ†…็ฝฎ้ป˜่ฎค agent ๆ— ๆณ•ไฟฎๆ”น๏ผŒ`--no-interactive` ไนŸไธไผš่งฆๅ‘ `agentSpawn`ใ€‚MCP Server ไผšๅˆๅนถ่ฟ›ๅฏนๅบ”ไฝœ็”จๅŸŸ็š„ `.kiro/settings/mcp.json`๏ผˆ่งไธŠๆ–‡ MCP ็ซ ่Š‚๏ผ‰ใ€‚ +Rules ไปฅๅธฆ Kiro inclusion frontmatter ็š„ steering ๆ–‡ไปถๅ†™ๅ…ฅ `.kiro/steering/` ไธŽ `~/.kiro/steering/`๏ผšๅธฆ `paths:` ็š„่ง„ๅˆ™ๅ†™ๆˆ `inclusion: fileMatch`๏ผŒๅนถๆŠŠๅ…ถ glob ๅˆ—่กจๅ†™ๅ…ฅ `fileMatchPattern`๏ผ›ๆฒกๆœ‰ `paths` ็š„่ง„ๅˆ™ๅ†™ๆˆ `inclusion: always`ใ€‚Kiro ๅช่ฏปๅ– steering ็›ฎๅฝ•็š„้กถๅฑ‚๏ผˆ[kirodotdev/Kiro#10448](https://github.com/kirodotdev/Kiro/issues/10448)๏ผ‰๏ผŒๅ› ๆญคไธŽ [Oh My Pi](#oh-my-pi) ไธ€ๆ ท๏ผŒnamespace ไธ‹็š„่ง„ๅˆ™ไผšๅนณ้“บๅ†™ๅ…ฅ๏ผš`rules/fe/style.md` ๅ†™ๆˆ `fe.style.md`๏ผŒ`push` ไผšๆŠŠๅฏน่ฏฅๆ–‡ไปถ็š„ไฟฎๆ”นๅ†™ๅ›ž `rules/fe/style.md`ใ€‚push ่ฆๆฑ‚่ฏฅๅนณ้“บๅ‰ฏๆœฌๆœ‰ไธ‹ๅ‘่ฎฐๅฝ•๏ผ›ๅŒๅ็š„ไธชไบบๆ–‡ไปถๆ—ขไธไผšๅœจ push ๅ‰่ขซๅˆทๆ–ฐ๏ผŒไนŸไธไผš่ขซๅฝ“ไฝœๅ›ข้˜Ÿ่ง„ๅˆ™็š„ไฟฎๆ”นใ€‚่‹ฅไฝ ๆ”ถๅˆฐ็š„ๅฆไธ€ๆก่ง„ๅˆ™ไนŸๅฏนๅบ”ๅŒไธ€ไธชๅนณ้“บๆ–‡ไปถๅ๏ผŒ่ฏฅ่ง„ๅˆ™ไธไผšๅ†™ๅ…ฅ๏ผ›ไธŽๅ›ข้˜Ÿ่ง„ๅˆ™ๅนณ้“บๆ–‡ไปถๅ็›ธๅŒ็š„ไฝ ่‡ชๅทฑ็š„ๆ–‡ไปถๆฐธ่ฟœไธไผš่ขซ่ฆ†็›–ๆˆ–ๅˆ ้™คใ€‚ๆ—ง็‰ˆๅ†™ๅ…ฅ็š„ๅตŒๅฅ—ๅ‰ฏๆœฌ `/.md` ไผšๅœจๅนณ้“บๅ‰ฏๆœฌๅ†™ๅ…ฅๅŽๅˆ ้™ค๏ผ›ไฝ ไฟฎๆ”น่ฟ‡็š„ๅ‰ฏๆœฌไผšไฟ็•™ๅนถ็ป™ๅ‡บๆ็คบ๏ผŒๅ› ไธบ Kiro ไธไผš่ฏปๅ–ๅฎƒใ€‚`push` ๆ—ถๅชๆœ‰ Markdown ๆญฃๆ–‡ๅ›žๆต๏ผ›ๆฒกๆœ‰ๅฏนๅบ”ๅ›ข้˜Ÿ่ง„ๅˆ™็š„ steering ๆ–‡ไปถ๏ผˆไพ‹ๅฆ‚ Kiro ่‡ชๅทฑ็”Ÿๆˆ็š„ `product.md`๏ผ‰ๅฑžไบŽไฝ ่‡ชๅทฑ๏ผš`pull` ไธไผšๅˆ ้™คๅฎƒ๏ผŒ`push` ไนŸไธไผšๆŠŠๅฎƒๅฝ“ไฝœๆ–ฐ็š„ๅ›ข้˜Ÿ่ง„ๅˆ™ๆไบคใ€‚ๆ—ง็‰ˆ teamai ๅŽŸๆ ทๅ†™ๅ…ฅ็š„ๅ‰ฏๆœฌ๏ผŒ่‹ฅไปๆ˜ฏ teamai ๅ†™ๅ…ฅ็š„ๅ†…ๅฎน๏ผŒไธ‹ไธ€ๆฌก `pull` ไผšๆ”นๅ†™ไธบๆ–ฐๆ ผๅผ๏ผ›ไฝ ไฟฎๆ”น่ฟ‡็š„ๅ‰ฏๆœฌไผšไฟ็•™ๅนถ็ป™ๅ‡บๆ็คบใ€‚Kiro CLI ๆ— ่ฎบ `inclusion` ๅ–ๅ€ผ้ƒฝไผšๅŠ ่ฝฝๅ…จ้ƒจ steering ๆ–‡ไปถ๏ผˆ[kirodotdev/Kiro#7950](https://github.com/kirodotdev/Kiro/issues/7950)๏ผ‰๏ผŒๅ› ๆญคๅœจ CLI ไธญ้™ๅฎš่ทฏๅพ„็š„่ง„ๅˆ™ไนŸไผšๅง‹็ปˆ็”Ÿๆ•ˆใ€‚Kiro IDE ๆ›พๅฟฝ็•ฅ `~/.kiro/steering` ไธญ็š„ `fileMatch`๏ผˆ[kirodotdev/Kiro#9176](https://github.com/kirodotdev/Kiro/issues/9176)๏ผŒKiro 0.12๏ผ‰๏ผ›็ปดๆŠค่€…็งฐๆญคๅŽๅทฒไฟฎๅค๏ผŒ่ฏฅ issue ๆœช็ปๅคๆต‹ๅณๅ…ณ้—ญใ€‚ + +### CodeBuddy ไธŽ WorkBuddy + +WorkBuddy ่ฟ่กŒ็š„ๆ˜ฏ CodeBuddy ็š„ๅผ•ๆ“Ž๏ผŒๅ› ๆญคไธค่€…้ƒฝๆŒ‰ CodeBuddy ็š„ๆ ผๅผๅพ—ๅˆฐ rules๏ผšๅธฆ `paths:` ็š„่ง„ๅˆ™ๅ†™ๆˆ `alwaysApply: false`๏ผŒๅนถๆŠŠ `paths:` ๅ†™ๆˆ YAML ๅ—ๅˆ—่กจ๏ผŒๆฏ้กนไธ€ไธชๅธฆๅผ•ๅท็š„ glob๏ผ›ๆฒกๆœ‰ `paths` ็š„่ง„ๅˆ™ๅ†™ๆˆ `alwaysApply: true`ใ€‚CodeBuddy ็š„ frontmatter ่งฃๆžๅ™จๆŒ‰่กŒ่ฏปๅ–่€Œ้žๆŒ‰ YAML ่งฃๆž๏ผŒๅŽŸๆ ทๅ‰ฏๆœฌไธญ็š„่กŒๅ†…ๅ†™ๆณ• `paths: ["a", "b"]` ไผš่ฎฉๅฎƒๅพ—ๅˆฐๅธฆๆ–นๆ‹ฌๅท็š„ globใ€‚ๅœจ้กน็›ฎไธญไธคไธชๅทฅๅ…ท้ƒฝ่ฏปๅ– `.codebuddy/rules/`๏ผŒๅ› ๆญค่ฏฅ็›ฎๅฝ•ไธบไธค่€…ๅชไฟๅญ˜ๆฏๆก่ง„ๅˆ™็š„ไธ€ไปฝๅ‰ฏๆœฌ๏ผšๆŽ’้™คๆˆ–ๅธ่ฝฝๅ…ถไธญไธ€ไธชๅทฅๅ…ทๆ—ถ๏ผŒๅช่ฆๅฆไธ€ไธชไปๅทฒๅฎ‰่ฃ…๏ผŒ่ฟ™ไบ›ๅ‰ฏๆœฌๅฐฑไผšไฟ็•™๏ผ›`doctor` ไนŸๅชๆฃ€ๆŸฅ่ฏฅ็›ฎๅฝ•ไธ€ๆฌก๏ผŒๅณ `Rules delivered to codebuddy, workbuddy`ใ€‚ๅชๆœ‰ๅญ˜ๅœจ `.workbuddy/` ๆ—ถ WorkBuddy ๆ‰่ง†ไธบๅทฒๅฎ‰่ฃ…ใ€‚ๅœจๆœ‰ `.workbuddy/` ไฝ†ๆฒกๆœ‰ `.codebuddy/` ็š„้กน็›ฎไธญ๏ผŒๅ…ฑ็”จๅ‰ฏๆœฌไผšๅˆ›ๅปบ `.codebuddy/`๏ผŒไบŽๆ˜ฏ CodeBuddy ๅœจ่ฏฅ้กน็›ฎไธญไนŸไผš่ขซ่ง†ไธบๅทฒๅฎ‰่ฃ…๏ผŒๅนถๅŒๆ ทๅพ—ๅˆฐ skillsใ€agents ๅ’Œ hooks๏ผ›ๅฆ‚ๆžœไฝ ไธ็”จ CodeBuddy๏ผŒ`teamai uninstall --agent codebuddy` ไผš็งป้™คๅฎƒไปฌๅนถ่ฎฉๅฎƒไฟๆŒๆŽ’้™ค๏ผŒๅ…ฑ็”จ็š„ rules ไปไธบ WorkBuddy ไฟ็•™ใ€‚user scope ไธ‹ CodeBuddy ่ฏปๅ– `~/.codebuddy/rules/`๏ผŒWorkBuddy ่ฏปๅ– `~/.workbuddy/rules/`ใ€‚`push` ๆ—ถๅชๆœ‰ Markdown ๆญฃๆ–‡ๅ›žๆต๏ผ›ๅ…ถไธญๆฒกๆœ‰ๅฏนๅบ”ๅ›ข้˜Ÿ่ง„ๅˆ™็š„ๆ–‡ไปถๅฑžไบŽไฝ ่‡ชๅทฑ๏ผš`pull` ไธไผšๅˆ ้™คๅฎƒ๏ผŒ`push` ไนŸไธไผšๆŠŠๅฎƒๅฝ“ไฝœๆ–ฐ็š„ๅ›ข้˜Ÿ่ง„ๅˆ™ๆไบคใ€‚ๆ—ง็‰ˆ teamai ๅŽŸๆ ทๅ†™ๅ…ฅ็š„ๅ‰ฏๆœฌ๏ผŒ่‹ฅไปๆ˜ฏ teamai ๅ†™ๅ…ฅ็š„ๅ†…ๅฎน๏ผŒไธ‹ไธ€ๆฌก `pull` ไผšๆ”นๅ†™ไธบๆ–ฐๆ ผๅผ๏ผ›ไฝ ไฟฎๆ”น่ฟ‡็š„ๅ‰ฏๆœฌไผšไฟ็•™ๅนถ็ป™ๅ‡บๆ็คบใ€‚ + +> ๅ‡็บง่ฏดๆ˜Ž๏ผšๆ—ง็‰ˆๆœฌๆŠŠ WorkBuddy ็š„้กน็›ฎ rules ๅ†™ๅˆฐ `.workbuddy/rules/`๏ผŒ่€Œ WorkBuddy ไปŽไธ่ฏปๅ–่ฏฅ็›ฎๅฝ•ใ€‚ไธ‹ไธ€ๆฌก `pull` ไผšๅˆ ้™คๅ…ถไธญไปๆ˜ฏ teamai ๆŠ•้€’ๅ†…ๅฎน็š„ๅ‰ฏๆœฌ๏ผˆ็›ฎๅฝ•ๆธ…็ฉบๅŽไธ€ๅนถๅˆ ้™ค๏ผ‰๏ผŒๅนถๆŠŠ rules ๅ†™ๅˆฐ `.codebuddy/rules/`๏ผŒๅณไฝฟๅ›ข้˜Ÿไป“ๅบ“ๆฒกๆœ‰ๅ˜ๅŒ–ไนŸๆ˜ฏๅฆ‚ๆญค๏ผ›ไฝ ๆ”น่ฟ‡็š„ๅ‰ฏๆœฌไผšไฟ็•™ๅนถ็‚นๅใ€‚WorkBuddy ็š„ไธ€ๆฌกๆ€ง่ฟ็งปๆŠŠ `~/.codebuddy/rules/` ๅคๅˆถๅˆฐไบ† `~/.workbuddy/rules/`๏ผˆไผš็•™ไธ‹ `~/.workbuddy/.migrated-from-codebuddy`๏ผ‰๏ผŒๅ…ถไธญไนŸๅŒ…ๆ‹ฌๅ›ข้˜Ÿ่ง„ๅˆ™็š„ๅ‰ฏๆœฌใ€‚ๅคๅˆถ่ฟ‡ๆฅ็š„ๅ›ข้˜Ÿ่ง„ๅˆ™่‹ฅไปๆ˜ฏ teamai ๆŠ•้€’็š„ๅ†…ๅฎน๏ผˆ่ฏฅ่ง„ๅˆ™็š„ๆŸ็งๆธฒๆŸ“็ป“ๆžœ๏ผŒๆˆ– teamai ่ฎฐๅฝ•็š„ๅœจ `~/.codebuddy/rules/` ไธ‹ๅŒๅๆ–‡ไปถๅ†™ๅ…ฅ็š„ๅญ—่Š‚๏ผ‰๏ผŒๅœจ WorkBuddy ไปๅพ—ๅˆฐ่ฏฅ่ง„ๅˆ™ๆ—ถไผšๆ”นๅ†™ไธบ CodeBuddy ๆ ผๅผ๏ผŒๅฆๅˆ™ๅˆ ้™ค๏ผ›ๆ”น่ฟ‡็š„ๅ‰ฏๆœฌไผšไฟ็•™ๅนถ็‚นๅ๏ผŒไฝ ่‡ชๅทฑ็š„่ง„ๅˆ™ไฟๆŒไธๅ˜ใ€‚ + ### ZCode ZCode ๅทฒไฝœไธบๅ†…็ฝฎ็›ฎๆ ‡ๆ”ฏๆŒใ€‚Skills ไธ‹ๅ‘ๅˆฐ `.zcode/skills/`๏ผˆZCode ๅŒๆ—ถไผš่ฏปๅ–ไธญๅคฎ็›ฎๅฝ• `~/.agents/skills/`๏ผŒ่ฏฅ็›ฎๅฝ•็”ฑ `agents` ๆก็›ฎ่ฆ†็›–๏ผ‰๏ผŒSubagents ไปฅ Claude ้ฃŽๆ ผ Markdown ไธ‹ๅ‘ๅˆฐ `.zcode/agents/`ใ€‚Hooks ไผšๅˆๅนถ่ฟ›ๅ…ฑไบซ็š„ `~/.zcode/cli/config.json`๏ผŒๅนถไฟ็•™ๆ’ไปถ็Šถๆ€็ญ‰ๆ— ๅ…ณ้”ฎๅ€ผใ€‚ๅ†™ๅ…ฅๅ™จไธบไฝ ๅค„็†ไบ†ไธคไธช ZCode ็‰นๆœ‰็š„็ป†่Š‚๏ผš @@ -2151,21 +2167,29 @@ ZCode ๅทฒไฝœไธบๅ†…็ฝฎ็›ฎๆ ‡ๆ”ฏๆŒใ€‚Skills ไธ‹ๅ‘ๅˆฐ `.zcode/skills/`๏ผˆZCode - Windows ไธŠ๏ผŒ้’ฉๅญๆก็›ฎ้€š่ฟ‡้š่—็š„ **wscript VBS ๅฏๅŠจๅ™จ**ๆ‰ง่กŒ๏ผˆ`wscript.exe <ๅˆ†ๅ‘ๅ‘ฝไปคๅฐพๆฎต>`๏ผ‰๏ผšwscript ๅฑž GUI ๅญ็ณป็ปŸ๏ผŒ้’ฉๅญ่ฟ่กŒ็ปไธๅผนๆŽงๅˆถๅฐ้ป‘ๆก†๏ผ›ๅฏๅŠจๅ™จๆŠŠ STDIN ๆš‚ๅญ˜ไธบไธดๆ—ถๆ–‡ไปถๅ†่ฝฌๅ‘๏ผŒไฟ่ฏ payload ๅฎŒๆ•ดๅˆฐ่พพ `hook-dispatch`ใ€‚่ถ…ๆ—ถๆŒ‰ไบ‹ไปถๆ”พๅฎฝ๏ผˆไผš่ฏๅฏๅŠจ 180 ็ง’ใ€stop / prompt ๆไบค 60 ็ง’ใ€ๅทฅๅ…ท่ฐƒ็”จๅŽ 30 ็ง’๏ผ‰๏ผŒ้ฟๅ…ไผš่ฏๅฏๅŠจๆ—ถๆบๅธฆไป“ๅบ“ๆ‹‰ๅ–็š„ๅˆ†ๅ‘่ขซไธญ้€”ๆސๆ–ญใ€‚ๅซๅคšๅญ—่Š‚ๆ–‡ๆœฌ๏ผˆๅฆ‚ไธญๆ–‡๏ผ‰็š„ payload ๅœจๅฏๅŠจๅ™จ็š„ ANSI ไปฃ็ ้กตๆš‚ๅญ˜็Žฏ่Š‚ๅฏ่ƒฝ้™็บงโ€”โ€”่บซไปฝๅญ—ๆฎตไผš่ขซๆŠขๆ•‘๏ผŒ้™็บงๅˆ†ๅ‘ไป่ƒฝๆญฃ็กฎๅ…ณ่”ๅˆฐไผš่ฏ๏ผ›ๅธ่ฝฝๆ—ถไผšๅŒๆ—ถๆธ…้™คๆก็›ฎไธŽ่„šๆœฌๆ–‡ไปถใ€‚ - POSIX ไธŠๆก็›ฎๅฐฑๆ˜ฏๆ™ฎ้€š็š„ `bash -lc <ๅˆ†ๅ‘ๅ‘ฝไปคๅฐพๆฎต>` argv ๅ‘้‡๏ผŒไธๅ†™ๅ…ฅๅฏๅŠจๅ™จ๏ผ›ไธคไธชๅนณๅฐไธŠ๏ผŒๅ‘ฝไปคๅฐพๆฎต้ƒฝไปฅ argv ๆœซไฝๅ…ƒ็ด ๅŽŸๆ ทๅญ˜ๅ‚จโ€”โ€”่ฟ™ๆญฃๆ˜ฏๆ‰˜็ฎกๆก็›ฎ่ฏ†ๅˆซไธŽๆ‰˜็ฎกๆธ…ๅ•ๆฏ”ๅฏน็š„ไพๆฎใ€‚ -ไปฅไธŠ่ทฏๅพ„ๅทฒๅฏน็…ง ZCode ๆกŒ้ข็ซฏๅฎžๆต‹้ชŒ่ฏ๏ผš่ฎพ็ฝฎ้กตใ€Œๆ–ฐๅปบๅญๆ™บ่ƒฝไฝ“ใ€ๅ†™ๅ…ฅ็š„ๅฐฑๆ˜ฏ `~/.zcode/agents/*.md`๏ผŒๅๅ‘ๆ”พๅ…ฅ็š„ๆ–‡ไปถไนŸไผšๅ‡บ็Žฐๅœจ้กต้ข็š„ๅทฒๅฎ‰่ฃ…ๅˆ—่กจไธญใ€‚MCP Server ไธ‹ๅ‘ๅˆฐ `~/.agents/mcp.json`๏ผˆ็”จๆˆท็บง๏ผŒClaude ็š„ `mcpServers` ็ป“ๆž„โ€”โ€”ๆญฃๆ˜ฏ ZCode ่‡ชๅทฑ็š„ MCP ่ฎพ็ฝฎ้กต่ฏปๅ–็š„ๆ–‡ไปถ๏ผ‰ใ€‚้กน็›ฎ็บงๆš‚ๆœชๆŽฅๅ…ฅ๏ผšZCode ็š„ๅทฅไฝœๅŒบ MCP ไฝฟ็”จไธๅŒ็š„้”ฎ๏ผˆ`.zcode/config.json` ๅ†…็š„ `mcp.servers`๏ผ‰๏ผŒClaude ๅ†™ๅ…ฅๅ™จๆ— ๆณ•็”Ÿๆˆ่ฏฅ็ป“ๆž„ใ€‚ZCode ๆš‚ๆ— ็”จๆˆท็บง Rules ็›ฎๅฝ•็บฆๅฎš๏ผŒๅ› ๆญค Rules ไธๅŒๆญฅใ€‚ +ไปฅไธŠ่ทฏๅพ„ๅทฒๅฏน็…ง ZCode ๆกŒ้ข็ซฏๅฎžๆต‹้ชŒ่ฏ๏ผš่ฎพ็ฝฎ้กตใ€Œๆ–ฐๅปบๅญๆ™บ่ƒฝไฝ“ใ€ๅ†™ๅ…ฅ็š„ๅฐฑๆ˜ฏ `~/.zcode/agents/*.md`๏ผŒๅๅ‘ๆ”พๅ…ฅ็š„ๆ–‡ไปถไนŸไผšๅ‡บ็Žฐๅœจ้กต้ข็š„ๅทฒๅฎ‰่ฃ…ๅˆ—่กจไธญใ€‚MCP Server ไธ‹ๅ‘ๅˆฐ `~/.agents/mcp.json`๏ผˆ็”จๆˆท็บง๏ผŒClaude ็š„ `mcpServers` ็ป“ๆž„โ€”โ€”ๆญฃๆ˜ฏ ZCode ่‡ชๅทฑ็š„ MCP ่ฎพ็ฝฎ้กต่ฏปๅ–็š„ๆ–‡ไปถ๏ผ‰ใ€‚้กน็›ฎ็บงๆš‚ๆœชๆŽฅๅ…ฅ๏ผšZCode ็š„ๅทฅไฝœๅŒบ MCP ไฝฟ็”จไธๅŒ็š„้”ฎ๏ผˆ`.zcode/config.json` ๅ†…็š„ `mcp.servers`๏ผ‰๏ผŒClaude ๅ†™ๅ…ฅๅ™จๆ— ๆณ•็”Ÿๆˆ่ฏฅ็ป“ๆž„ใ€‚ZCode ไธ่ฏปๅ– rules ็›ฎๅฝ•๏ผšuser scope ไธ‹ๅ›ข้˜Ÿ rule ๆ˜ฏ `~/.zcode/AGENTS.md` ไธญ็š„ไธ€ไธชๅŒบๅ—๏ผŒZCode ๆŠŠๅฎƒไฝœไธบ็”จๆˆทไธŠไธ‹ๆ–‡่ฏปๅ–๏ผŒไธๆŒ‰่ทฏๅพ„้™ๅฎšไฝœ็”จ่Œƒๅ›ดใ€‚ๅœจ้กน็›ฎไธญ๏ผŒteamai ๅ†™ๅœจ `~/.zcode/cli/config.json` ไธญ็š„ `SessionStart` hook ไผšๆŠŠ้กน็›ฎ็š„ๅ›ข้˜Ÿ rule ๅŠ ๅ…ฅๆฏไธชๆ–ฐไผš่ฏ๏ผˆZCode ไธ่ฟ่กŒ้กน็›ฎ็บง hook๏ผ‰ใ€‚ZCode ๅŽ‹็ผฉไผš่ฏๆ—ถไผšไธขๅผƒ่ฟ™ๆฎตๆ–‡ๆœฌ๏ผŒrule ่ฆๅˆฐไธ‹ไธ€ไธชไผš่ฏๆ‰ๅ›žๆฅใ€‚ ### 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 ไผš้šไฝœ็”จๅŸŸ่‡ชๅŠจๅˆ‡ๆข๏ผ‰ใ€‚ๅ›ข้˜ŸๆŒ‡ไปคๅœจ็”จๆˆท่Œƒๅ›ดๅ†™ๅ…ฅ `~/.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/` ๅธƒๅฑ€ใ€‚ +Rules ไปฅ OMP ่‡ชๅทฑ็š„ frontmatter ๅ†™ๅ…ฅ `.omp/rules/` ไธŽ `~/.omp/agent/rules/`๏ผšๆฒกๆœ‰ `paths:` ็š„่ง„ๅˆ™ๅ†™ๆˆ `alwaysApply: true`๏ผŒๅ…ถๆญฃๆ–‡่ฟ›ๅ…ฅๆฏๆฌก็š„ๆ็คบ๏ผ›ๅธฆ `paths:` ็š„่ง„ๅˆ™ๅ†™ๆˆ `globs`๏ผˆๅณๅ…ถ glob ๅˆ—่กจ๏ผ‰ๅŠ ไธ€ไธช `description`๏ผˆๆญฃๆ–‡็š„็ฌฌไธ€ไธช Markdown ๆ ‡้ข˜๏ผŒๆฒกๆœ‰ๆ ‡้ข˜ๆ—ถไธบ `Team rule for files matching `๏ผ‰๏ผŒOMP ไผšๅœจๆ็คบ็š„ rulebook ไธญไปฅ `name (globs): description` ๅˆ—ๅ‡บๅฎƒ๏ผŒๅนถๅœจๅทฅไฝœๅŒน้…ๆ—ถ่ฏปๅ–ใ€‚ไธค่€…้ƒฝๆฒกๆœ‰็š„่ง„ๅˆ™ไผš่ขซ OMP ไธขๅผƒ๏ผŒteamai ไปฅๅ‰ๅŽŸๆ ทๅคๅˆถ็š„ๆฏๆก่ง„ๅˆ™ๆญฃๆ˜ฏๅฆ‚ๆญคใ€‚OMP ๅช่ฏปๅ– rules ็›ฎๅฝ•็š„้กถๅฑ‚๏ผŒๅ› ๆญค namespace ไธ‹็š„่ง„ๅˆ™ไผšๅนณ้“บๅ†™ๅ…ฅ๏ผš`rules/fe/style.md` ๅ†™ๆˆ `fe.style.md`๏ผŒ`push` ไผšๆŠŠๅฏน่ฏฅๆ–‡ไปถ็š„ไฟฎๆ”นๅ†™ๅ›ž `rules/fe/style.md`ใ€‚push ่ฆๆฑ‚่ฏฅๅนณ้“บๅ‰ฏๆœฌๆœ‰ไธ‹ๅ‘่ฎฐๅฝ•๏ผ›ๅŒๅ็š„ไธชไบบๆ–‡ไปถๆ—ขไธไผšๅœจ push ๅ‰่ขซๅˆทๆ–ฐ๏ผŒไนŸไธไผš่ขซๅฝ“ไฝœๅ›ข้˜Ÿ่ง„ๅˆ™็š„ไฟฎๆ”นใ€‚่‹ฅไฝ ๆ”ถๅˆฐ็š„ๅฆไธ€ๆก่ง„ๅˆ™ไนŸๅฏนๅบ”ๅŒไธ€ไธชๅนณ้“บๆ–‡ไปถๅ๏ผŒ่ฏฅ่ง„ๅˆ™ไธไผšๅ†™ๅ…ฅ๏ผšๆ น็›ฎๅฝ•่ง„ๅˆ™๏ผˆไพ‹ๅฆ‚ `rules/fe.style.md`๏ผ‰ไฟ็•™่ฏฅๆ–‡ไปถ๏ผŒไธคๆก namespace ่ง„ๅˆ™ๅˆ™้ƒฝไธๅ†™ๅ…ฅ๏ผ›`pull` ไผšๆŒ‡ๅ‡บ่ฟ™ไบ›่ง„ๅˆ™๏ผŒ`doctor` ไผšๆŠฅๅ‘Šๅคฑ่ดฅใ€‚ไธŽๅ›ข้˜Ÿ่ง„ๅˆ™ๅนณ้“บๆ–‡ไปถๅ็›ธๅŒ็š„ไฝ ่‡ชๅทฑ็š„ๆ–‡ไปถไธไผš่ขซ่ฆ†็›–ๆˆ–ๅˆ ้™ค๏ผšๅชๆœ‰ไธŽไธ‹ๅ‘่ฎฐๅฝ•ไธญ็š„ๅ†…ๅฎนไธ€่‡ด๏ผŒๆˆ–ไธŽๆธฒๆŸ“็ป“ๆžœๅฎŒๅ…จไธ€่‡ด๏ผŒๆ‰่ƒฝ่ฏๆ˜Žๅฎƒๆ˜ฏ teamai ๅ†™ๅ…ฅ็š„ใ€‚ไธ‹ๅ‘ๅŽไฝ ไฟฎๆ”น่ฟ‡็š„ๅนณ้“บๅ‰ฏๆœฌ๏ผŒ`remove` ๅ’Œ `uninstall` ไผšไฟ็•™ๅนถ็‚นๅใ€‚`push` ๆ—ถๅชๆœ‰ Markdown ๆญฃๆ–‡ๅ›žๆต๏ผ›OMP rules ็›ฎๅฝ•ไธญๆฒกๆœ‰ๅฏนๅบ”ๅ›ข้˜Ÿ่ง„ๅˆ™็š„ๆ–‡ไปถๅฑžไบŽไฝ ่‡ชๅทฑ๏ผš`pull` ไธไผšๅˆ ้™คๅฎƒ๏ผŒ`push` ไนŸไธไผšๆŠŠๅฎƒๅฝ“ไฝœๆ–ฐ็š„ๅ›ข้˜Ÿ่ง„ๅˆ™ๆไบคใ€‚ๆ—ง็‰ˆ teamai ๅŽŸๆ ทๅ†™ๅ…ฅ็š„ๅ‰ฏๆœฌ๏ผŒ่‹ฅไปๆ˜ฏ teamai ๅ†™ๅ…ฅ็š„ๅ†…ๅฎน๏ผŒไธ‹ไธ€ๆฌก `pull` ไผšๆ”นๅ†™ไธบๆ–ฐๆ ผๅผ๏ผ›ๆ—ง็š„ๅตŒๅฅ—ๅ‰ฏๆœฌ `/.md` ๅœจๅนณ้“บๅ‰ฏๆœฌๅ†™ๅ…ฅๅŽๅˆ ้™ค๏ผŒๆ— ่ฎบๆ˜ฏๅฆๆœ‰ไธ‹ๅ‘่ฎฐๅฝ•๏ผˆ่ฎฐๅฝ•๏ผŒๆˆ–ไธŽๅ›ข้˜Ÿ่ง„ๅˆ™ๅŽŸๆ–‡ไธ€่‡ด๏ผŒๅณๅฏ่ฏๆ˜Žๆœช่ขซไฟฎๆ”น๏ผ‰๏ผ›ไฝ ไฟฎๆ”น่ฟ‡็š„ๅ‰ฏๆœฌไผšไฟ็•™ๅนถ็ป™ๅ‡บๆ็คบ๏ผŒๅ› ไธบ OMP ไธไผš่ฏปๅ–ๅฎƒ๏ผšๅฆ‚้œ€ไฟ็•™ไฟฎๆ”น๏ผŒ่ฏทๆŠŠๅฎƒๅคๅˆถๅˆฐๅนณ้“บๆ–‡ไปถไธญใ€‚OMP ๅชๅœจไผš่ฏๅฏๅŠจ็š„็›ฎๅฝ•่ฏปๅ– `.omp/rules/`๏ผŒๅ› ๆญค้กน็›ฎ่ง„ๅˆ™ๅชๅˆฐ่พพไปŽ้กน็›ฎๆ น็›ฎๅฝ•ๅฏๅŠจ็š„ไผš่ฏ๏ผŒไปŽๅญ็›ฎๅฝ•ๅฏๅŠจ็š„ไผš่ฏๆ”ถไธๅˆฐใ€‚OMP ่ฟ˜ไผšๆŠŠ้กน็›ฎไธญ็š„ Cursor rules๏ผˆ`.cursor/rules/*.mdc`๏ผŒไป…้กถๅฑ‚๏ผ‰ๅ’Œ Copilot instructions๏ผˆ`.github/instructions/**/*.instructions.md`๏ผ‰ๅฝ“ไฝœ rule ๅŠ ่ฝฝ๏ผŒๅนถๆŒ‰ๅ็งฐๆฏๆกๅชไฟ็•™ไธ€ไปฝ๏ผŒไผ˜ๅ…ˆไฝฟ็”จๅฎƒ่‡ชๅทฑ็š„ `.omp/rules` ๅ‰ฏๆœฌ๏ผˆๆฎ OMP 18.2.1 ็š„ๅŠ ่ฝฝๅ™จๆ ธๅฎž๏ผ‰ใ€‚ๅ› ๆญคๅฏ็”จ Cursor ๆˆ– Copilot ๆ—ถ๏ผŒๆ น็›ฎๅฝ•ๅ›ข้˜Ÿ่ง„ๅˆ™ๅช้€่พพ OMP ไธ€ๆฌก๏ผ›ไฝ†ๅฏ็”จ Copilot ๆ—ถ๏ผŒnamespace ไธ‹็š„่ง„ๅˆ™ไผš้€่พพไธคๆฌก๏ผšไธ€ๆฌกๆ˜ฏ `.omp/rules` ไธญ็š„ `fe.style`๏ผŒไธ€ๆฌกๆ˜ฏ `.github/instructions/fe/` ไธญ็š„ `style`ใ€‚ๅชๆœ‰ไฝ ๅฏ็”จ่ฏฅๆฅๆบๆ—ถ๏ผŒOMP ๆ‰ไผš่ฏปๅ– `~/.cursor/rules`ใ€‚ + ### DeepSeek Harness -DeepSeek Harness๏ผˆ`dsh`๏ผ‰ๆ”ฏๆŒ TeamAI Skills ๅ’Œๅ…ฑไบซ่ต„ๆบใ€‚DSH ๅฎ˜ๆ–น็š„ Claude Hook Bridge ๆ˜ฏ้€š่ฟ‡ profile ๆ’ไปถๅŠ ่ฝฝ็š„๏ผŒๅนถไธๆ˜ฏ่ฎพ็ฝฎๆ–‡ไปถไธญ็š„ Hooks๏ผ›ๅ› ๆญคๆฃ€ๆต‹ๅˆฐ็”จๆˆท็บง `~/.dsh/` ๅฎ‰่ฃ…ๅŽ๏ผŒ`teamai init`ใ€`teamai pull` ๆˆ– `teamai hooks inject` ไผšๅœจ `~/.teamai/dsh/` ไธ‹็”Ÿๆˆๅ…ผๅฎน Claude ็š„ Hook ้…็ฝฎๅ’Œ Cordis patchใ€‚ +DeepSeek Harness๏ผˆ`dsh`๏ผ‰ๆ”ฏๆŒ TeamAI Skills ๅ’Œๅ…ฑไบซ่ต„ๆบใ€‚DSH ๅฎ˜ๆ–น็š„ Claude Hook Bridge ๆ˜ฏ้€š่ฟ‡ profile ๆ’ไปถๅŠ ่ฝฝ็š„๏ผŒๅนถไธๆ˜ฏ่ฎพ็ฝฎๆ–‡ไปถไธญ็š„ Hooks๏ผ›ๅ› ๆญคๅฝ“ dsh ็š„ไธป็›ฎๅฝ•๏ผˆ`$DSH_HOME`๏ผŒๆœช่ฎพ็ฝฎๆ—ถไธบ `~/.dsh/`๏ผ‰ๅญ˜ๅœจๆ—ถ๏ผŒ`teamai init`ใ€`teamai pull` ๆˆ– `teamai hooks inject` ไผšๅœจ `~/.teamai/dsh/` ไธ‹็”Ÿๆˆๅ…ผๅฎน Claude ็š„ Hook ้…็ฝฎๅ’Œ Cordis patchใ€‚ + +dsh ไธ่ฏปๅ– rules ็›ฎๅฝ•ใ€‚user scope ไธ‹ๅ›ข้˜Ÿ rule ๆ˜ฏ `$DSH_HOME/AGENTS.md`๏ผˆๆœช่ฎพ็ฝฎ `DSH_HOME` ๆ—ถไธบ `~/.dsh/AGENTS.md`๏ผ‰ไธญ็š„ไธ€ไธชๅŒบๅ—๏ผŒdsh ไผšๆŠŠๅฎƒๆ”พ่ฟ›็ฌฌไธ€ๆฌก่ฏทๆฑ‚๏ผŒไธๆŒ‰่ทฏๅพ„้™ๅฎšไฝœ็”จ่Œƒๅ›ดใ€‚ไธŽ Hook ไธ€ๆ ท๏ผŒๅชๆœ‰่ฏฅไธป็›ฎๅฝ•ๅญ˜ๅœจๆ—ถ teamai ๆ‰ๅ†™ๅ…ฅๅฎƒใ€‚Skills ไปๅ†™ๅ…ฅ `~/.dsh/skills/`๏ผŒไธ”ๅชๅœจ `~/.dsh/` ๅญ˜ๅœจๆ—ถๅ†™ๅ…ฅ๏ผŒไธŽ `DSH_HOME` ๆ— ๅ…ณใ€‚ๅœจ้กน็›ฎไธญ๏ผŒdsh ๅธฆไธŠไธ‹้ข็š„ patch ่ฟ่กŒๅŽ๏ผŒteamai ็š„ session-start hook ไผšๅŠ ๅ…ฅ้กน็›ฎ็š„ๅ›ข้˜Ÿ ruleใ€‚dsh ไปฅๅˆ†็ฆปๆ–นๅผ่ฟ่กŒ่ฏฅ hook๏ผŒ็ฌฌไธ€ๆฌก่ฏทๆฑ‚ๅฏ่ƒฝ้”™่ฟ‡ๅฎƒไปฌ๏ผŒๅŽ‹็ผฉไผš่ฏๆ—ถไนŸไผšไธขๅผƒๅฎƒไปฌใ€‚ TeamAI ไผšๆ‰“ๅฐๅธฆ็ปๅฏน่ทฏๅพ„็š„ patchใ€‚ๅฐ†่ฟ™ไธช `--patch` ๅ‚ๆ•ฐๅŠ ๅˆฐๅฏๅŠจ DSH profile ็š„ๅ‘ฝไปคไธญ๏ผŒไพ‹ๅฆ‚ `dsh tui --patch "<ๆ‰“ๅฐๅ‡บ็š„่ทฏๅพ„>"`ใ€‚่ฟ™ๆ˜ฏไธ€ๆฌกๆ€ง็š„ๅฏๅŠจๅ™จ้€‰ๆ‹ฉ๏ผ›`teamai hooks remove` ๅ’Œ `teamai uninstall` ไผš็งป้™ค TeamAI patch๏ผŒๅŒๆ—ถไฟ็•™็”Ÿๆˆ้…็ฝฎไธญ็š„ๅ…ถไป– Hook ๆก็›ฎใ€‚ ### JoyCode -JoyCode ๅทฒไฝœไธบๅ†…็ฝฎ็›ฎๆ ‡ๆ”ฏๆŒใ€‚Skillsใ€Rules ๅ’Œ Subagents ๅˆ†ๅˆซไธ‹ๅ‘ๅˆฐ `.joycode/skills/`ใ€`.joycode/rules/` ๅ’Œ `.joycode/agents/`ใ€‚Rules ไฝฟ็”จไธŽ Cursor ๅ…ผๅฎน็š„ `.mdc` ๆ ผๅผ๏ผŒๅŒ…ๆ‹ฌไธ‹ๆ–‡ๆ‰€่ฟฐ็š„ๆดพ็”Ÿ frontmatter ๅ’Œไป…ๆญฃๆ–‡ๅพ€่ฟ”ๅŒๆญฅ๏ผ›Subagents ไฝฟ็”จๅธฆ YAML frontmatter ็š„ Markdown ๆ–‡ไปถใ€‚ +JoyCode ๅทฒไฝœไธบๅ†…็ฝฎ็›ฎๆ ‡ๆ”ฏๆŒใ€‚Skillsใ€Rules ๅ’Œ Subagents ๅˆ†ๅˆซไธ‹ๅ‘ๅˆฐ `.joycode/skills/`ใ€`.joycode/rules/` ๅ’Œ `.joycode/agents/`ใ€‚Subagents ไฝฟ็”จๅธฆ YAML frontmatter ็š„ Markdown ๆ–‡ไปถใ€‚ + +Rules ๆ˜ฏ้‡‡็”จ JoyCode ่‡ชๆœ‰ๆธฒๆŸ“็š„ `.mdc` ๆ–‡ไปถใ€‚JoyCode ้€่กŒ่ฏปๅ– frontmatter๏ผŒ่€Œไธๆ˜ฏๆŒ‰ YAML ่งฃๆž๏ผšๅฎƒไผšไฟ็•™ Cursor ๆธฒๆŸ“็ป™ `globs` ๅŠ ็š„ๅผ•ๅท๏ผŒๅนถๆŒ‰ๆฏไธช้€—ๅทๆ‹†ๅˆ†ๅ–ๅ€ผ๏ผŒๅ› ๆญค Cursor ๅฝขๅผ็š„ๅธฆ `paths:` ็š„ rule ไปŽๆœช็”Ÿๆ•ˆใ€‚ๅธฆ `paths:` ็š„ rule ๅ†™ๆˆไธๅŠ ๅผ•ๅทใ€้€—ๅทๅˆ†้š”็š„ `globs:`๏ผŒๆฏไธช `{a,b}` ้€‰ๆ‹ฉ้กน้ƒฝๅฑ•ๅผ€ไธบๅ•็‹ฌ็š„ glob๏ผŒๅนถๅŠ ไธŠ `alwaysApply: false`๏ผ›ไธๅธฆ `paths` ็š„ rule ๅ†™ๆˆ `alwaysApply: true`ใ€‚`push` ๆ—ถๅชๆœ‰ Markdown ๆญฃๆ–‡ๅ›žๆต๏ผŒไธŽ Cursor ็›ธๅŒใ€‚ๆ—ง็‰ˆ teamai ไปฅ Cursor ๅฝขๅผๅ†™ๅ…ฅ็š„ๅ‰ฏๆœฌ๏ผŒ่‹ฅไปๆ˜ฏ teamai ๆ‰€ไธ‹ๅ‘็š„ๅ†…ๅฎน๏ผŒไผšๅœจไธ‹ไธ€ๆฌก `pull` ๆ—ถ้‡ๅ†™๏ผ›ไฝ ๆ”น่ฟ‡็š„ๅ‰ฏๆœฌไผšไฟ็•™ๅนถ่ขซ็‚นๅ๏ผ›ๅช่ฆๅ…ถ `globs` ไปๅธฆๅผ•ๅท๏ผŒ`pull` ไผšๆŒ‡ๅ‡บๅฎƒไธไฝœ็”จไบŽไปปไฝ•ๆ–‡ไปถ๏ผŒๅนถ่ฏดๆ˜Žๅฆ‚ไฝ•ไฟฎๆญฃใ€‚`doctor` ๅฐ†้กน็›ฎ `.joycode/rules/` ไธญ็š„ๆฏไปฝๅ‰ฏๆœฌไธŽ่ฏฅๆธฒๆŸ“ๆฏ”ๅฏนใ€‚ + +user scope ไธ‹ JoyCode ไธ่ฏปๅ– rules ็›ฎๅฝ•๏ผšๅ›ข้˜Ÿ rule ๆ˜ฏ `~/.joycode/rules.txt` ไธญ็š„ไธ€ไธชๅŒบๅ—๏ผŒไธๆŒ‰่ทฏๅพ„้™ๅฎšไฝœ็”จ่Œƒๅ›ดใ€‚pull ไผšๅˆ ้™คๆ—ง็‰ˆๆœฌ็•™ๅœจ `~/.joycode/rules/` ไธญๆœชไฟฎๆ”น็š„ `.mdc` ๅ‰ฏๆœฌ๏ผŒๅนถ็‚นๅไฝ ๆ”น่ฟ‡็š„ๅ‰ฏๆœฌใ€‚ JoyCode ่ง„ๅˆ™ๆธ…็†้‡‡็”จไฟๅฎˆ็ญ–็•ฅ๏ผšไธๅœจๅ›ข้˜Ÿ่ง„ๅˆ™ๅˆ—่กจไธญ็š„ๆœฌๅœฐ `.mdc` ๅ’Œ `.md` ๆ–‡ไปถไผš่ขซไฟ็•™๏ผŒๅชๆœ‰ๅ›ข้˜Ÿๆ˜Ž็กฎ่ฎฐๅฝ•ไบ†ๅˆ ้™คๆ ‡่ฎฐ๏ผˆtombstone๏ผ‰ๆ‰ไผšๆธ…็†ใ€‚่ฟ™่ƒฝไฟๆŠคๅŒไธ€็›ฎๅฝ•ไธญ็š„ไธชไบบ่ง„ๅˆ™๏ผ›็ผบๅฐ‘ๅˆ ้™ค่ฎฐๅฝ•็š„ๆ—งๅ›ข้˜Ÿๅ‰ฏๆœฌไนŸไผšไฟ็•™๏ผŒไธไผš็Œœๆต‹ๅ…ถๅทฒ่ฟ‡ๆœŸใ€‚ @@ -2177,7 +2201,7 @@ JoyCode ่ง„ๅˆ™ๆธ…็†้‡‡็”จไฟๅฎˆ็ญ–็•ฅ๏ผšไธๅœจๅ›ข้˜Ÿ่ง„ๅˆ™ๅˆ—่กจไธญ็š„ๆœฌๅœฐ `. Cursor ็š„ๅญไปฃ็†้ƒจ็ฝฒๅˆฐ `.cursor/agents/*.md`๏ผŒYAML frontmatter ๆบๅธฆ `agent_id`๏ผˆๅ›ข้˜Ÿไปฃ็†ๅ๏ผ‰ใ€`description`ใ€`tools`๏ผŒไปฅๅŠๅ›ข้˜Ÿไปฃ็†ๅฃฐๆ˜Žไบ†็š„ `model`๏ผŒๅค–ๅŠ ๆ‰€ๆœ‰ `tool_extras.cursor` ๅญ—ๆฎต๏ผ›`reverseFromCursor` ๆŒ‰ๅŒๆ ทๅญ—ๆฎต่ฏปๅ›ž๏ผŒๅ› ๆญค `pull` โ†’ `push` ๅพ€่ฟ”ไธไผšไธข modelใ€‚ -Cursor ็š„้กน็›ฎ่ง„ๅˆ™ๅฟ…้กปไปฅ **`.mdc`** ๆ–‡ไปถๅฝขๅผๆ”พๅœจ `.cursor/rules/` ไธ‹๏ผŒไธ”ๅธฆ YAML frontmatterโ€”โ€”ๆ”พๅœจ้‚ฃ้‡Œ็š„็บฏ `.md` ไผš่ขซ Cursor ็›ดๆŽฅๅฟฝ็•ฅใ€‚ๅ› ๆญค teamai ๅ‘ Cursor ๅ†™่ง„ๅˆ™ๆ—ถ็”จ `.mdc`๏ผˆๅ…ถไป–ๅทฅๅ…ทไปๅ†™็บฏ `.md`๏ผ‰๏ผŒๅนถไปŽๅ›ข้˜Ÿ่ง„ๅˆ™ๆดพ็”Ÿ frontmatter๏ผš +Cursor ็š„้กน็›ฎ่ง„ๅˆ™ๅฟ…้กปไปฅ **`.mdc`** ๆ–‡ไปถๅฝขๅผๆ”พๅœจ `.cursor/rules/` ไธ‹๏ผŒไธ”ๅธฆ YAML frontmatterโ€”โ€”ๆ”พๅœจ้‚ฃ้‡Œ็š„็บฏ `.md` ไผš่ขซ Cursor ็›ดๆŽฅๅฟฝ็•ฅใ€‚ๅ› ๆญค teamai ๅ‘ Cursor ๅ†™่ง„ๅˆ™ๆ—ถ็”จ `.mdc`๏ผˆJoyCodeใ€Copilotใ€Kiroใ€Qoderใ€Qoder CNใ€CodeBuddyใ€WorkBuddy ไธŽ Oh My Pi ๅ„ๆœ‰่‡ชๅทฑ็š„ๆ ผๅผ๏ผŒ่งๅ„่‡ชๅฐ่Š‚๏ผ›ๅ…ถไป–ๅทฅๅ…ทๅ†™็บฏ `.md`๏ผ‰๏ผŒๅนถไปŽๅ›ข้˜Ÿ่ง„ๅˆ™ๆดพ็”Ÿ frontmatter๏ผš - ๅธฆ `paths:` ๅˆ—่กจ็š„่ง„ๅˆ™ไผš่ฝฌๆˆ `globs: "<้€—ๅทๆ‹ผๆŽฅ>"` + `alwaysApply: false`๏ผˆไธŠไธ‹ๆ–‡ไธญๆœ‰ๅŒน้…ๆ–‡ไปถๆ—ถ Cursor ่‡ชๅŠจ้™„ๅŠ ่ฏฅ่ง„ๅˆ™๏ผ‰ใ€‚ๅ€ผๅŠ ๅผ•ๅทๆ˜ฏๅ› ไธบไปฅ `*` ๅผ€ๅคด็š„ glob ไธๅŠ ๅผ•ๅทๆ—ถๅนถ้žๅˆๆณ• YAMLใ€‚ - ๆ—  `paths` ็š„่ง„ๅˆ™๏ผˆๅ›ข้˜Ÿๅผบๅˆถ่ง„ๅˆ™๏ผ‰ไผš่ฝฌๆˆ `alwaysApply: true`๏ผˆๆฏไธช Cursor ไผš่ฏ้ƒฝๅบ”็”จ๏ผ‰ใ€‚ @@ -2213,9 +2237,13 @@ teamai remove rules --force # ่ทณ่ฟ‡็กฎ่ฎค๏ผŒ็”จไบŽ่„šๆœฌๅ’Œ CI ้™คไบ†ๆ‰˜็ฎกๅนณๅฐใ€cloneใ€้…็ฝฎๅ’Œ hook ๆฃ€ๆŸฅไน‹ๅค–๏ผŒ`doctor` ่ฟ˜ไผš้ชŒ่ฏ่ฝๅˆฐๆœฌๆœบไธŠ็š„ๅ†…ๅฎนใ€‚` is installed` ๅœจ `enabledAgents` ๅˆ—ๅ‡บไบ†ไธไผšๆ”ถๅˆฐไปปไฝ•ๅ†…ๅฎน็š„ๅทฅๅ…ทๆ—ถๅคฑ่ดฅโ€”โ€”่ฟ™ๆญฃๆ˜ฏ pull ๆŠฅๅ‘ŠๆˆๅŠŸใ€่€Œ่ฏฅๅทฅๅ…ทไป€ไนˆ้ƒฝๆฒกๆ”ถๅˆฐ็š„ๆƒ…ๅ†ตใ€‚ๅฎƒไฝฟ็”จไธŽๅŒๆญฅ็›ธๅŒ็š„่งฃๆž้€ป่พ‘๏ผŒๅ› ๆญคๅƒ OpenClaw ่ฟ™ๆ ทๆŠŠ skills ๆ”พๅœจ workspace ็›ฎๅฝ•่€Œ้žๅทฅๅ…ทๆ น็›ฎๅฝ•็š„ๅทฅๅ…ท๏ผŒไผšๅœจๅŒๆญฅ็œŸๆญฃๅ†™ๅ…ฅ็š„ไฝ็ฝฎ่ขซๅˆคๆ–ญใ€‚ๅทฅๅ…ทๅทฒๅฎ‰่ฃ…ๆ—ถไนŸไผšไฝœไธบ้€š่ฟ‡้กนๆŠฅๅ‘Š๏ผŒๅ› ๆญค `--json` ๆ— ่ฎบๅ“ช็งๆƒ…ๅ†ต้ƒฝไผšไธบๆฏไธชๅทฒๅฏ็”จๅทฅๅ…ท็ป™ๅ‡บไธ€ๆก่ฎฐๅฝ•ใ€‚pull ็ป“ๆŸๆ—ถ็š„ๆฃ€ๆŸฅๅช่ฆ†็›–ๅฎƒไปŽๅฝ“ๅ‰็›ฎๅฝ•่งฃๆžๅ‡บ็š„้‚ฃไธช scope๏ผ›ๅ…ถไป– scope ่ฏทๅœจๅฏนๅบ”็›ฎๅฝ•ไธ‹่ฟ่กŒ `teamai doctor`ใ€‚`Skills delivered to ` ไผšๆŠŠ่ง’่‰ฒๅ‘ฝๅ็ฉบ้—ดใ€ๆ ‡็ญพ่ฎข้˜…ไธŽๆŽ’้™ค่ง„ๅˆ™่งฃๆžๅ‡บ็š„ skill ้›†ๅˆ๏ผŒไธŽๆฏไธชๅทฒๅฎ‰่ฃ…ๅทฅๅ…ท็ฃ็›˜ไธŠ็š„ๅ†…ๅฎนๆฏ”ๅฏน๏ผšไปŽๆœช้€่พพ็š„ skill ไธŽ้€่พพไฝ†ไธๅฏ่ฏป็š„ skill ไผšๅˆ†ๅˆซๆŠฅๅ‘Šโ€”โ€”ๅŽ่€…ๆŒ‡ `SKILL.md` ็ผบๅคฑใ€frontmatter ๆ— ๆณ•่งฃๆž๏ผŒๆˆ–ๅ…ถ `name` ไธŽ็›ฎๅฝ•ๅไธไธ€่‡ด๏ผŒๅฏผ่‡ด agent ๆฐธ่ฟœๅ‘็Žฐไธไบ†ๅฎƒใ€‚`Team docs delivered` ๅฐ†ไฝ ๅบ”ๆ”ถๅˆฐ็š„ๆ–‡ๆกฃ๏ผˆไธๅซๆœชๆฟ€ๆดป็š„ docs namespace๏ผ‰ไธŽ `sharing.docs.localDir` ๆฏ”ๅฏน๏ผˆๅฎƒๅชๆœ‰ไธ€ไธช็›ฎๆ ‡็›ฎๅฝ•๏ผŒ่€Œ้žๆฏไธชๅทฅๅ…ทไธ€ไธช๏ผ‰๏ผ›ๆฏไธชๅบ”ๆœ‰็š„ๆ–‡ๆกฃ้ƒฝๅฟ…้กปๆ˜ฏๅฏ่ฏปๅ–็š„ๆ–‡ไปถ๏ผŒๅ› ๆญคๅ ็”จไบ†่ฏฅๅๅญ—็š„็›ฎๅฝ•ๆˆ–ๆ–ญ้“พๆŽฅไนŸ็ฎ—็ผบๅคฑใ€‚ๅฎƒ่ฟ˜ไผšๅฐ†ๆœฌๅœฐๅคšไฝ™็š„้ž้š่—ๆ–‡ไปถๆŠฅๅ‘Šไธบ่ฟ‡ๆœŸๆ–‡ๆกฃ๏ผŒๅณไฝฟๅ›ข้˜Ÿๆ–‡ๆกฃๅทฒ็ปๅˆ ็ฉบไนŸไผšๆฃ€ๆŸฅ๏ผ›ๆœฌๅœฐ้š่—ๆ–‡ไปถไผšไฟ็•™๏ผŒไธไผšไฝฟๆฃ€ๆŸฅๅคฑ่ดฅ๏ผŒๆœชๆฟ€ๆดป namespace ไธญๅ›ข้˜Ÿๆ–‡ๆกฃ็š„ๆœฌๅœฐๅ‰ฏๆœฌไนŸไธไผš๏ผšpull ไผšๅˆ ้™คๆœชไฟฎๆ”น็š„ๅ‰ฏๆœฌ๏ผŒๅนถ็‚นๅไฝ ไฟฎๆ”น่ฟ‡็š„ๅ‰ฏๆœฌใ€‚`doctor` ่ฟ˜ไผš่พ“ๅ‡บๆ็คบ๏ผŒๅฎƒไปฌๅชๆ˜ฏไฟกๆฏ๏ผŒไธๆ˜ฏๅคฑ่ดฅ็š„ๆฃ€ๆŸฅใ€‚ๆฏๆกๆ็คบๆŒ‡ๅ‡บไธ€ไธชๅœจๆœฌๆœบๆ›ฟๆขไบ†ๆ น็›ฎๅฝ•ๆก็›ฎ็š„ namespace skillใ€agentใ€ruleใ€ๅ…ฑไบซๆŒ‡ไปคๆ–‡ไปถใ€env ๅ˜้‡ใ€hookใ€MCP server ๆˆ–ๅ›ข้˜Ÿๆจกๅž‹้…็ฝฎ๏ผˆ`rules: "style" from rules/checkout/style.md replaces rules/style.md`๏ผ‰ใ€‚ๅฝ“ๆŸไธช namespace ๆไพ›ไบ† env ๅ˜้‡ใ€hookใ€MCP server ๆˆ–ๅ›ข้˜Ÿๆจกๅž‹้…็ฝฎๆ—ถ๏ผŒ่ฟ˜ไผšๆœ‰ไธ€ๆกๆ็คบๆŒ‰ๆฅๆบ็ปŸ่ฎก่ฏฅ็ฑปๅž‹็š„ๆก็›ฎ๏ผˆ`env: 3 received here (2 root, 1 checkout)`๏ผ‰ใ€‚ๆœช้…็ฝฎ่ง’่‰ฒๆˆ–้กน็›ฎๆ—ถ๏ผŒๆ็คบๆ”นไธบๅˆ—ๅ‡บๅ›ข้˜Ÿไป“ๅบ“ไธญ้‡ๅคๅฎšไน‰็š„ๆฏไธชๆ–‡ไปถ๏ผŒไปฅๅŠๅœจๆ นๆ–‡ไปถไธญ้‡ๅคๅ‡บ็Žฐ็š„ๆฏไธช env ๅ˜้‡ใ€hook ๆˆ– MCP server ๅ็งฐใ€‚ -`Rules delivered to ` ไธŽ `Agents delivered to ` ๅฏนๅฆๅค–ไธค็ฑปๆŒ‰ๅทฅๅ…ทไธ‹ๅ‘็š„่ต„ๆบๅšๅŒๆ ท็š„ไบ‹๏ผŒๅนถไธ”้ƒฝๅ‘ handler ่ฏข้—ฎ่ฝ็‚น๏ผŒ่€Œไธๆ˜ฏ่‡ช่กŒๆ‹ผ่ทฏๅพ„๏ผšrule ็š„ๆ–‡ไปถๅๅ’Œๅ†…ๅฎนๅ› ๅทฅๅ…ท่€Œๅผ‚๏ผˆ`.md` ๅŽŸๆ ทใ€`.mdc` ๅธฆๆดพ็”Ÿ็š„ `globs`/`alwaysApply`ใ€`.instructions.md` ๅธฆ `applyTo`๏ผ‰๏ผŒagent ็š„่ฝ็‚นๆฅ่‡ชๆธฒๆŸ“็ป“ๆžœ๏ผŒไธ”็”ฑ `targets:` ๅ†ณๅฎšๅ“ชไบ›ๅทฅๅ…ทๅบ”ๅฝ“ๆ”ถๅˆฐใ€‚ๅทฒ้€่พพ็š„ rule ไผšไธŽ handler ไธบ่ฏฅๅทฅๅ…ทๆธฒๆŸ“ๅ‡บ็š„ๅญ—่Š‚้€ไธ€ๆฏ”ๅฏน๏ผŒ่€Œไธๅชๆ˜ฏๆฃ€ๆŸฅ่ฏฅๅทฅๅ…ทๆ‰€้œ€็š„้”ฎๆ˜ฏๅฆๅญ˜ๅœจ๏ผš`globs` ไธŽๅ›ข้˜Ÿ rule ็š„ `paths:` ไธๅ†ไธ€่‡ด็š„ `.mdc`๏ผŒๅณไฝฟ `alwaysApply` ๅ–ๅ€ผๅˆๆณ•๏ผŒไนŸไผšไฝœ็”จๅˆฐ้”™่ฏฏ็š„ๆ–‡ไปถไธŠ๏ผ›่ฟ™้‡ŒไผšๆŠฅๅ‘Šไธบ `delivered from an older copy`โ€”โ€”ๆญฃๆ–‡ๆผ‚็งป็š„ๅ‰ฏๆœฌๅŒๆ ทๅฆ‚ๆญค๏ผŒๅ› ไธบไธค่€…้ƒฝๅ†™ๅ…ฅๆˆๅŠŸ๏ผŒๅด้ƒฝๆ˜ฏ้”™็š„ใ€‚agent ไผšไธŽๆธฒๆŸ“็ป“ๆžœ้€ๅญ—่Š‚ๆฏ”ๅฏน๏ผšๆ—ง็‰ˆ spec ็•™ไธ‹็š„ๅ‰ฏๆœฌ๏ผˆๆ™ฎ้€š pull ไผš่ทณ่ฟ‡ๅ›ข้˜Ÿไป“ๅบ“ๆœชๅ˜ๅŒ–็š„ scope๏ผŒๅฎƒๅฏ่ƒฝไธ€็›ด็•™ๅœจ้‚ฃ้‡Œ๏ผ‰ๆŠฅๅ‘Šไธบ `delivered from an older spec`๏ผŒ่€Œไธๆ˜ฏๅฝ“ไฝœๅทฒ้€่พพใ€‚`Every team agent reaches a tool` ไผšๆŒ‡ๅ‡บๅœจไปปไฝ•ๅทฒๅฎ‰่ฃ…ๅทฅๅ…ทไธŠ้ƒฝๆ— ๆณ•ๆธฒๆŸ“็š„ agent๏ผŒ้€šๅธธๆ˜ฏ spec ่งฃๆžๅคฑ่ดฅ๏ผŒๆˆ– `targets:` ๅชๅˆ—ไบ†ๆœฌๆœบๆฒกๆœ‰็š„ๅทฅๅ…ทใ€‚่ฟ™ไธค้กนไป…ๅœจ `doctor` ไธญ่ฟ่กŒ๏ผšๅฎƒไปฌไผšๆŒ‰ๅทฅๅ…ท่ฏปๅ–ๆฏๆก ruleใ€่งฃๆžๆฏไธช agent๏ผŒๆ”พ่ฟ› pull ็ป“ๆŸๆ—ถ็š„ๆฃ€ๆŸฅไผš่€—ๅฐฝๅ…ถๆ—ถ้—ด้ข„็ฎ—ใ€‚ +ๅฏนไบŽ Oh My Pi ๅ’Œ Kiro๏ผŒๅณไฝฟๆ‰€ๆœ‰ๆ”ถๅˆฐ็š„ rule ้ƒฝๅ‘็”Ÿๅนณ้“บๅ็งฐๅ†ฒ็ชใ€ๆ— ๆณ•ๅ†™ๅ…ฅไปปไฝ•ๆ–‡ไปถ๏ผŒ`doctor` ไนŸไผšๆŠฅๅ‘Šๅ†ฒ็ชใ€‚ๅœจๅ›ข้˜Ÿไป“ๅบ“ไธญ้‡ๅ‘ฝๅๅ…ถไธญไธ€ๆก rule๏ผŒ็„ถๅŽ่ฟ่กŒ `teamai pull`ใ€‚ + +`Rules delivered to ` ไธŽ `Agents delivered to ` ๅฏนๅฆๅค–ไธค็ฑปๆŒ‰ๅทฅๅ…ทไธ‹ๅ‘็š„่ต„ๆบๅšๅŒๆ ท็š„ไบ‹๏ผŒๅนถไธ”้ƒฝๅ‘ handler ่ฏข้—ฎ่ฝ็‚น๏ผŒ่€Œไธๆ˜ฏ่‡ช่กŒๆ‹ผ่ทฏๅพ„๏ผšrule ็š„ๆ–‡ไปถๅๅ’Œๅ†…ๅฎนๅ› ๅทฅๅ…ท่€Œๅผ‚๏ผˆ`.md` ๅŽŸๆ ทใ€`.mdc` ๅธฆๆดพ็”Ÿ็š„ `globs`/`alwaysApply`๏ผˆJoyCode ็š„ไธๅŠ ๅผ•ๅท๏ผ‰ใ€`.instructions.md` ๅธฆ `applyTo`ใ€Kiro ๅนณ้“บ็š„ `.md` ๅธฆ `inclusion`/`fileMatchPattern`ใ€Qoder ็š„ `.md` ๅธฆ `trigger`/`glob`ใ€Oh My Pi ๅนณ้“บ็š„ `.md` ๅธฆ `alwaysApply` ๆˆ– `globs`/`description`ใ€CodeBuddy ็š„ `.md` ๅธฆ `alwaysApply`/`paths`๏ผ›ๅ…ฑ็”จไธ€ไปฝๅ‰ฏๆœฌ็š„ๅทฅๅ…ท๏ผŒไพ‹ๅฆ‚้กน็›ฎไธญ็š„ CodeBuddy ไธŽ WorkBuddy๏ผŒๅชๆœ‰ไธ€้กนๅŒๆ—ถ็‚นๅไธค่€…็š„ๆฃ€ๆŸฅ๏ผ‰๏ผŒagent ็š„่ฝ็‚นๆฅ่‡ชๆธฒๆŸ“็ป“ๆžœ๏ผŒไธ”็”ฑ `targets:` ๅ†ณๅฎšๅ“ชไบ›ๅทฅๅ…ทๅบ”ๅฝ“ๆ”ถๅˆฐใ€‚ๅทฒ้€่พพ็š„ rule ไผšไธŽ handler ไธบ่ฏฅๅทฅๅ…ทๆธฒๆŸ“ๅ‡บ็š„ๅญ—่Š‚้€ไธ€ๆฏ”ๅฏน๏ผŒ่€Œไธๅชๆ˜ฏๆฃ€ๆŸฅ่ฏฅๅทฅๅ…ทๆ‰€้œ€็š„้”ฎๆ˜ฏๅฆๅญ˜ๅœจ๏ผš`globs` ไธŽๅ›ข้˜Ÿ rule ็š„ `paths:` ไธๅ†ไธ€่‡ด็š„ `.mdc`๏ผŒๅณไฝฟ `alwaysApply` ๅ–ๅ€ผๅˆๆณ•๏ผŒไนŸไผšไฝœ็”จๅˆฐ้”™่ฏฏ็š„ๆ–‡ไปถไธŠ๏ผ›่ฟ™้‡ŒไผšๆŠฅๅ‘Šไธบ `delivered from an older copy`โ€”โ€”ๆญฃๆ–‡ๆผ‚็งป็š„ๅ‰ฏๆœฌๅŒๆ ทๅฆ‚ๆญค๏ผŒๅ› ไธบไธค่€…้ƒฝๅ†™ๅ…ฅๆˆๅŠŸ๏ผŒๅด้ƒฝๆ˜ฏ้”™็š„ใ€‚agent ไผšไธŽๆธฒๆŸ“็ป“ๆžœ้€ๅญ—่Š‚ๆฏ”ๅฏน๏ผšๆ—ง็‰ˆ spec ็•™ไธ‹็š„ๅ‰ฏๆœฌ๏ผˆๆ™ฎ้€š pull ไผš่ทณ่ฟ‡ๅ›ข้˜Ÿไป“ๅบ“ๆœชๅ˜ๅŒ–็š„ scope๏ผŒๅฎƒๅฏ่ƒฝไธ€็›ด็•™ๅœจ้‚ฃ้‡Œ๏ผ‰ๆŠฅๅ‘Šไธบ `delivered from an older spec`๏ผŒ่€Œไธๆ˜ฏๅฝ“ไฝœๅทฒ้€่พพใ€‚`Every team agent reaches a tool` ไผšๆŒ‡ๅ‡บๅœจไปปไฝ•ๅทฒๅฎ‰่ฃ…ๅทฅๅ…ทไธŠ้ƒฝๆ— ๆณ•ๆธฒๆŸ“็š„ agent๏ผŒ้€šๅธธๆ˜ฏ spec ่งฃๆžๅคฑ่ดฅ๏ผŒๆˆ– `targets:` ๅชๅˆ—ไบ†ๆœฌๆœบๆฒกๆœ‰็š„ๅทฅๅ…ทใ€‚่ฟ™ไธค้กนไป…ๅœจ `doctor` ไธญ่ฟ่กŒ๏ผšๅฎƒไปฌไผšๆŒ‰ๅทฅๅ…ท่ฏปๅ–ๆฏๆก ruleใ€่งฃๆžๆฏไธช agent๏ผŒๆ”พ่ฟ› pull ็ป“ๆŸๆ—ถ็š„ๆฃ€ๆŸฅไผš่€—ๅฐฝๅ…ถๆ—ถ้—ด้ข„็ฎ—ใ€‚ + +ๅˆ ้™คๆœ€ๅŽไธ€ๆกๅ›ข้˜Ÿ rule ๅŽ๏ผŒ`doctor` ไปไผšๆŠฅๅ‘Šๆธ…็†ๅคฑ่ดฅ็•™ไธ‹็š„ teamai ๆ‰€ๆ‹ฅๆœ‰็š„ OpenCode glob ๆˆ–ๅ†…่”ๅŒบๅ—ใ€‚่ฟ่กŒ `teamai pull` ๅฏ็งป้™คๅฎƒไปฌใ€‚ -ๆœ‰ไธ‰ไธชๅทฅๅ…ทๅนถไธ่ฏปๅ– rules ็›ฎๅฝ•๏ผŒๆŒ‰ๆ–‡ไปถๆฏ”ๅฏน็š„ๆฃ€ๆŸฅๆ— ๆณ•ไปฃ่กจๅฎƒไปฌ๏ผŒๅ› ๆญคๅ„่‡ชๅ•ๅˆ—ไธ€้กนใ€‚`Team rules are active in opencode` ๆฃ€ๆŸฅ `opencode.json` ็š„ `instructions` ไธญๆ˜ฏๅฆไปๅˆ—็€ teamai ๆ‰€ๆ‹ฅๆœ‰็š„้‚ฃๆก glob๏ผšOpenCode ไธไผš่‡ชๅŠจๆ‰ซๆ `.opencode/rules`๏ผŒ็ผบไบ†ๅฎƒ๏ผŒๅทฒ้€่พพ็š„ๆฏไธช `.md` ้ƒฝไธไผš็”Ÿๆ•ˆ๏ผŒ่€ŒๆŒ‰ๆ–‡ไปถๆฏ”ๅฏน็š„ๆฃ€ๆŸฅไพๆ—ง้€š่ฟ‡ใ€‚`Team rules are inlined in Hermes SOUL.md` ๆŠŠ `SOUL.md` ไธญ teamai ็ฎก็†็š„ไปฃ็ ๅ—ไธŽๅ›ข้˜Ÿ rule ๅ†…่”ๅŽ็š„ๅ†…ๅฎนๆฏ”ๅฏนโ€”โ€”Hermes ็š„ๅธธ้ฉปๆŒ‡ไปคๆฅ่‡ช่ฟ™ไธ€ไธชๆ–‡ไปถ่€Œ้žๆŸไธช็›ฎๅฝ•๏ผŒๅ› ๆญคไปฃ็ ๅ—่ขซๅˆ ้™คๆˆ–ๅœ็•™ๅœจๆ—ง็‰ˆ่ง„ๅˆ™้›†ไธŠ๏ผŒ้ƒฝๆ„ๅ‘ณ็€่ฏฅๅทฅๅ…ท่ฏปๅˆฐ็š„ๆ˜ฏ้”™่ฏฏ็š„่ง„ๅˆ™๏ผŒ่€Œ็ฃ็›˜ไธŠ็œ‹ไธๅ‡บไปปไฝ•ๅผ‚ๅธธใ€‚user scope ไธ‹๏ผŒ`Team rules are inlined in Codex AGENTS.md` ๆŠŠ่ฏฅๅทฅๅ…ท `AGENTS.md` ไธญ็š„ team-rules ๅŒบๅ—ไธŽๅ›ข้˜Ÿ rule ๅ†…่”ๅŽ็š„ๅ†…ๅฎนๆฏ”ๅฏน๏ผŒๆ—่พนๆœ‰ `AGENTS.override.md` ้ฎ่”ฝ่ฏฅๆ–‡ไปถๆ—ถไนŸไผšๅคฑ่ดฅใ€‚ๅœจ้กน็›ฎไธญ๏ผŒๅฝ“่ฏฅๅทฅๅ…ท `hooks.json` ไธญ็š„ teamai `SessionStart` ๆˆ– `SubagentStart` ๆก็›ฎ็ผบๅคฑๆˆ–ๆฒกๆœ‰่ฎพ็ฝฎ `additionalContextLimit: 0` ๆ—ถ๏ผŒ`Project rules and instructions reach whole through its session hooks` ไผšๅคฑ่ดฅ๏ผšๆฒกๆœ‰ๅฎƒ๏ผŒCodex ๅฏน่พƒๅคง็š„ๅ†…ๅฎนๅชไฟ็•™ๅผ€ๅคดๅ’Œ็ป“ๅฐพใ€‚Codex ๆฒกๆœ‰ `Rules delivered to ` ๆฃ€ๆŸฅใ€‚ +ๆœ‰ๅ‡ ไธชๅทฅๅ…ทๅนถไธ่ฏปๅ– rules ็›ฎๅฝ•๏ผŒๆŒ‰ๆ–‡ไปถๆฏ”ๅฏน็š„ๆฃ€ๆŸฅๆ— ๆณ•ไปฃ่กจๅฎƒไปฌ๏ผŒๅ› ๆญคๅ„่‡ชๅ•ๅˆ—ไธ€้กนใ€‚`Team rules are active in opencode` ๆฃ€ๆŸฅ `opencode.json`๏ผˆ้กน็›ฎไธญไธบ `.opencode/opencode.json`๏ผ‰็š„ `instructions` ไธญๆ˜ฏๅฆๅˆ—็€ teamai ๆ‰€ๆ‹ฅๆœ‰็š„ๆฏๆก glob๏ผŒไธ”ๆฒกๆœ‰่ฟ‡ๆ—ถ็š„ๆก็›ฎ๏ผˆๅฆ‚ๆ—ง็‰ˆๆœฌๅ†™ๅ…ฅ็š„็›ธๅฏน `rules/*.md`๏ผ‰๏ผšOpenCode ไธไผš่‡ชๅŠจๆ‰ซๆ `.opencode/rules`๏ผŒ็ผบไบ†ๅฎƒ๏ผŒๅทฒ้€่พพ็š„ๆฏไธช `.md` ้ƒฝไธไผš็”Ÿๆ•ˆ๏ผŒ่€ŒๆŒ‰ๆ–‡ไปถๆฏ”ๅฏน็š„ๆฃ€ๆŸฅไพๆ—ง้€š่ฟ‡ใ€‚user scope ไธ‹๏ผŒ`Team rules are inlined in Hermes SOUL.md` ๆŠŠ `SOUL.md` ไธญ teamai ็ฎก็†็š„ไปฃ็ ๅ—ไธŽๅ›ข้˜Ÿ rule ๅ†…่”ๅŽ็š„ๅ†…ๅฎนๆฏ”ๅฏนโ€”โ€”Hermes ็š„ๅธธ้ฉปๆŒ‡ไปคๆฅ่‡ช่ฟ™ไธ€ไธชๆ–‡ไปถ่€Œ้žๆŸไธช็›ฎๅฝ•๏ผŒๅ› ๆญคไปฃ็ ๅ—่ขซๅˆ ้™คๆˆ–ๅœ็•™ๅœจๆ—ง็‰ˆ่ง„ๅˆ™้›†ไธŠ๏ผŒ้ƒฝๆ„ๅ‘ณ็€่ฏฅๅทฅๅ…ท่ฏปๅˆฐ็š„ๆ˜ฏ้”™่ฏฏ็š„่ง„ๅˆ™๏ผŒ่€Œ็ฃ็›˜ไธŠ็œ‹ไธๅ‡บไปปไฝ•ๅผ‚ๅธธใ€‚user scope ไธ‹๏ผŒ`Team rules are inlined in `๏ผˆ`Codex AGENTS.md`ใ€`ZCode AGENTS.md`ใ€`DeepSeek Harness AGENTS.md`ใ€`OpenClaw workspace AGENTS.md`ใ€`Pi AGENTS.md`ใ€`JoyCode rules.txt`๏ผ‰ๆŠŠ่ฏฅๅทฅๅ…ทๆ‰€่ฏปๆ–‡ไปถไธญ็š„ team-rules ๅŒบๅ—ไธŽๅ›ข้˜Ÿ rule ๅ†…่”ๅŽ็š„ๅ†…ๅฎนๆฏ”ๅฏน๏ผ›ๅฏน Codex๏ผŒๆ—่พนๆœ‰ `AGENTS.override.md` ้ฎ่”ฝ่ฏฅๆ–‡ไปถๆ—ถไนŸไผšๅคฑ่ดฅใ€‚ๅœจ้กน็›ฎไธญ๏ผŒๅฝ“่ฏฅๅทฅๅ…ท `hooks.json` ไธญ็š„ teamai `SessionStart` ๆˆ– `SubagentStart` ๆก็›ฎ็ผบๅคฑๆˆ–ๆฒกๆœ‰่ฎพ็ฝฎ `additionalContextLimit: 0` ๆ—ถ๏ผŒ`Project rules and instructions reach whole through its session hooks` ไผšๅคฑ่ดฅ๏ผšๆฒกๆœ‰ๅฎƒ๏ผŒCodex ๅฏน่พƒๅคง็š„ๅ†…ๅฎนๅชไฟ็•™ๅผ€ๅคดๅ’Œ็ป“ๅฐพใ€‚ๅœจ้กน็›ฎไธญ๏ผŒๅฝ“ `~/.zcode/cli/config.json` ๆฒกๆœ‰ teamai ็š„ `SessionStart` ๆก็›ฎๆˆ–ๆฒกๆœ‰่ฎพ็ฝฎ `hooks.enabled: true` ๆ—ถ๏ผŒ`Project rules reach zcode through its SessionStart hook` ไผšๅคฑ่ดฅ๏ผ›ๅฝ“ `~/.teamai/dsh/` ไธ‹็š„ patch ๆˆ– hook ้…็ฝฎ็ผบๅคฑๆ—ถ๏ผŒ`Project rules reach dsh through its session-start hook` ไผšๅคฑ่ดฅ๏ผŒdoctor ๆ— ๆณ•็œ‹ๅˆฐ dsh ๆ˜ฏๅฆๅธฆ `--patch` ่ฟ่กŒใ€‚ๅฏน Pi๏ผŒteamai ็š„ Pi ๆ‰ฉๅฑ•็ผบๅคฑๆˆ–่ฟ‡ๆœŸๆ—ถ๏ผŒ`pi adds the team instructions and rules to its prompt` ไผšๅคฑ่ดฅใ€‚Codexใ€ZCodeใ€DeepSeek Harness ๅ’Œ Pi ๆฒกๆœ‰ `Rules delivered to ` ๆฃ€ๆŸฅใ€‚ `MCP servers delivered to ` ๅฐ†ๅ›ข้˜Ÿ `mcp.yaml` ไธบ่ฏฅๅทฅๅ…ท่งฃๆžๅ‡บ็š„ๆฏไธช server ไธŽ่ฏฅๅทฅๅ…ท่‡ชๅทฑ้…็ฝฎๆ–‡ไปถไธญ็š„ๆก็›ฎ้€ไธ€ๆฏ”ๅฏน๏ผŒๅนถๅˆ—ๅ‡บ reconcile ่ทณ่ฟ‡็š„ server ๅŠๅŽŸๅ› ใ€‚ๆฏ”ๅฏน็š„ๆ˜ฏๆก็›ฎๅ†…ๅฎน่€Œ้žๅๅญ—๏ผšreconcile ไธไผš่ฆ†็›–ไธๅฑžไบŽ teamai ็š„ๆก็›ฎ๏ผŒๅ› ๆญคไฝ ่‡ชๅทฑๅ†™็š„ๅŒๅ server ไผšๅ ไฝ่ฟ™ไธชๅๅญ—๏ผŒๅ›ข้˜Ÿ็š„ๅฎšไน‰ไปŽๆœช็œŸๆญฃ้€่พพ๏ผ›่ฟ‡ๆœŸ็š„ๆ—งๅ‰ฏๆœฌๅŒๆ ท็ญ‰ไบŽๆฒก้€่พพใ€‚ไธค่€…้ƒฝๆŠฅๅ‘Šไธบ `not the team's definition`๏ผŒ่€Œ่ฆ†็›–้ž teamai ๅ†™ๅ…ฅ็š„ๆก็›ฎๅชๆœ‰ `teamai pull --force` ่ƒฝๅšๅˆฐใ€‚ๆœช่งฃๆž็š„ `${VAR}` ไผšๅœจ่ฟ™้‡Œ่ฟžๅŒๅ˜้‡ๅไธ€่ตทๆŠฅๅ‘Šโ€”โ€”ๅฆๅˆ™ๅฎƒๅชๅœจ pull ๆ—ถๅ‡บ็Žฐไธ€ๆฌก๏ผŒไน‹ๅŽๅ†ๆ— ๆ็คบใ€‚ๆฒกๆœ‰ๅ€ผ็š„ๅทฒๅฃฐๆ˜Žๅฏ†้’ฅไธ็ฎ—ๅคฑ่ดฅ๏ผšdoctor ๆŠŠๅฎƒไฝœไธบๅค‡ๆณจๆ‰“ๅฐ๏ผˆ`--json` ไธญ็š„ `notes`๏ผ‰๏ผŒๅนถ้™„ไธŠ่ฎพ็ฝฎๅฎƒ็š„ๅ‘ฝไปค๏ผŒ้€€ๅ‡บ็ ไธŽๆฒกๆœ‰ๅฎƒๆ—ถ็›ธๅŒ๏ผ›ๅค‡ๆณจ่ฟ˜ไผš่ฏดๆ˜Žไธบๅฎƒไฟ็•™็š„ๆก็›ฎๅฏ่ƒฝๅซๆœ‰ๆ—งๅ€ผ๏ผŒไปฅๅŠๆŸไธช key ๆ—ขๅฃฐๆ˜Žไธบๅฏ†้’ฅใ€ๅˆๅœจ `env.yaml` ไธญ่ฎพ็ฝฎ็š„ๆƒ…ๅ†ตใ€‚ๆ— ๆณ•่งฃๆž็š„ `mcp.yaml` ๅนถไธ็ญ‰ไบŽๅ›ข้˜Ÿๆฒกๆœ‰ MCP๏ผšๅฎƒไผšไฝœไธบ `Team MCP servers can be read` ่ฟžๅŒ่งฃๆž้”™่ฏฏไธ€่ตทๆŠฅๅ‘Š๏ผŒๅ› ไธบ่ฟ™็งๆ–‡ไปถไธไผšๅ‘ไปปไฝ•ๅทฅๅ…ทๆณจๅ…ฅๅ†…ๅฎน๏ผŒ่€Œไธ”้™ค็ฌฌไธ€ๆฌกไน‹ๅค–็š„ๆฏๆฌก่ฟ่กŒ้ƒฝๅฏนๆญคไฟๆŒๆฒ‰้ป˜ใ€‚ๆ— ๆณ•่งฃๆž็š„ๅ›ข้˜Ÿ hooks ไธŽๅ›ข้˜Ÿๆจกๅž‹้…็ฝฎ๏ผˆๆ–‡ไปถๆ— ๆณ•่งฃๆžใ€ๅŒไธ€ๆ–‡ไปถๅ†…้‡ๅค็š„ๅๅญ—๏ผŒๆˆ–ไธคไธชๆดปๅŠจ namespace ไธญ็š„ๅŒๅๆก็›ฎ๏ผ‰ไผš่ฎฉ `Team hooks can be resolved` ไธŽ `Team model profiles can be resolved` ๅคฑ่ดฅ๏ผŒๅนถ็ป™ๅ‡บ pull ๅช่ฎฐๅฝ•ไธ€ๆฌก็š„ๅŽŸๅ› ๏ผ›`teamai status` ๆŠŠๅฎƒไปฌ่ฎกไธบ 0 ๆ—ถไผšๆŒ‡ๅ‘่ฟ™้‡Œใ€‚`Env variables injected in shell profile` ไธๅ†ๅชๆŸฅๆ ‡่ฎฐๆณจ้‡Š๏ผšๅฎƒไผšๆฃ€ๆŸฅ `env/env.yaml` ่ƒฝๅฆ่งฃๆžใ€ไปฅๅŠๆ˜ฏๅฆๅœจ `variables:` ้”ฎไธ‹ๅฃฐๆ˜Žไบ†ๅ˜้‡๏ผˆๅ†™ๆˆๆ™ฎ้€š็š„ `KEY: value` ๆ˜ ๅฐ„็ญ‰ไบŽๆฒกๆœ‰ๅฃฐๆ˜Ž๏ผ›่€Œๆ˜พๅผๅ†™ๆˆ `variables: []` ๅฑžไบŽๆฒกๆœ‰ๅ†…ๅฎน่ฆไธ‹ๅ‘็š„้…็ฝฎ๏ผŒไธไผšๅˆคไธบๅคฑ่ดฅ๏ผ‰ใ€ๆฏไธชๅ˜้‡ๆ˜ฏๅฆไปฅ `env.yaml` ๅฃฐๆ˜Ž็š„ๅ€ผ๏ผˆๆˆ–ไฝ ไธบ่ฏฅๅ›ข้˜Ÿ่ฎพ็ฝฎ็š„ๅ€ผ๏ผ›็”จ `--from-env` ่ฎพ็ฝฎ็š„ไธไผšๅ†™ๅ…ฅ๏ผ‰ๅ†™่ฟ›ไบ† `env.sh`๏ผˆๆฎ‹็•™็š„ๆ—งๅ€ผไผšไธ€็›ด่ขซๅฏผๅ‡บๅˆฐๆฏไธช shell ๅ’Œ MCP server๏ผŒ็›ดๅˆฐไธ‹ๆฌก pull๏ผ›ๆฏ”ๅฏนๆ—ถไผš็”จ็”Ÿๆˆๅ™จ่‡ช่บซ็š„้€†่ฟ็ฎ—่ฏปๅ›ž `env.sh`๏ผŒๅ› ๆญค่ทจๅคš่กŒๅผ•็”จ็š„ๅคš่กŒๅ€ผ่ƒฝๅคŸๆญฃ็กฎๅŒน้…๏ผŒ่€Œไธไผš่ขซ่ฏฏๅˆคไธบ่ฟ‡ๆœŸ๏ผ‰๏ผŒไปฅๅŠๆœฌไฝœ็”จๅŸŸๆณจๅ…ฅ็š„ไปฃ็ ๅ—๏ผˆๅณ source ๆœฌไฝœ็”จๅŸŸ `env.sh` ็š„้‚ฃไธ€ๅ—๏ผŒๅ› ไธบๅŒไธ€ไธช profile ้‡Œ่ฟ˜ๅฏ่ƒฝๆœ‰ๅ…ถไป–ไฝœ็”จๅŸŸ็š„ไปฃ็ ๅ—๏ผ‰ๆ˜ฏๅฆ็œŸ็š„่ƒฝๅŠ ่ฝฝๅฎƒโ€”โ€”ๆœชๅŠ ๅผ•ๅท็š„ Windows ่ทฏๅพ„ๅœจ POSIX shell ไธญไผš่ขซ่ฝฌไน‰็ ดๅ๏ผŒ`source` ไปŽไธๆ‰ง่กŒ๏ผŒ่€Œไธ”ๆฒกๆœ‰ไปปไฝ•ๆ็คบใ€‚`No stale env blocks left behind` ๆ˜ฏ็‹ฌ็ซ‹็š„ไธ€้กนๆฃ€ๆŸฅ๏ผšpull ไผ˜ๅ…ˆ้€‰็”จๅ“ชไธชๆ–‡ไปถไผš้šๆ—ถ้—ดๅ˜ๅŒ–๏ผˆWindows ไธŠ Git Bash ็š„็™ปๅฝ• shell ่ฏปๅ–็š„ๆ˜ฏ `.bash_profile`/`.bash_login`/`.profile`๏ผŒไปŽไธ่ฏปๅ– `.bashrc`๏ผ‰๏ผŒ่€Œ pull ๅชไผšๆ–ฐๅขžไปฃ็ ๅ—๏ผŒไปŽไธ่ฟ็งปๆ—ง็š„๏ผŒๅ› ๆญคๆ—ฉๆœŸๅฎ‰่ฃ…ๆˆ–ๅนณๅฐๅ˜ๅŒ–็•™ไธ‹็š„ๅคฑๆ•ˆไปฃ็ ๅ—ๅฏ่ƒฝไธ€็›ด็•™ๅœจๅฆไธ€ไธชๅ€™้€‰ๆ–‡ไปถ้‡Œใ€‚ๅฎƒไผšๅˆ—ๅ‡บๆฏไธ€ไธช่ฟ™ๆ ท็š„ๆ–‡ไปถ๏ผˆๆฃ€ๆŸฅ `.zshrc`ใ€`.bashrc`ใ€`.bash_profile`ใ€`.bash_login` ๅ’Œ `.profile`๏ผŒๆ–ฐๆ—งๅ†™ๆณ•้ƒฝ็ฎ—๏ผ‰๏ผŒๅนถๆŒ‡ๅ‘ `teamai uninstall` ๆฅๆธ…้™คๅฎƒไปฌโ€”โ€”่ฟ™ไธŽๆŠ•้€’ๆฃ€ๆŸฅๅˆ†ๅผ€่ฟ›่กŒ๏ผŒๅ› ๆญคไธไผšๅ› ไธบ่ฟ˜็•™็€ไธ€ไธชๆ—งๅ‰ฏๆœฌ๏ผŒๅฐฑ่ฎฉไธ€ไธชๆญฃๅธธๅทฅไฝœ็š„ env ไปฃ็ ๅ—่ขซๅˆคๆˆๆ•…้šœใ€‚ @@ -2722,9 +2750,10 @@ teamai uninstall --agent claude ็งป้™คๅ†…ๅฎน๏ผš - ๅฆ‚ๆžœ ownership ไปๆœ‰ๆ•ˆ๏ผŒๅ…ˆๆขๅค TeamAI ็ฎก็†็š„ๆจกๅž‹้…็ฝฎ - AI ๅทฅๅ…ท settings ไธญ็š„ teamai hooks -- ๅ„ๅทฅๅ…ทๆŒ‡ไปคๆ–‡ไปถไธญ็š„ teamai ๅ—๏ผˆๆ–‡ๅŒ–ใ€ๅ…ฑไบซๆŒ‡ไปคใ€recall ไปฅๅŠ Codex ็š„ๅ›ข้˜Ÿ่ง„ๅˆ™๏ผ‰๏ผŒไปฅๅŠๆ—ฉๆœŸ็‰ˆๆœฌๅ†™่ฟ‡่ฟ™ไบ›ๅ—็š„ๆ–‡ไปถ๏ผˆไฟ็•™็”จๆˆท่‡ชๅ†™ๅ†…ๅฎน๏ผ›teamai ๅ†™ๅ…ฅ็š„ `teamai-context` ๆ–‡ไปถๆ•ดไฝ“ๅˆ ้™ค๏ผŒ่‹ฅ OpenCode ไธญๅฏนๅบ”็š„ `instructions` ๆก็›ฎ็”ฑ teamai ๆทปๅŠ ๏ผŒๅˆ™ไธ€ๅนถ็งป้™ค๏ผŒๅณไฝฟไฝ ่‡ชๅ†™็š„ๅ†…ๅฎน่ฎฉ่ฏฅๆ–‡ไปถไฟ็•™ไธ‹ๆฅ๏ผŒๆˆ–่ฏฅๆ–‡ไปถๅทฒไธๅญ˜ๅœจ๏ผ›ไฝ ่‡ชๅทฑๅˆ—ๅ…ฅ็š„ๆก็›ฎไบˆไปฅไฟ็•™๏ผ‰ +- ๅ„ๅทฅๅ…ทๆŒ‡ไปคๆ–‡ไปถไธญ็š„ teamai ๅ—๏ผˆๆ–‡ๅŒ–ใ€ๅ…ฑไบซๆŒ‡ไปคใ€recall๏ผ‰๏ผŒไปฅๅŠๆ—ฉๆœŸ็‰ˆๆœฌๅ†™่ฟ‡่ฟ™ไบ›ๅ—็š„ๆ–‡ไปถ๏ผˆไฟ็•™็”จๆˆท่‡ชๅ†™ๅ†…ๅฎน๏ผ›teamai ๅ†™ๅ…ฅ็š„ `teamai-context` ๆ–‡ไปถๆ•ดไฝ“ๅˆ ้™ค๏ผŒ่‹ฅ OpenCode ไธญๅฏนๅบ”็š„ `instructions` ๆก็›ฎ็”ฑ teamai ๆทปๅŠ ๏ผŒๅˆ™ไธ€ๅนถ็งป้™ค๏ผŒๅณไฝฟไฝ ่‡ชๅ†™็š„ๅ†…ๅฎน่ฎฉ่ฏฅๆ–‡ไปถไฟ็•™ไธ‹ๆฅ๏ผŒๆˆ–่ฏฅๆ–‡ไปถๅทฒไธๅญ˜ๅœจ๏ผ›ไฝ ่‡ชๅทฑๅˆ—ๅ…ฅ็š„ๆก็›ฎไบˆไปฅไฟ็•™๏ผ‰ +- ๆฒกๆœ‰่‡ช่บซ rules ๆ ผๅผ็š„ๅทฅๅ…ทๅœจ็”จๆˆทไฝœ็”จๅŸŸ่ฏปๅ–็š„ๆ–‡ไปถไธญ็š„ๅ›ข้˜Ÿ่ง„ๅˆ™ๅ—๏ผˆ`~/.codex/AGENTS.md`ใ€`~/.zcode/AGENTS.md`ใ€`$DSH_HOME/AGENTS.md`ใ€OpenClaw workspace ็š„ `AGENTS.md`ใ€`~/.pi/agent/AGENTS.md`ใ€`~/.joycode/rules.txt`๏ผ‰๏ผ›่ฏฅๆ–‡ไปถ่‹ฅๆ˜ฏ teamai ๅชไธบ่ฟ™ไธชๅ—ๅˆ›ๅปบ็š„๏ผŒๅˆ™ๆ•ดไธชๅˆ ้™ค - ๅ›ข้˜ŸๅŒๆญฅ็š„ skills๏ผŒๅŒ…ๆ‹ฌ OpenClaw workspace skills๏ผˆไฟ็•™็”จๆˆท่‡ชๅปบ skills๏ผ‰ -- ๅ›ข้˜ŸๅŒๆญฅ็š„ rules๏ผŒๅŒ…ๆ‹ฌๆ—ง็‰ˆๆœฌ็•™ๅœจ `.codex/rules/` ไธญ็š„ๅ‰ฏๆœฌ๏ผŒๅ›ข้˜ŸๆญคๅŽๅทฒๅˆ ้™ค็š„ rule ็š„ๅ‰ฏๆœฌไนŸๅŒ…ๆ‹ฌๅœจๅ†…ใ€‚ๆธ…็†ไฝฟ็”จ่ฎฐๅฝ•็š„ `toolRoots` ไฝ็ฝฎๅ’Œๅ‘ๅธƒ่€…ๆœฌๅœฐ็š„ๆ–‡ไปถๅใ€‚ๅ…ถไธญไฝ ๆ”น่ฟ‡็š„ๅ‰ฏๆœฌไผšไฟ็•™๏ผŒๅนถๅœจ่ญฆๅ‘Šไธญ็‚นๅใ€‚ๅทฒๅˆ ้™ค rule ็š„ๅ‰ฏๆœฌๅชๆœ‰ไธŽ่ฎฐๅฝ•็š„ๆŠ•้€’ๅ“ˆๅธŒไธ€่‡ดๆ—ถๆ‰ไผšๅˆ ้™ค๏ผ›ๆฒกๆœ‰่ฏฅ่ฎฐๅฝ•ๆ—ถไนŸไผšไฟ็•™ๅนถ็‚นๅใ€‚Codex ็š„ `*.rules` ๆ–‡ไปถไฟ็•™ +- ๅ›ข้˜ŸๅŒๆญฅ็š„ rules๏ผŒๅŒ…ๆ‹ฌๆ—ง็‰ˆๆœฌ็•™ๅœจ `.codex/rules/`ใ€้กน็›ฎ็š„ `.workbuddy/rules/` ไธŽ `.pi/rules/`ใ€`.openclaw/rules/`ใ€`~/.pi/agent/rules/` ๅ’Œ `~/.joycode/rules/` ไธญ็š„ๅ‰ฏๆœฌ๏ผŒๅ›ข้˜ŸๆญคๅŽๅทฒๅˆ ้™ค็š„ rule ็š„ๅ‰ฏๆœฌไนŸๅŒ…ๆ‹ฌๅœจๅ†…ใ€‚้กน็›ฎ `.codebuddy/rules/` ไธญ็š„ๅ‰ฏๆœฌ๏ผŒๅช่ฆ CodeBuddy ไธŽ WorkBuddy ไธญ็š„ๅฆไธ€ไธชไปๅทฒๅฎ‰่ฃ…ๅฐฑไผšไฟ็•™ใ€‚ๆธ…็†ไฝฟ็”จ่ฎฐๅฝ•็š„ `toolRoots` ไฝ็ฝฎๅ’Œๅ‘ๅธƒ่€…ๆœฌๅœฐ็š„ๆ–‡ไปถๅใ€‚ๅ…ถไธญไฝ ๆ”น่ฟ‡็š„ๅ‰ฏๆœฌไผšไฟ็•™๏ผŒๅนถๅœจ่ญฆๅ‘Šไธญ็‚นๅใ€‚ๅทฒๅˆ ้™ค rule ็š„ๅ‰ฏๆœฌๅชๆœ‰ไธŽ่ฎฐๅฝ•็š„ๆŠ•้€’ๅ“ˆๅธŒไธ€่‡ดๆ—ถๆ‰ไผšๅˆ ้™ค๏ผ›ๆฒกๆœ‰่ฏฅ่ฎฐๅฝ•ๆ—ถไนŸไผšไฟ็•™ๅนถ็‚นๅใ€‚Codex ็š„ `*.rules` ๆ–‡ไปถไฟ็•™ - ๅ›ข้˜ŸๅŒๆญฅ็š„่‡ชๅฎšไน‰ agents ๅ’Œ CLI ๅ†…็ฝฎ agents๏ผˆไฟ็•™็”จๆˆท่‡ชๅปบ agents๏ผ‰ - Shell profile ไธญ็š„ env ๅ—โ€”โ€”ไผšๆธ…็†ๆฏไธ€ไธชๅ€™้€‰ๆ–‡ไปถ๏ผˆ`.zshrc`ใ€`.bashrc`ใ€`.bash_profile`ใ€`.bash_login`ใ€`.profile`๏ผ‰ไธญใ€ไปฃ็ ๅ—ๆŒ‡ๅ‘ๆœฌไฝœ็”จๅŸŸ่‡ช่บซ `env.sh` ็š„้‚ฃไบ›๏ผŒ่€Œไธไป…ไป…ๆ˜ฏๅฝ“ๅ‰ `pull` ไผš้€‰ไธญ็š„้‚ฃไธ€ไธช๏ผ›ๆŒ‡ๅ‘ๅ…ถไป–ไฝœ็”จๅŸŸ `env.sh` ็š„ไปฃ็ ๅ—ไธๅ—ๅฝฑๅ“ - ้กน็›ฎไธญ teamai ็š„ git hook๏ผšไป“ๅบ“ git ้…็ฝฎไธญ็š„ `hook.teamai-post-checkout` ไธŽ `hook.teamai-post-merge` ๆก็›ฎ๏ผŒไปฅๅŠ `.git/hooks/post-checkout` ไธŽ `post-merge` ไธญๅธฆๆ ‡่ฎฐ็š„ไปฃ็ ๅ—๏ผˆ็งป้™คๅŽๅชๅ‰ฉ shebang ็š„่„šๆœฌๆ˜ฏ teamai ๅˆ›ๅปบ็š„๏ผŒไผš่ขซๅˆ ้™ค๏ผ‰ใ€‚ๅ…ถไป– hook ไฟ็•™ diff --git a/package-lock.json b/package-lock.json index 1d10995a2..9cb4c9d2e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -34,6 +34,7 @@ "@types/node": "^20.17.0", "@types/semver": "^7.8.0", "@vitest/coverage-v8": "^3.2.7", + "esbuild": "^0.27.3", "fast-check": "^4.10.2", "opencode-ai": "1.18.23", "oxlint": "1.85.0", @@ -2638,10 +2639,11 @@ }, "node_modules/esbuild": { "version": "0.27.3", - "resolved": "https://mirrors.tencent.com/npm/esbuild/-/esbuild-0.27.3.tgz", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz", "integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==", "dev": true, "hasInstallScript": true, + "license": "MIT", "bin": { "esbuild": "bin/esbuild" }, diff --git a/package.json b/package.json index ca543f24d..52a6cf4c4 100644 --- a/package.json +++ b/package.json @@ -81,6 +81,7 @@ "@types/node": "^20.17.0", "@types/semver": "^7.8.0", "@vitest/coverage-v8": "^3.2.7", + "esbuild": "^0.27.3", "fast-check": "^4.10.2", "opencode-ai": "1.18.23", "oxlint": "1.85.0", diff --git a/skill-data/core/references/contribute-member.md b/skill-data/core/references/contribute-member.md index 40a3f8b36..1f04426d5 100644 --- a/skill-data/core/references/contribute-member.md +++ b/skill-data/core/references/contribute-member.md @@ -117,13 +117,32 @@ The doc lands in the team's `learnings/` and appears for teammates on their next - Teammates receive it automatically on their next session, or via `teamai pull`. Before listing rules, `push` refreshes copies whose bodies still match a recorded -sync revision. Copilot's generated `applyTo` header does not count as a local -edit: unedited old instructions update in native format, including under -`COPILOT_HOME` in user scope. Genuine local body edits remain push candidates. +sync revision. The header teamai generates for a tool's own rules format +(Cursor `.mdc`, JoyCode's own `.mdc`, Copilot `applyTo`, Kiro `inclusion`, Qoder +`trigger`, CodeBuddy and WorkBuddy `alwaysApply`, Oh My Pi `alwaysApply`/`globs`) does not count as a local edit: unedited old copies update in that +format, including under `COPILOT_HOME` in user scope. Genuine local body edits remain push candidates. Rule pre-sync leaves tools excluded by `enabledAgents` or `disabledAgents` untouched. When only team `paths` change, `applyTo` refreshes if the local file still matches a recorded version's generated copy; locally edited headers are kept. The copies push refreshes are recorded, so a later `teamai pull` still updates them. +Oh My Pi and Kiro read only the top of their rules directories, so a namespaced +rule is written flat there (`rules/fe/style.md` as `fe.style.md`); an edit of that file +pushes back to `rules/fe/style.md`. Push requires a delivery record for the flat copy. +A personal file with that name is neither refreshed before push nor offered as +an edit of the team rule. +If two namespaced rules flatten to the same name, neither is written; `teamai doctor` +reports the collision even when no other rule reaches that tool. Rename one in the +team repo, then run `teamai pull`. Doctor also reports team-owned OpenCode globs +or inline blocks left after the last rule is removed. + +A new file in the rules directory of a tool with a rules format of its own +(Cursor, JoyCode, Copilot, Kiro, Qoder, CodeBuddy, WorkBuddy, Oh My Pi) is the +member's own rule in that tool's format: push never offers it, and pull leaves +it. To author a new team rule, write it as a plain `.md` in `.claude/rules/` +(scope it with `paths:` frontmatter, which teamai renders into each tool's +format), then run `teamai push`. A YAML comment after an unquoted glob stays +outside its scope: `paths: **/*.ts # TypeScript files` matches `**/*.ts`, including +when written as a block-list entry under `paths:`. ## If push is denied diff --git a/skill-data/setup/references/uninstall.md b/skill-data/setup/references/uninstall.md index 64932c6ae..73e8b0c6f 100644 --- a/skill-data/setup/references/uninstall.md +++ b/skill-data/setup/references/uninstall.md @@ -17,7 +17,8 @@ machine** (all tools)?"* 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 + `--agent workbuddy` keeps that file while CodeBuddy is installed. The team + rules in a project's `.codebuddy/rules` are shared the same way. 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. @@ -59,8 +60,23 @@ and give it your team repo URL."* ## Notes +- For OpenCode, uninstall also removes the rules globs teamai added to + `instructions` in `opencode.json`, including the relative `rules/*.md` an + earlier release wrote in user scope. In a project it removes + `.opencode/rules/**/*.md` from `.opencode/opencode.json` and the + `.opencode/rules/*.md` an earlier release wrote to the root `opencode.json`, + and deletes `.opencode/opencode.json` when nothing else is left in it. + The user's own entries stay. +- Uninstall removes the team-rules block from the file a tool with no rules + format reads in user scope (`~/.codex/AGENTS.md`, `~/.zcode/AGENTS.md`, + `$DSH_HOME/AGENTS.md`, the OpenClaw workspace `AGENTS.md`, + `~/.pi/agent/AGENTS.md`, `~/.joycode/rules.txt`), and the file when teamai + created it for the block alone. - Uninstall cleans legacy Codex rule copies at the recorded `toolRoots` - location, including publishers' bare local filenames. It keeps edited copies. + location, including publishers' bare local filenames, and the copies earlier + releases left in a project's `.workbuddy/rules` and `.pi/rules`, in + `.openclaw/rules`, `~/.pi/agent/rules` and `~/.joycode/rules`. It keeps + edited copies and names them. For a rule the team has 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 diff --git a/src/__tests__/builtin-rules.test.ts b/src/__tests__/builtin-rules.test.ts index 5f2b75356..470a6ad40 100644 --- a/src/__tests__/builtin-rules.test.ts +++ b/src/__tests__/builtin-rules.test.ts @@ -79,6 +79,21 @@ describe('builtin-rules', () => { expect(content).toContain('Team Knowledge Recall'); }); + it.each([ + ['kiro', '.kiro/steering', 'inclusion: always'], + ['qoder', '.qoder/rules', 'trigger: always_on'], + ])('deploys the recall rule to %s in its own rules format (#946)', async (tool, dir, header) => { + fs.mkdirSync(path.join(tmpDir, dir), { recursive: true }); + const teamConfig = { toolPaths: { [tool]: { rules: dir } } } as any; + + const { deployBuiltinRules } = await import('../builtin-rules.js'); + await deployBuiltinRules(teamConfig); + + const content = fs.readFileSync(path.join(tmpDir, dir, 'teamai-recall.md'), 'utf-8'); + expect(content.startsWith(`---\n${header}\n---\n\n`)).toBe(true); + expect(content).toContain('Team Knowledge Recall'); + }); + it('should skip tool directories that do not exist (tool not installed)', async () => { // Arrange: only create one tool directory const claudeRulesDir = path.join(tmpDir, '.claude', 'rules'); diff --git a/src/__tests__/codebuddy-workbuddy-rules.test.ts b/src/__tests__/codebuddy-workbuddy-rules.test.ts new file mode 100644 index 000000000..47b4cf15f --- /dev/null +++ b/src/__tests__/codebuddy-workbuddy-rules.test.ts @@ -0,0 +1,273 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import path from 'node:path'; +import os from 'node:os'; +import fse from 'fs-extra'; + +vi.mock('../config.js', async (importOriginal) => ({ + ...(await importOriginal()), + loadStateForScope: vi.fn(async () => ({})), + saveStateForScope: vi.fn(), +})); + +vi.mock('../utils/logger.js', () => ({ + log: { persist: vi.fn(), info: vi.fn(), success: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn(), dim: vi.fn() }, +})); + +import crypto from 'node:crypto'; +import { RulesHandler } from '../resources/rules.js'; +import { openLedger } from '../resources/delivered-copies.js'; +import { log } from '../utils/logger.js'; +import { TeamaiConfigSchema } from '../types.js'; +import type { LocalConfig, TeamaiConfig } from '../types.js'; + +/** + * CodeBuddy and WorkBuddy run the same engine and read CodeBuddy's rules + * format (#946). In a project both read `.codebuddy/rules`, so they share one + * copy there; in user scope each has its own home. + */ +const SCOPED = '---\npaths: ["src/**/*.ts", "test/**"]\n---\n\nUse named exports.\n'; +const RENDER = '---\nalwaysApply: false\npaths:\n - "src/**/*.ts"\n - "test/**"\n---\n\nUse named exports.\n'; + +describe('CodeBuddy and WorkBuddy rules (#946)', () => { + let tmpDir: string; + let homeDir: string; + let projectRoot: string; + let repoPath: string; + let handler: RulesHandler; + let teamConfig: TeamaiConfig; + let localConfig: LocalConfig; + + const home = (rel: string) => path.join(homeDir, rel); + const project = (rel: string) => path.join(projectRoot, rel); + + beforeEach(async () => { + tmpDir = await fse.mkdtemp(path.join(os.tmpdir(), 'teamai-codebuddy-rules-')); + homeDir = path.join(tmpDir, 'home'); + projectRoot = path.join(tmpDir, 'project'); + repoPath = path.join(tmpDir, 'team-repo'); + await fse.outputFile(path.join(repoPath, 'rules', 'scoped.md'), SCOPED); + await fse.ensureDir(projectRoot); + vi.stubEnv('HOME', homeDir); + vi.clearAllMocks(); + handler = new RulesHandler(); + teamConfig = TeamaiConfigSchema.parse({ team: 'test', repo: 'https://example.invalid/x/team.git' }); + localConfig = { + repo: { localPath: repoPath, remote: 'https://example.invalid/x/team.git' }, + username: 'u', + additionalRoles: [], + scope: 'user', + enabledAgents: ['codebuddy', 'workbuddy'], + } as unknown as LocalConfig; + }); + + afterEach(async () => { + vi.unstubAllEnvs(); + await fse.remove(tmpDir); + }); + + const inProject = () => { + localConfig = { ...localConfig, scope: 'project', projectRoot } as LocalConfig; + }; + + it('writes CodeBuddy\'s render to ~/.codebuddy/rules and ~/.workbuddy/rules in user scope', async () => { + await fse.ensureDir(home('.codebuddy')); + await fse.ensureDir(home('.workbuddy')); + + await handler.pullAllRules(teamConfig, localConfig); + + expect(await fse.readFile(home('.codebuddy/rules/scoped.md'), 'utf8')).toBe(RENDER); + expect(await fse.readFile(home('.workbuddy/rules/scoped.md'), 'utf8')).toBe(RENDER); + }); + + it('writes one copy to the project\'s .codebuddy/rules for both tools, and nothing to .workbuddy/rules', async () => { + inProject(); + await fse.ensureDir(project('.codebuddy')); + await fse.ensureDir(project('.workbuddy')); + + await handler.pullAllRules(teamConfig, localConfig); + + expect(await fse.readFile(project('.codebuddy/rules/scoped.md'), 'utf8')).toBe(RENDER); + expect(await fse.pathExists(project('.workbuddy/rules'))).toBe(false); + const targets = await handler.deliveryTargets(teamConfig, localConfig, (await handler.scanTeamForPull(teamConfig, localConfig))[0]); + expect(targets.map(({ dest }) => dest)).toEqual([project('.codebuddy/rules/scoped.md')]); + }); + + it('shares Qoder and Qoder CN\'s one project copy the same way, any two tools reading one file in one render', async () => { + inProject(); + localConfig = { ...localConfig, enabledAgents: ['qoder', 'qoder-cn'] } as LocalConfig; + await fse.ensureDir(project('.qoder')); + + const targets = await handler.deliveryTargets(teamConfig, localConfig, (await handler.scanTeamForPull(teamConfig, localConfig))[0]); + + expect(targets).toMatchObject([{ tool: 'qoder', dest: project('.qoder/rules/scoped.md'), sharedWith: ['qoder-cn'] }]); + }); + + it('delivers to .codebuddy/rules for WorkBuddy alone, probing .workbuddy for its install', async () => { + inProject(); + localConfig = { ...localConfig, enabledAgents: ['workbuddy'] } as LocalConfig; + + await handler.pullAllRules(teamConfig, localConfig); + expect(await fse.pathExists(project('.codebuddy/rules/scoped.md'))).toBe(false); + + await fse.ensureDir(project('.workbuddy')); + await handler.pullAllRules(teamConfig, localConfig); + expect(await fse.readFile(project('.codebuddy/rules/scoped.md'), 'utf8')).toBe(RENDER); + }); + + describe('the shared project copy', () => { + beforeEach(async () => { + inProject(); + await fse.ensureDir(project('.codebuddy')); + await fse.ensureDir(project('.workbuddy')); + await handler.pullAllRules(teamConfig, localConfig); + }); + + it.each([['codebuddy'], ['workbuddy']])('stays while only %s is enabled', async (tool) => { + await fse.outputFile(path.join(repoPath, 'rules', 'scoped.md'), SCOPED.replace('named', 'default')); + localConfig = { ...localConfig, enabledAgents: [tool] } as LocalConfig; + + await handler.pullAllRules(teamConfig, localConfig); + + expect(await fse.readFile(project('.codebuddy/rules/scoped.md'), 'utf8')).toBe(RENDER.replace('named', 'default')); + }); + + it('is not offered for push after a clean pull, and an edited body pushes without the CodeBuddy frontmatter', async () => { + expect(await handler.scanLocalForPush(teamConfig, localConfig)).toEqual([]); + + await fse.writeFile(project('.codebuddy/rules/scoped.md'), RENDER.replace('named', 'default')); + const items = await handler.scanLocalForPush(teamConfig, localConfig); + expect(items).toMatchObject([{ name: 'scoped', status: 'modified' }]); + await handler.pushItem(items[0], teamConfig, localConfig); + + expect(await fse.readFile(path.join(repoPath, 'rules', 'scoped.md'), 'utf8')).toBe(SCOPED.replace('named', 'default')); + }); + + it("keeps a member's own CodeBuddy rule there on pull and never offers it as a new team rule", async () => { + const mine = project('.codebuddy/rules/mine.md'); + await fse.writeFile(mine, '---\nalwaysApply: true\n---\n\nMine.\n'); + + await handler.pullAllRules(teamConfig, localConfig); + + expect(await fse.pathExists(mine)).toBe(true); + expect(await handler.scanLocalForPush(teamConfig, localConfig)).toEqual([]); + }); + }); + + const sha256 = (text: string) => crypto.createHash('sha256').update(text).digest('hex'); + const warnings = () => vi.mocked(log.warn).mock.calls.map(([message]) => String(message)); + + describe('a project\'s .workbuddy/rules, which WorkBuddy never read', () => { + const legacy = (file: string) => project(`.workbuddy/rules/${file}`); + + beforeEach(async () => { + inProject(); + await fse.ensureDir(project('.workbuddy')); + }); + + it('reclaims the copies an older pull wrote there, and delivers to .codebuddy/rules instead', async () => { + await fse.outputFile(legacy('scoped.md'), SCOPED); + await fse.outputFile(legacy('mine.md'), 'My own WorkBuddy note.\n'); + + await handler.pullAllRules(teamConfig, localConfig); + + expect(await fse.pathExists(legacy('scoped.md'))).toBe(false); + expect(await fse.readFile(legacy('mine.md'), 'utf8')).toBe('My own WorkBuddy note.\n'); + expect(await fse.readFile(project('.codebuddy/rules/scoped.md'), 'utf8')).toBe(RENDER); + expect(warnings()).toEqual([]); + }); + + it('removes the directory once nothing else is in it, and also for a WorkBuddy that is excluded', async () => { + localConfig = { ...localConfig, enabledAgents: ['claude'] } as LocalConfig; + await fse.outputFile(legacy('scoped.md'), SCOPED); + + await handler.pullAllRules(teamConfig, localConfig); + + expect(await fse.pathExists(project('.workbuddy/rules'))).toBe(false); + expect(await fse.pathExists(project('.workbuddy'))).toBe(true); + }); + + it('keeps an edited copy and names it once, saying where WorkBuddy reads a project\'s rules', async () => { + const edited = SCOPED.replace('named', 'my own'); + await fse.outputFile(legacy('scoped.md'), edited); + + await handler.pullAllRules(teamConfig, localConfig); + + expect(await fse.readFile(legacy('scoped.md'), 'utf8')).toBe(edited); + expect(warnings()).toHaveLength(1); + expect(warnings()[0]).toContain(legacy('scoped.md')); + expect(warnings()[0]).toContain('WorkBuddy reads a project\'s rules from .codebuddy/rules'); + expect(warnings()[0]).not.toContain('Codex'); + }); + }); + + describe('the team rules WorkBuddy\'s one-time migration copied from ~/.codebuddy/rules', () => { + const migrated = (file: string) => home(`.workbuddy/rules/${file}`); + + beforeEach(async () => { + await fse.ensureDir(home('.codebuddy')); + await fse.outputFile(home('.workbuddy/.migrated-from-codebuddy'), '2026-09-01T00:00:00.000Z'); + await fse.outputFile(path.join(repoPath, 'rules', 'backend.md'), 'Backend rule.\n'); + await fse.outputFile(path.join(repoPath, 'rules', 'frontend.md'), 'Frontend rule.\n'); + }); + + it('re-renders a copy still delivered, removes unedited ones no longer delivered, and keeps the rest', async () => { + // Delivered here: verbatim, as ~/.codebuddy/rules held it. + await fse.outputFile(migrated('scoped.md'), SCOPED); + // Not delivered to this member (filtered out): verbatim. + await fse.outputFile(migrated('backend.md'), 'Backend rule.\n'); + // Not delivered: an older version, proven only by ~/.codebuddy/rules' record. + await fse.outputFile(migrated('frontend.md'), 'Frontend rule, older.\n'); + // Not delivered, and the member changed it. + await fse.outputFile(home('.workbuddy/rules/edited.md'), 'Edited.\n'); + await fse.outputFile(path.join(repoPath, 'rules', 'edited.md'), 'Team version.\n'); + // The member's own CodeBuddy rule, migrated with the rest. + await fse.outputFile(migrated('mine.md'), 'Mine.\n'); + const ledger = openLedger({ [home('.codebuddy/rules/frontend.md')]: sha256('Frontend rule, older.\n') }); + const rules = (await handler.scanTeamForPull(teamConfig, localConfig)).filter((rule) => rule.name === 'scoped'); + + await handler.pullAllRules(teamConfig, localConfig, rules, [], ledger); + + expect(await fse.readFile(migrated('scoped.md'), 'utf8')).toBe(RENDER); + expect(ledger.hashes[migrated('scoped.md')]).toBe(sha256(RENDER)); + expect(await fse.pathExists(migrated('backend.md'))).toBe(false); + expect(await fse.pathExists(migrated('frontend.md'))).toBe(false); + expect(await fse.readFile(migrated('edited.md'), 'utf8')).toBe('Edited.\n'); + expect(await fse.readFile(migrated('mine.md'), 'utf8')).toBe('Mine.\n'); + expect(warnings()).toHaveLength(1); + expect(warnings()[0]).toContain(migrated('edited.md')); + expect(warnings()[0]).toContain('~/.codebuddy/rules'); + expect(warnings()[0]).not.toContain(migrated('mine.md')); + }); + + it('keeps an edited copy of a rule still delivered, as an edit of the copy it came from (#822)', async () => { + const source = home('.codebuddy/rules/scoped.md'); + const edited = SCOPED.replace('named', 'my own'); + await fse.outputFile(migrated('scoped.md'), edited); + const ledger = openLedger({ [source]: sha256(SCOPED) }); + + await handler.pullAllRules(teamConfig, localConfig, undefined, [], ledger); + + expect(await fse.readFile(migrated('scoped.md'), 'utf8')).toBe(edited); + expect(ledger.kept.map(({ dest }) => dest)).toContain(migrated('scoped.md')); + expect(ledger.hashes[migrated('scoped.md')]).toBe(sha256(SCOPED)); + }); + + it.each([ + ['without the migration marker', async () => { await fse.remove(home('.workbuddy/.migrated-from-codebuddy')); }, {}], + ['while WorkBuddy is excluded', async () => {}, { enabledAgents: ['codebuddy'] }], + ])('leaves ~/.workbuddy/rules alone %s', async (_label, arrange, config) => { + await arrange(); + localConfig = { ...localConfig, ...config } as LocalConfig; + await fse.outputFile(migrated('backend.md'), 'Backend rule.\n'); + await fse.outputFile(migrated('edited.md'), 'Edited.\n'); + await fse.outputFile(path.join(repoPath, 'rules', 'edited.md'), 'Team version.\n'); + const rules = (await handler.scanTeamForPull(teamConfig, localConfig)).filter((rule) => rule.name === 'scoped'); + + await handler.pullAllRules(teamConfig, localConfig, rules, [], openLedger({})); + + expect(await fse.readFile(migrated('backend.md'), 'utf8')).toBe('Backend rule.\n'); + expect(await fse.readFile(migrated('edited.md'), 'utf8')).toBe('Edited.\n'); + expect(warnings().filter((message) => message.includes('copied it from'))).toEqual([]); + }); + }); +}); diff --git a/src/__tests__/cursor-mdc.test.ts b/src/__tests__/cursor-mdc.test.ts index 50b49afcf..9e6c14206 100644 --- a/src/__tests__/cursor-mdc.test.ts +++ b/src/__tests__/cursor-mdc.test.ts @@ -1,10 +1,7 @@ import { describe, it, expect } from 'vitest'; import matter from 'gray-matter'; -import { - teamRuleToCursorMdc, - mergeCursorBodyIntoTeamMd, - cursorMdcBodyEqualsTeamMd, -} from '../resources/cursor-mdc.js'; +import { teamRuleToCursorMdc } from '../resources/cursor-mdc.js'; +import { mergeRuleBodyIntoTeamMd, ruleBodyEqualsTeamMd } from '../resources/team-rule.js'; describe('teamRuleToCursorMdc', () => { it('maps a team `paths:` array to Cursor `globs` with alwaysApply=false', () => { @@ -83,7 +80,7 @@ Body.`; }); }); -describe('mergeCursorBodyIntoTeamMd', () => { +describe('mergeRuleBodyIntoTeamMd', () => { it('keeps the team rule frontmatter and replaces only the body', () => { const team = `--- paths: @@ -97,7 +94,7 @@ alwaysApply: false --- Edited body.`; - const merged = mergeCursorBodyIntoTeamMd(mdc, team); + const merged = mergeRuleBodyIntoTeamMd(mdc, team); expect(merged).toContain('paths:'); expect(merged).toContain('- "**/*.ts"'); expect(merged).toContain('Edited body.'); @@ -113,7 +110,7 @@ paths: ["**/*.ts"] Same body.`; const mdc = teamRuleToCursorMdc(team); - expect(mergeCursorBodyIntoTeamMd(mdc, team)).toBe(team); + expect(mergeRuleBodyIntoTeamMd(mdc, team)).toBe(team); }); it('writes body only for a rule that does not exist upstream yet', () => { @@ -122,20 +119,20 @@ alwaysApply: true --- Brand new rule.`; - expect(mergeCursorBodyIntoTeamMd(mdc, null)).toBe('Brand new rule.\n'); + expect(mergeRuleBodyIntoTeamMd(mdc, null)).toBe('Brand new rule.\n'); }); it('handles a team rule that has no frontmatter', () => { - expect(mergeCursorBodyIntoTeamMd('just a body', 'old body')).toBe('just a body\n'); + expect(mergeRuleBodyIntoTeamMd('just a body', 'old body')).toBe('just a body\n'); }); it('does not leak an empty `---/---` block into the body', () => { const mdc = '---\n---\nThe rule body.'; - expect(mergeCursorBodyIntoTeamMd(mdc, null)).toBe('The rule body.\n'); + expect(mergeRuleBodyIntoTeamMd(mdc, null)).toBe('The rule body.\n'); }); }); -describe('cursorMdcBodyEqualsTeamMd โ€” round-trip stability', () => { +describe('ruleBodyEqualsTeamMd โ€” round-trip stability', () => { it('a pulled .mdc compares equal to its source team .md (no spurious modified)', () => { const team = `--- paths: @@ -144,13 +141,13 @@ paths: Rule text that must not drift.`; const mdc = teamRuleToCursorMdc(team); - expect(cursorMdcBodyEqualsTeamMd(mdc, team)).toBe(true); + expect(ruleBodyEqualsTeamMd(mdc, team)).toBe(true); }); it('a mandatory (no-frontmatter) team rule round-trips equal', () => { const team = 'A mandatory rule with no frontmatter.'; const mdc = teamRuleToCursorMdc(team); - expect(cursorMdcBodyEqualsTeamMd(mdc, team)).toBe(true); + expect(ruleBodyEqualsTeamMd(mdc, team)).toBe(true); }); it('detects a genuine body edit as different', () => { @@ -165,7 +162,7 @@ alwaysApply: false --- Edited body.`; - expect(cursorMdcBodyEqualsTeamMd(editedMdc, team)).toBe(false); + expect(ruleBodyEqualsTeamMd(editedMdc, team)).toBe(false); }); it('ignores frontmatter-only differences', () => { @@ -179,6 +176,6 @@ alwaysApply: true --- Same body.`; - expect(cursorMdcBodyEqualsTeamMd(differentFrontmatter, team)).toBe(true); + expect(ruleBodyEqualsTeamMd(differentFrontmatter, team)).toBe(true); }); }); diff --git a/src/__tests__/doctor-delivery.test.ts b/src/__tests__/doctor-delivery.test.ts index b2886ac7c..dfc7822c1 100644 --- a/src/__tests__/doctor-delivery.test.ts +++ b/src/__tests__/doctor-delivery.test.ts @@ -179,6 +179,45 @@ describe('doctor โ€” skills delivered on disk', () => { expect(await installed!.check()).toBe(true); }); + describe('OpenClaw hook enabled', () => { + const NAME = 'OpenClaw hook enabled'; + const stateDir = (): string => path.join(homeDir, '.openclaw'); + + async function hookCheck(): Promise { + const ctx = await resolveDoctorContext(); + if (!ctx) throw new Error('expected a resolved doctor context'); + return (await buildChecks(ctx)).find((c) => c.name === NAME); + } + + beforeEach(async () => { + for (const v of ['OPENCLAW_STATE_DIR', 'OPENCLAW_PROFILE', 'OPENCLAW_CONFIG_PATH', 'OPENCLAW_WORKSPACE_DIR']) vi.stubEnv(v, ''); + teamConfig.toolPaths = { openclaw: { skills: '.openclaw/skills' } }; + // The hook teamai injected, in the default workspace. + await fse.ensureDir(path.join(stateDir(), 'workspace', 'hooks', 'teamai-status-report')); + }); + + it.each([ + ['the entry is missing', { hooks: { internal: { enabled: true } } }, false], + ['the entry is disabled', { hooks: { internal: { entries: { 'teamai-status-report': { enabled: false } } } } }, false], + ['internal hooks are off', { hooks: { internal: { enabled: false, entries: { 'teamai-status-report': { enabled: true } } } } }, false], + ['the entry is enabled', { hooks: { internal: { enabled: true, entries: { 'teamai-status-report': { enabled: true } } } } }, true], + ])('reports %s', async (_label, cfg, ok) => { + await fse.writeJson(path.join(stateDir(), 'openclaw.json'), cfg); + + const check = await hookCheck(); + + expect(check).toBeDefined(); + expect(await check!.check()).toBe(ok); + expect(check!.fix).toContain('openclaw hooks enable teamai-status-report'); + }); + + it('is not built when teamai has no hook in the workspace', async () => { + await fse.remove(path.join(stateDir(), 'workspace', 'hooks')); + + expect(await hookCheck()).toBeUndefined(); + }); + }); + it('reports each installed tool separately', async () => { teamConfig.toolPaths = { claude: { skills: '.claude/skills' }, diff --git a/src/__tests__/doctor-rules-delivery.test.ts b/src/__tests__/doctor-rules-delivery.test.ts index 58ddf97a5..9360ecf76 100644 --- a/src/__tests__/doctor-rules-delivery.test.ts +++ b/src/__tests__/doctor-rules-delivery.test.ts @@ -21,7 +21,7 @@ vi.mock('../utils/logger.js', () => ({ import crypto from 'node:crypto'; import { loadLocalConfig, loadStateForScope, loadTeamConfig } from '../config.js'; -import { buildChecks, resolveDoctorContext, type Check } from '../doctor.js'; +import { buildChecks, doctor, resolveDoctorContext, type Check, type DoctorReport } from '../doctor.js'; import { checkoutKey } from '../pull.js'; import { StateSchema, TeamaiConfigSchema, type LocalConfig, type TeamaiConfig } from '../types.js'; @@ -175,6 +175,33 @@ describe('doctor โ€” rules delivered on disk', () => { expect(cursor.fix).toContain('delivered from an older copy: reviews'); }); + it('names --force for an unrecorded older copy, and a plain pull for one on record (#946)', async () => { + await deliverPlain(CLAUDE_RULES, 'coding-style'); + await deliverPlain(CLAUDE_RULES, 'reviews'); + await deliverMdc('coding-style'); + await deliverMdc('reviews', ''); + + const unrecorded = await rulesCheck('cursor'); + expect(await unrecorded.check()).toBe(false); + expect(unrecorded.fix).toContain('teamai pull --force'); + + // What an older CLI wrote and recorded: the "Already synced" pull re-renders it. + const older = path.join(homeDir, CURSOR_RULES, 'reviews.mdc'); + const delivered = { [older]: crypto.createHash('sha256').update('Body of reviews\n').digest('hex') }; + vi.mocked(loadStateForScope).mockResolvedValue(StateSchema.parse({ + lastPullByWorkspace: { [await checkoutKey(homeDir)]: { rev: 'r1', targets: [], delivered } }, + })); + try { + const recorded = await rulesCheck('cursor'); + expect(await recorded.check()).toBe(false); + expect(recorded.fix).toContain('reviews'); + expect(recorded.fix).toContain('Run `teamai pull`'); + expect(recorded.fix).not.toContain('--force'); + } finally { + vi.mocked(loadStateForScope).mockResolvedValue(StateSchema.parse({})); + } + }); + it('reports a .mdc whose globs no longer match the team rule', async () => { // The frontmatter fields are all present and `alwaysApply` is a legal // value, so checking that the keys exist calls this delivered. Cursor @@ -203,6 +230,152 @@ describe('doctor โ€” rules delivered on disk', () => { expect(claude.fix).toContain('delivered from an older copy: reviews'); }); + it.each([ + ['kiro', '.kiro/steering', '---\ninclusion: fileMatch\nfileMatchPattern: ["**/*.ts"]\n---\n\n', '`inclusion` or `fileMatchPattern`'], + ['qoder', '.qoder/rules', '---\ntrigger: glob\nglob: **/*.ts\n---\n\n', '`trigger` or `glob`'], + ])('checks %s against its own render, and names the fields it scopes by (#946)', async (tool, dir, frontmatter, fields) => { + await writeTeamRule('reviews', '---\npaths:\n - "**/*.ts"\n---\n'); + teamConfig.toolPaths = { [tool]: { rules: dir } }; + const always = tool === 'kiro' ? '---\ninclusion: always\n---\n\n' : '---\ntrigger: always_on\n---\n\n'; + await fse.outputFile(path.join(homeDir, dir, 'coding-style.md'), `${always}Body of coding-style\n`); + const reviews = path.join(homeDir, dir, 'reviews.md'); + await fse.outputFile(reviews, `${frontmatter}Body of reviews\n`); + + expect(await (await rulesCheck(tool)).check()).toBe(true); + + // A hand edit to the glob: well-formed, and wrong. + await fse.writeFile(reviews, `${frontmatter.replace('**/*.ts', '**/*.py')}Body of reviews\n`); + const check = await rulesCheck(tool); + expect(await check.check()).toBe(false); + expect(check.fix).toContain('delivered from an older copy: reviews'); + expect(check.fix).toContain(fields); + expect(check.fix).not.toContain('.mdc'); + }); + + it.each([ + ['omp', '.omp/agent/rules', '---\nalwaysApply: true\n---\n\n'], + ['kiro', '.kiro/steering', '---\ninclusion: always\n---\n\n'], + ])('checks the flat file %s reads for a namespaced rule, not the nested path (#946)', async (tool, rulesDir, always) => { + await writeTeamRule('fe/style'); + teamConfig.toolPaths = { [tool]: { rules: rulesDir } }; + const dir = path.join(homeDir, rulesDir); + for (const name of ['coding-style', 'reviews']) { + await fse.outputFile(path.join(dir, `${name}.md`), `${always}Body of ${name}\n`); + } + // Where an older teamai left it: the tool does not read below the top level. + await fse.outputFile(path.join(dir, 'fe', 'style.md'), 'Body of fe/style\n'); + + const missing = await rulesCheck(tool); + expect(await missing.check()).toBe(false); + expect(missing.fix).toContain('not delivered: fe/style'); + + await fse.outputFile(path.join(dir, 'fe.style.md'), `${always}Body of fe/style\n`); + expect(await (await rulesCheck(tool)).check()).toBe(true); + }); + + it('fails for a namespaced rule OMP gets no file for, as a root rule has its flat name (#946)', async () => { + await writeTeamRule('fe/style'); + await writeTeamRule('fe.style'); + teamConfig.toolPaths = { omp: { rules: '.omp/agent/rules' } }; + const dir = path.join(homeDir, '.omp/agent/rules'); + for (const name of ['coding-style', 'reviews', 'fe.style']) { + await fse.outputFile(path.join(dir, `${name}.md`), `---\nalwaysApply: true\n---\n\nBody of ${name}\n`); + } + + const check = await rulesCheck('omp'); + expect(await check.check()).toBe(false); + expect(check.fix).toContain('not written, as another team rule has its flat name: fe/style'); + expect(check.fix).toContain('rename one of them in the team repo'); + }); + + it.each([ + ['omp', '.omp/agent/rules'], + ['kiro', '.kiro/steering'], + ])('reports %s collisions when every desired rule has the same flat filename', async (tool, rules) => { + await fse.remove(path.join(repoPath, 'rules')); + await writeTeamRule('fe.style/x'); + await writeTeamRule('fe/style.x'); + teamConfig.toolPaths = { [tool]: { rules } }; + await fse.ensureDir(path.join(homeDir, rules)); + + const check = await rulesCheck(tool); + expect(await check.check()).toBe(false); + expect(check.fix).toContain('not written, as another team rule has its flat name'); + expect(check.fix).toContain('fe.style/x'); + expect(check.fix).toContain('fe/style.x'); + expect(check.fix).toContain('rename one of them in the team repo'); + + localConfig.disabledAgents = [tool]; + expect((await checks()).some((c) => c.name === `Rules delivered to ${tool}`)).toBe(false); + localConfig.disabledAgents = []; + await fse.remove(path.join(homeDir, rules.split('/')[0])); + expect((await checks()).some((c) => c.name === `Rules delivered to ${tool}`)).toBe(false); + }); + + it('checks a project\'s .joycode/rules against JoyCode\'s render, not Cursor\'s quoted one (#946)', async () => { + const projectRoot = path.join(tempDir, 'project'); + Object.assign(localConfig, { scope: 'project', projectRoot }); + teamConfig.toolPaths = { joycode: { rules: '.joycode/rules' } }; + await writeTeamRule('reviews', '---\npaths:\n - "src/**"\n - "test/**"\n---\n'); + const dir = path.join(projectRoot, '.joycode/rules'); + await fse.outputFile(path.join(dir, 'coding-style.mdc'), '---\nalwaysApply: true\n---\n\nBody of coding-style\n'); + const reviews = path.join(dir, 'reviews.mdc'); + await fse.outputFile(reviews, '---\nglobs: src/**, test/**\nalwaysApply: false\n---\n\nBody of reviews\n'); + + expect(await (await rulesCheck('joycode')).check()).toBe(true); + + // What teamai wrote before: JoyCode matches the quotes and never applies it. + await fse.writeFile(reviews, '---\nglobs: "src/**, test/**"\nalwaysApply: false\n---\n\nBody of reviews\n'); + const check = await rulesCheck('joycode'); + expect(await check.check()).toBe(false); + expect(check.fix).toContain(dir); + expect(check.fix).toContain('delivered from an older copy: reviews'); + expect(check.fix).toContain('`globs` or `alwaysApply`'); + }); + + describe('CodeBuddy and WorkBuddy (#946)', () => { + const ALWAYS = '---\nalwaysApply: true\n---\n\n'; + const defaults = TeamaiConfigSchema.parse({ team: 't', repo: 'owner/repo' }).toolPaths; + + async function deliverCodebuddy(dir: string): Promise { + for (const name of ['coding-style', 'reviews']) await fse.outputFile(path.join(dir, `${name}.md`), `${ALWAYS}Body of ${name}\n`); + } + + beforeEach(() => { + teamConfig.toolPaths = { codebuddy: defaults.codebuddy, workbuddy: defaults.workbuddy }; + }); + + it('checks the shared project .codebuddy/rules once, naming both tools', async () => { + const projectRoot = path.join(tempDir, 'project'); + Object.assign(localConfig, { scope: 'project', projectRoot }); + await fse.ensureDir(path.join(projectRoot, '.workbuddy')); + await deliverCodebuddy(path.join(projectRoot, '.codebuddy/rules')); + + const rules = (await checks()).filter((c) => c.name.startsWith('Rules delivered to')); + expect(rules.map((c) => c.name)).toEqual(['Rules delivered to codebuddy, workbuddy']); + expect(await rules[0].check()).toBe(true); + + // A verbatim copy, as teamai wrote it before: CodeBuddy ignores its `paths:` form. + await fse.writeFile(path.join(projectRoot, '.codebuddy/rules/reviews.md'), 'Body of reviews\n'); + const check = await rulesCheck('codebuddy, workbuddy'); + expect(await check.check()).toBe(false); + expect(check.fix).toContain(path.join(projectRoot, '.codebuddy/rules')); + expect(check.fix).toContain('delivered from an older copy: reviews'); + expect(check.fix).toContain('`alwaysApply` or `paths`'); + }); + + it('checks WorkBuddy\'s user rules in ~/.workbuddy/rules', async () => { + await fse.ensureDir(path.join(homeDir, '.codebuddy')); + await deliverCodebuddy(path.join(homeDir, '.workbuddy/rules')); + + const workbuddy = await rulesCheck('workbuddy'); + expect(await workbuddy.check()).toBe(true); + const codebuddy = await rulesCheck('codebuddy'); + expect(await codebuddy.check()).toBe(false); + expect(codebuddy.fix).toContain(path.join(homeDir, '.codebuddy/rules')); + }); + }); + it('passes a copy the member changed since teamai delivered it, which pull keeps (#822)', async () => { const edited = path.join(homeDir, CLAUDE_RULES, 'reviews.md'); await fse.writeFile(edited, 'My own version\n'); @@ -255,11 +428,43 @@ describe('doctor โ€” rules delivered on disk', () => { it('passes when opencode.json lists the rules glob beside the user\'s own', async () => { await installOpencode(); - await writeOpencodeConfig({ instructions: ['CONVENTIONS.md', 'rules/*.md'] }); + await writeOpencodeConfig({ instructions: ['CONVENTIONS.md', `${path.join(homeDir, OPENCODE_RULES)}/*.md`] }); + + expect(await (await namedCheck('Team rules are active in opencode'))!.check()).toBe(true); + }); + + it('fails on the relative rules/*.md an earlier release wrote, which loads the project\'s rules (#946)', async () => { + await installOpencode(); + await writeOpencodeConfig({ instructions: ['rules/*.md', `${path.join(homeDir, OPENCODE_RULES)}/*.md`] }); + + const active = await namedCheck('Team rules are active in opencode'); + expect(await active!.check()).toBe(false); + expect(active!.fix).toContain('`rules/*.md`'); + expect(active!.fix).toContain('session'); + }); + + it('does not flag a glob the member added for a directory that is no team namespace (#946)', async () => { + await installOpencode(); + const root = path.join(homeDir, OPENCODE_RULES); + await writeOpencodeConfig({ instructions: [`${root}/*.md`, `${root}/mine/*.md`] }); expect(await (await namedCheck('Team rules are active in opencode'))!.check()).toBe(true); }); + it('fails until the directory of a namespaced rule has its own glob (#946)', async () => { + await installOpencode(); + await writeTeamRule('fe/style'); + const root = path.join(homeDir, OPENCODE_RULES); + await writeOpencodeConfig({ instructions: [`${root}/*.md`] }); + + const active = await namedCheck('Team rules are active in opencode'); + expect(await active!.check()).toBe(false); + expect(active!.fix).toContain(`\`${root}/fe/*.md\``); + + await writeOpencodeConfig({ instructions: [`${root}/*.md`, `${root}/fe/*.md`] }); + expect(await (await namedCheck('Team rules are active in opencode'))!.check()).toBe(true); + }); + it('fails when opencode.json cannot be parsed, which is when the pull skipped it', async () => { await installOpencode(); await writeOpencodeConfig('{ not json'); @@ -267,6 +472,50 @@ describe('doctor โ€” rules delivered on disk', () => { const active = await namedCheck('Team rules are active in opencode'); expect(await active!.check()).toBe(false); expect(active!.fix).toContain('could not be read'); + expect(active!.fix).toContain('Fix the file, then run `teamai pull`.'); + }); + + it('checks .opencode/opencode.json in a project, not the root opencode.json (#946)', async () => { + await installOpencode(); + const projectRoot = path.join(tempDir, 'project'); + await fse.ensureDir(path.join(projectRoot, '.opencode', 'rules')); + Object.assign(localConfig, { scope: 'project', projectRoot }); + // Only the root file lists a glob, the one earlier releases wrote. + await fse.writeJson(path.join(projectRoot, 'opencode.json'), { instructions: ['.opencode/rules/*.md'] }); + + const active = await namedCheck('Team rules are active in opencode'); + expect(await active!.check()).toBe(false); + // The file is missing, not broken: a plain pull writes it. + expect(active!.fix).toContain(`${path.join(projectRoot, '.opencode', 'opencode.json')} does not list \`.opencode/rules/**/*.md\``); + expect(active!.fix).not.toContain('could not be read'); + expect(active!.fix).toContain('Run `teamai pull`.'); + + await fse.writeJson(path.join(projectRoot, '.opencode', 'opencode.json'), { instructions: ['.opencode/rules/**/*.md'] }); + expect(await (await namedCheck('Team rules are active in opencode'))!.check()).toBe(true); + }); + + it.each(['user', 'project'] as const)('reports stale OpenCode activation after the last %s rule is removed', async (scope) => { + await installOpencode(); + let configFile = path.join(homeDir, OPENCODE_CONFIG); + let glob = `${path.join(homeDir, OPENCODE_RULES)}/*.md`; + if (scope === 'project') { + const projectRoot = path.join(tempDir, 'project'); + Object.assign(localConfig, { scope, projectRoot }); + await fse.ensureDir(path.join(projectRoot, '.opencode', 'rules')); + configFile = path.join(projectRoot, '.opencode', 'opencode.json'); + glob = '.opencode/rules/**/*.md'; + } + await fse.remove(path.join(repoPath, 'rules')); + await fse.writeJson(configFile, { instructions: ['CONVENTIONS.md', glob] }); + + const active = await namedCheck('Team rules are active in opencode'); + expect(active).toBeDefined(); + expect(await active!.check()).toBe(false); + expect(active!.fix).toContain(`\`${glob}\``); + expect(active!.fix).toContain('Run `teamai pull`.'); + + await fse.writeJson(configFile, { instructions: ['CONVENTIONS.md'] }); + expect(await namedCheck('Team rules are active in opencode')).toBeUndefined(); }); it('emits no opencode activation check while opencode is not installed here', async () => { @@ -333,6 +582,37 @@ describe('doctor โ€” rules delivered on disk', () => { expect(await namedCheck('Team rules are inlined in Hermes SOUL.md')).toBeUndefined(); }); + describe('Hermes in project scope (#946)', () => { + beforeEach(async () => { + const hermesHome = path.join(tempDir, 'hermes'); + await fse.ensureDir(hermesHome); + vi.stubEnv('HERMES_HOME', hermesHome); + // The block a user-scope pull wrote, which holds the user rules, not this project's. + await fse.writeFile(path.join(hermesHome, 'SOUL.md'), '\nUser rule\n\n'); + Object.assign(localConfig, { scope: 'project', projectRoot: path.join(tempDir, 'project') }); + }); + + it('does not compare SOUL.md with the project rules, which only a user-scope pull writes there', async () => { + expect(await namedCheck('Team rules are inlined in Hermes SOUL.md')).toBeUndefined(); + }); + + it('notes that Hermes gets no project rules, and why', async () => { + const spy = vi.spyOn(console, 'log').mockImplementation(() => {}); + let report: DoctorReport; + try { + await doctor({ json: true }); + report = JSON.parse(String(spy.mock.calls.at(-1)?.[0])) as DoctorReport; + } finally { + spy.mockRestore(); + } + const note = (report.notes ?? []).find((line) => line.startsWith('Hermes gets no project rules')); + expect(note).toBeDefined(); + expect(note).toContain('.hermes.md'); + expect(note).toContain('pre_llm_call'); + expect(note).toContain('4,000'); + }); + }); + describe('Codex AGENTS.md, user scope (#938)', () => { const CODEX = 'Team rules are inlined in Codex AGENTS.md'; // What pull writes for the two team rules, markers included. diff --git a/src/__tests__/doctor.test.ts b/src/__tests__/doctor.test.ts index b612697c4..f3731e2d7 100644 --- a/src/__tests__/doctor.test.ts +++ b/src/__tests__/doctor.test.ts @@ -18,6 +18,7 @@ vi.mock('../utils/fs.js', () => ({ // Manifest loaders read through these; no manifest exists on this machine. readFileIfExists: vi.fn().mockResolvedValue(null), readJson: vi.fn().mockResolvedValue(null), + readJsonObject: vi.fn().mockResolvedValue({ kind: 'missing' }), // The delivery checks walk the team repo through resolveDesiredSkills, // resolveDesiredRules, resolveDesiredAgents and DocsHandler. This machine // has none of those; delivery on a real disk is covered by diff --git a/src/__tests__/dsh-hooks.test.ts b/src/__tests__/dsh-hooks.test.ts index eb8e5e9fc..6fa2b35f1 100644 --- a/src/__tests__/dsh-hooks.test.ts +++ b/src/__tests__/dsh-hooks.test.ts @@ -46,6 +46,29 @@ describe('DeepSeek Harness hook bridge', () => { expect(await fse.pathExists(path.join(home, '.teamai', 'dsh'))).toBe(false); }); + it('writes the bridge where only $DSH_HOME exists, the home dsh runs from (#946)', async () => { + const dshHome = path.join(tmp, 'dsh-home'); + await fse.ensureDir(dshHome); + vi.stubEnv('DSH_HOME', dshHome); + try { + await reconcileHooksToAllTools(toolPaths, home, [], manifest()); + expect(await fse.pathExists(patchFile())).toBe(true); + } finally { + vi.unstubAllEnvs(); + } + }); + + it('does not create the bridge where $DSH_HOME does not exist, even with ~/.dsh (#946)', async () => { + await fse.ensureDir(path.join(home, '.dsh')); + vi.stubEnv('DSH_HOME', path.join(tmp, 'missing-dsh-home')); + try { + await reconcileHooksToAllTools(toolPaths, home, [], manifest()); + expect(await fse.pathExists(patchFile())).toBe(false); + } finally { + vi.unstubAllEnvs(); + } + }); + it('writes Claude-compatible hooks and a parseable dsh patch when dsh is installed', async () => { await fse.ensureDir(path.join(home, '.dsh')); await reconcileHooksToAllTools(toolPaths, home, [], manifest()); diff --git a/src/__tests__/e2e/doctor-delivery-cli.test.ts b/src/__tests__/e2e/doctor-delivery-cli.test.ts index a913cb8dd..d33da57cc 100644 --- a/src/__tests__/e2e/doctor-delivery-cli.test.ts +++ b/src/__tests__/e2e/doctor-delivery-cli.test.ts @@ -25,6 +25,8 @@ interface DoctorReport { ok: boolean; checks: CheckResult[] } describe('teamai doctor delivery checks (e2e)', () => { let sandbox: string; let home: string; + // Absolute: OpenCode resolves a relative entry from the session's cwd (#946). + const opencodeUserGlob = (): string => `${path.join(home, '.config', 'opencode', 'rules').split(path.sep).join('/')}/*.md`; let repo: string; function runDoctor(): DoctorReport { @@ -169,11 +171,11 @@ describe('teamai doctor delivery checks (e2e)', () => { // the delivered copy with the render, so a placeholder is a stale copy. write(path.join(home, '.claude/agents/reviewer.md'), CLAUDE_AGENT_MD); write(path.join(home, '.codex/agents/reviewer.toml'), CODEX_AGENT_TOML); - write(path.join(home, '.codebuddy/rules/coding-style.md'), 'Coding style body\n'); + write(path.join(home, '.codebuddy/rules/coding-style.md'), '---\nalwaysApply: true\n---\n\nCoding style body\n'); write(path.join(home, '.codebuddy/agents/reviewer.md'), CLAUDE_AGENT_MD); write(path.join(home, '.config/opencode/rules/coding-style.md'), 'Coding style body\n'); // The glob the pull adds; without it every .md above is inert. - write(path.join(home, '.config', 'opencode', 'opencode.json'), JSON.stringify({ instructions: ['rules/*.md'] })); + write(path.join(home, '.config', 'opencode', 'opencode.json'), JSON.stringify({ instructions: [opencodeUserGlob()] })); // The entry teamai renders for claude, placeholder resolved โ€” the check // compares the value, so a hand-shaped entry of the same name is not it. write(path.join(home, '.claude.json'), JSON.stringify({ @@ -275,7 +277,7 @@ describe('teamai doctor delivery checks (e2e)', () => { expect(active.ok).toBe(false); expect(active.fix).toContain('inert'); - write(path.join(home, '.config', 'opencode', 'opencode.json'), JSON.stringify({ instructions: ['rules/*.md'] })); + write(path.join(home, '.config', 'opencode', 'opencode.json'), JSON.stringify({ instructions: [opencodeUserGlob()] })); }); it('passes a multiline env value the shell quotes across several lines', () => { diff --git a/src/__tests__/fixtures/openclaw/events.ts b/src/__tests__/fixtures/openclaw/events.ts new file mode 100644 index 000000000..e66ac7ef2 --- /dev/null +++ b/src/__tests__/fixtures/openclaw/events.ts @@ -0,0 +1,77 @@ +/** + * OpenClaw internal hook events, shaped the way OpenClaw's producers build them + * (openclaw@2026.9.7 source). Every event comes from `createInternalHookEvent` + * (src/hooks/internal-hooks.ts): `{ type, action, sessionKey, context, + * timestamp, messages }`. A handler is called with that event as its only + * argument; there is no `event` field. + * + * Producers: + * - command:new src/gateway/session-create-service.ts (emitCommandHooks) + * - command:reset src/auto-reply/reply/commands-reset-hooks.ts, src/gateway/session-reset-service.ts + * - command:stop src/auto-reply/reply/commands-session-abort.ts (no workspaceDir) + * - session:auto-reset src/hooks/session-auto-reset.ts + * - gateway:startup src/gateway/server-startup-post-attach.ts (sessionKey "gateway:startup") + * - message:received src/auto-reply/reply/message-received-hooks.ts (no workspaceDir) + * - message:sent src/infra/outbound/message-sent-hook.ts + * + * The key list is pinned from docs/automation/hooks/event-types.md (Event types table). + */ + +export const OPENCLAW_EVENT_KEYS = [ + 'command:new', + 'command:reset', + 'command:stop', + 'session:auto-reset', + 'session:compact:before', + 'session:compact:after', + 'session:patch', + 'agent:bootstrap', + 'gateway:startup', + 'gateway:shutdown', + 'gateway:pre-restart', + 'message:received', + 'message:transcribed', + 'message:preprocessed', + 'message:sent', +] as const; + +export interface OpenClawHookEvent { + type: string; + action: string; + sessionKey: string; + context: Record; + timestamp: Date; + messages: string[]; +} + +function event(type: string, action: string, sessionKey: string, context: Record): OpenClawHookEvent { + return { type, action, sessionKey, context, timestamp: new Date(0), messages: [] }; +} + +/** Events for one workspace; `workspaceDir` is where the producer says the agent works. */ +export function openclawEvents(workspaceDir: string): Record { + return { + 'command:new': event('command', 'new', 'agent:main:main', { + agentId: 'main', commandSource: 'webchat', cfg: {}, storePath: '/state/sessions', workspaceDir, + }), + 'command:reset': event('command', 'reset', 'agent:main:main', { + agentId: 'main', commandSource: 'gateway:sessions.reset', cfg: {}, storePath: '/state/sessions', workspaceDir, + }), + 'command:stop': event('command', 'stop', 'agent:main:main', { + sessionId: 's-1', commandSource: 'chat', senderId: 'u-1', + }), + 'session:auto-reset': event('session', 'auto-reset', 'agent:main:main', { + cfg: {}, agentId: 'main', workspaceDir, storePath: '/state/sessions', + sessionEntry: { sessionId: 's-1' }, reason: 'idle', + }), + 'gateway:startup': event('gateway', 'startup', 'gateway:startup', { + cfg: {}, deps: {}, workspaceDir, + }), + 'message:received': event('message', 'received', 'agent:main:main', { + from: 'u-1', content: 'hello', channelId: 'chat', + }), + 'message:sent': event('message', 'sent', 'agent:main:main', { + to: 'u-1', content: 'hi', channelId: 'chat', success: true, + }), + }; +} diff --git a/src/__tests__/fixtures/rule-parsers/omp/rules.ts b/src/__tests__/fixtures/rule-parsers/omp/rules.ts new file mode 100644 index 000000000..6ea667d72 --- /dev/null +++ b/src/__tests__/fixtures/rule-parsers/omp/rules.ts @@ -0,0 +1,464 @@ +/** + * Oh My Pi's rule parser, vendored so teamai's OMP render is read back by the + * code that reads it (#946). Source: @oh-my-pi/pi-coding-agent and + * @oh-my-pi/pi-utils 18.2.1 (https://github.com/can1357/oh-my-pi): + * + * parseFrontmatter pi-utils/src/frontmatter.ts + * loadRulesDir coding-agent/src/discovery/builtin.ts (loadRules) + helpers.ts (loadFilesFromDir) + * buildRule, discoverRuleFromMarkdown coding-agent/src/discovery/helpers.ts + * parseRuleConditionAndScope, parseRuleAgents coding-agent/src/capability/rule.ts + * bucketRules coding-agent/src/capability/rule-buckets.ts + * + * Changes from the source, all needed to run outside Bun: Bun's `YAML.parse` + * is the `yaml` package's; the native `glob` that lists a rules directory is + * `readdirSync` with the same non-recursive `*.{md,mdc}` pattern, hidden + * files skipped; `Bun.Glob` in `ruleAppliesToAgent` is an exact match (teamai + * writes no `agents`); the TTSR manager is an interface that never accepts a + * rule (teamai writes no `condition`); the frontmatter warning is dropped. + * Update it from a new OMP release by re-copying these functions. + * + * MIT License + * + * Copyright (c) 2025 Mario Zechner + * Copyright (c) 2025-2026 Can Bรถlรผk + * Copyright (c) 2026 Stencil Labs, Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +import fs from 'node:fs'; +import path from 'node:path'; +import YAML from 'yaml'; + +export interface Rule { + name: string; + path: string; + content: string; + globs?: string[]; + alwaysApply?: boolean; + description?: string; + condition?: string[]; + astCondition?: string[]; + scope?: string[]; + agents?: string[]; + interruptMode?: 'never' | 'prose-only' | 'tool-only' | 'always'; +} + +type RuleFrontmatter = Record; + +// โ”€โ”€โ”€ pi-utils/src/frontmatter.ts โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +function stripHtmlComments(content: string): string { + return content.replace(//g, ''); +} + +function kebabToCamel(key: string): string { + if (!key.includes('-')) return key; + return key.replace(/-([a-z])/g, (_, c: string) => c.toUpperCase()); +} + +function normalizeFrontmatterKeys(obj: T): T { + if (obj === null || typeof obj !== 'object') return obj; + if (Array.isArray(obj)) { + let changed = false; + const out: unknown[] = Array.from({ length: obj.length }); + for (let i = 0; i < obj.length; i++) { + const v: unknown = obj[i]; + const nv = normalizeFrontmatterKeys(v); + out[i] = nv; + if (nv !== v) changed = true; + } + return (changed ? (out as unknown) : obj) as T; + } + let changed = false; + const result: Record = {}; + for (const [key, value] of Object.entries(obj as Record)) { + const nk = key.includes('-') ? kebabToCamel(key) : key; + const nv = normalizeFrontmatterKeys(value); + result[nk] = nv; + if (nk !== key || nv !== value) changed = true; + } + return (changed ? result : obj) as T; +} + +const PLAIN_SCALAR_KEY_VALUE = /^(\s*[A-Za-z_][\w-]*:\s+)(\S.*?)(\s*)$/; +const FLOW_OR_EXPLICIT_VALUE_START = new Set(['"', "'", '[', '{', '|', '>', '!', '&', '*', '#']); + +function quoteAmbiguousPlainScalars(metadata: string): string | undefined { + let changed = false; + const lines = metadata.split('\n').map((line) => { + const match = line.match(PLAIN_SCALAR_KEY_VALUE); + if (!match) return line; + const [, prefix, rawValue, suffix] = match; + const value = rawValue.trimEnd(); + if (!value.includes(': ')) return line; + if (FLOW_OR_EXPLICIT_VALUE_START.has(value[0])) return line; + changed = true; + return `${prefix}${JSON.stringify(value)}${suffix}`; + }); + return changed ? lines.join('\n') : undefined; +} + +function parseYamlRecord(metadata: string, repairTabs: boolean): Record | null { + const loaded: unknown = YAML.parse(repairTabs ? metadata.replaceAll('\t', ' ') : metadata); + if (loaded === null || loaded === undefined) return null; + if (typeof loaded !== 'object' || Array.isArray(loaded)) return null; + return loaded as Record; +} + +export function parseFrontmatter(content: string): { frontmatter: Record; body: string } { + const frontmatter: Record = {}; + + const newlineNormalized = content.replace(/\r\n?/g, '\n'); + const normalized = stripHtmlComments(newlineNormalized); + if (!normalized.startsWith('---')) { + return { frontmatter, body: normalized }; + } + + const endIndex = normalized.indexOf('\n---', 3); + if (endIndex === -1) { + return { frontmatter, body: normalized }; + } + + const metadata = normalized.slice(4, endIndex); + const body = normalized.slice(endIndex + 4).trim(); + + try { + const loaded = parseYamlRecord(metadata, true); + return { frontmatter: normalizeFrontmatterKeys({ ...frontmatter, ...loaded }), body }; + } catch { + const quotedMetadata = quoteAmbiguousPlainScalars(metadata); + if (quotedMetadata) { + try { + const loaded = parseYamlRecord(quotedMetadata, true); + return { frontmatter: normalizeFrontmatterKeys({ ...frontmatter, ...loaded }), body }; + } catch { + // Fall through to the simple key/value fallback. + } + } + + for (const line of metadata.split('\n')) { + const match = line.match(/^([\w-]+):\s*(.*)$/); + if (!match) continue; + const raw = match[2].trim(); + let value: unknown = raw; + if (raw.length > 0) { + try { + const parsed: unknown = YAML.parse(raw); + if (parsed !== null && typeof parsed !== 'object') value = parsed; + else if (Array.isArray(parsed)) value = parsed; + } catch { + // keep the raw string + } + } + frontmatter[match[1]] = value; + } + + return { frontmatter: normalizeFrontmatterKeys(frontmatter), body }; + } +} + +// โ”€โ”€โ”€ coding-agent/src/capability/rule.ts โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +const CONDITION_GLOB_SCOPE_TOOLS = ['edit', 'write'] as const; + +function normalizeRuleField(value: unknown): string[] | undefined { + if (typeof value === 'string') { + const token = value.trim(); + return token.length > 0 ? [token] : undefined; + } + if (!Array.isArray(value)) { + return undefined; + } + + const tokens = value + .filter((item): item is string => typeof item === 'string') + .map((item) => item.trim()) + .filter((item) => item.length > 0); + if (tokens.length === 0) { + return undefined; + } + + return Array.from(new Set(tokens)); +} + +function splitScopeTokens(value: string): string[] { + const tokens: string[] = []; + let current = ''; + let parenDepth = 0; + let bracketDepth = 0; + let braceDepth = 0; + let quote: '"' | "'" | undefined; + for (let i = 0; i < value.length; i++) { + const char = value[i]; + if (quote) { + current += char; + if (char === quote && value[i - 1] !== '\\') { + quote = undefined; + } + continue; + } + if (char === '"' || char === "'") { + quote = char; + current += char; + continue; + } + if (char === '(') { + parenDepth++; + current += char; + continue; + } + if (char === ')') { + parenDepth = Math.max(0, parenDepth - 1); + current += char; + continue; + } + if (char === '[') { + bracketDepth++; + current += char; + continue; + } + if (char === ']') { + bracketDepth = Math.max(0, bracketDepth - 1); + current += char; + continue; + } + if (char === '{') { + braceDepth++; + current += char; + continue; + } + if (char === '}') { + braceDepth = Math.max(0, braceDepth - 1); + current += char; + continue; + } + if (char === ',' && parenDepth === 0 && bracketDepth === 0 && braceDepth === 0) { + const token = current.trim(); + if (token.length > 0) { + tokens.push(token); + } + current = ''; + continue; + } + current += char; + } + + const tail = current.trim(); + if (tail.length > 0) { + tokens.push(tail); + } + + return tokens; +} + +function normalizeScopeField(value: unknown): string[] | undefined { + const normalized = normalizeRuleField(value); + if (!normalized) { + return undefined; + } + + const tokens = normalized + .flatMap(splitScopeTokens) + .map((token) => { + const quote = token[0]; + if (token.length >= 2 && (quote === '"' || quote === "'") && token[token.length - 1] === quote) { + return token.slice(1, -1).trim(); + } + return token; + }) + .filter((item) => item.length > 0); + if (tokens.length === 0) { + return undefined; + } + return Array.from(new Set(tokens)); +} + +function parseRuleAgents(value: unknown): string[] | undefined { + const tokens = normalizeScopeField(value); + if (!tokens) { + return undefined; + } + return Array.from(new Set(tokens.map((token) => token.replace(/\s*,\s*/g, ',').toLowerCase()))); +} + +function ruleAppliesToAgent(rule: Pick, agentName: string | undefined): boolean { + const patterns = rule.agents; + if (!patterns || patterns.length === 0 || agentName === undefined) { + return true; + } + const name = agentName.trim().toLowerCase(); + // Source: `pattern === name || new Bun.Glob(pattern).match(name)`. + return patterns.some((pattern) => pattern === name); +} + +function isLikelyFileGlob(value: string): boolean { + const token = value.trim(); + if (token.length === 0) { + return false; + } + if (/[\\^$+|()]/.test(token)) { + return false; + } + if (!/[?*[\]{}]/.test(token)) { + return false; + } + if (token.includes('/')) { + return true; + } + return /^\*\.[^\s/]+$/.test(token); +} + +function parseRuleConditionAndScope(frontmatter: RuleFrontmatter): Pick { + const rawCondition = frontmatter.condition ?? frontmatter.ttsr_trigger ?? frontmatter.ttsrTrigger; + const parsedCondition = normalizeRuleField(rawCondition); + const astCondition = normalizeRuleField(frontmatter.astCondition); + const parsedScope = normalizeScopeField(frontmatter.scope); + + const inferredScope: string[] = []; + const condition: string[] = []; + for (const token of parsedCondition ?? []) { + if (isLikelyFileGlob(token)) { + for (const toolName of CONDITION_GLOB_SCOPE_TOOLS) { + inferredScope.push(`tool:${toolName}(${token})`); + } + continue; + } + condition.push(token); + } + + if (condition.length === 0 && inferredScope.length > 0) { + condition.push('.*'); + } + + const scope = [...(parsedScope ?? []), ...inferredScope]; + return { + condition: condition.length > 0 ? Array.from(new Set(condition)) : undefined, + astCondition, + scope: scope.length > 0 ? Array.from(new Set(scope)) : undefined, + }; +} + +// โ”€โ”€โ”€ coding-agent/src/discovery/helpers.ts โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +function buildRule(name: string, body: string, frontmatter: RuleFrontmatter, filePath: string): Rule { + const { condition, astCondition, scope } = parseRuleConditionAndScope(frontmatter); + + let globs: string[] | undefined; + if (Array.isArray(frontmatter.globs)) { + globs = frontmatter.globs.filter((item): item is string => typeof item === 'string'); + } else if (typeof frontmatter.globs === 'string') { + globs = [frontmatter.globs]; + } + + const resolvedName = name.replace(/\.(md|mdc)$/, ''); + const rawMode = frontmatter.interruptMode; + const interruptMode: Rule['interruptMode'] = rawMode === 'never' || rawMode === 'prose-only' || rawMode === 'tool-only' || rawMode === 'always' + ? rawMode + : undefined; + return { + name: resolvedName, + path: filePath, + content: body, + globs, + alwaysApply: frontmatter.alwaysApply === true, + description: typeof frontmatter.description === 'string' ? frontmatter.description : undefined, + condition, + astCondition, + scope, + agents: parseRuleAgents(frontmatter.agents), + interruptMode, + }; +} + +export function discoverRuleFromMarkdown(name: string, content: string, filePath: string): Rule | null { + const { frontmatter, body } = parseFrontmatter(content); + if (frontmatter.enabled === false) return null; + return buildRule(name, body, frontmatter, filePath); +} + +// โ”€โ”€โ”€ coding-agent/src/discovery/builtin.ts (loadRules), one rules dir โ”€โ”€โ”€โ”€ + +/** + * The rules OMP loads from one `rules` directory (`.omp/rules`, + * `~/.omp/agent/rules`): `*.md` and `*.mdc` at its top level only. The + * capability then drops a rule without content (`ruleCapability.validate`). + */ +export function loadRulesDir(dir: string): Rule[] { + let entries: fs.Dirent[]; + try { + entries = fs.readdirSync(dir, { withFileTypes: true }); + } catch { + return []; + } + const rules: Rule[] = []; + for (const entry of entries) { + if (!entry.isFile() || entry.name.startsWith('.') || !/\.(md|mdc)$/.test(entry.name)) continue; + const filePath = path.join(dir, entry.name); + const rule = discoverRuleFromMarkdown(entry.name, fs.readFileSync(filePath, 'utf8'), filePath); + if (rule && rule.content) rules.push(rule); + } + return rules; +} + +// โ”€โ”€โ”€ coding-agent/src/capability/rule-buckets.ts โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +interface TtsrManager { + addRule(rule: Rule): boolean; +} + +export interface RuleBuckets { + rulebookRules: Rule[]; + alwaysApplyRules: Rule[]; +} + +/** + * Split the rules into the always-apply ones (their text in the system + * prompt) and the rulebook (listed by name, globs and description, read on + * demand); a rule in neither is dropped. TTSR rules are registered, not + * bucketed. + */ +export function bucketRules( + rules: readonly Rule[], + ttsrManager: TtsrManager = { addRule: () => false }, + options: { agentName?: string } = { agentName: 'main' }, +): RuleBuckets { + const includedRules: Rule[] = []; + for (const rule of rules) { + if (!ruleAppliesToAgent(rule, options.agentName)) continue; + includedRules.push(rule); + } + + const rulebookRules: Rule[] = []; + const alwaysApplyRules: Rule[] = []; + + for (const rule of includedRules) { + const hasTtsrCondition = (rule.condition && rule.condition.length > 0) || (rule.astCondition && rule.astCondition.length > 0); + const isTtsrRule = hasTtsrCondition ? ttsrManager.addRule(rule) : false; + if (isTtsrRule) continue; + if (rule.alwaysApply === true) { + alwaysApplyRules.push(rule); + continue; + } + if (rule.description) { + rulebookRules.push(rule); + } + } + + return { rulebookRules, alwaysApplyRules }; +} diff --git a/src/__tests__/helpers/rule-parsers.ts b/src/__tests__/helpers/rule-parsers.ts new file mode 100644 index 000000000..c39678c2f --- /dev/null +++ b/src/__tests__/helpers/rule-parsers.ts @@ -0,0 +1,212 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +/** + * Rule parsers taken from a tool's installed bundle, so a render is checked + * against the code that reads it (#946). The bundles are not vendored: point + * `TEAMAI_RULE_PARSER_BUNDLES` at them, as `=` entries separated + * by the platform's path delimiter, e.g. + * + * TEAMAI_RULE_PARSER_BUNDLES=cursor=$HOME/.local/share/cursor-agent/versions/:codebuddy=/node_modules/@tencent-ai/codebuddy-code + * + * and `joycode=` (an unpacked + * `.vsix` has it under `extension/`). + * + * A test whose tool has no entry is skipped; CI runs the byte-exact contract + * tests instead. A loader for another tool goes here beside Cursor's. + */ + +/** The path given for `tool`, or undefined when there is none. */ +export function ruleParserBundle(tool: string): string | undefined { + for (const entry of (process.env.TEAMAI_RULE_PARSER_BUNDLES ?? '').split(path.delimiter)) { + const at = entry.indexOf('='); + if (at > 0 && entry.slice(0, at) === tool) return entry.slice(at + 1); + } + return undefined; +} + +/** The source of the function declaration that starts at `start`, braces matched. */ +function functionSource(source: string, start: number): string { + let depth = 0; + for (let i = source.indexOf('{', start); i < source.length; i++) { + if (source[i] === '{') depth++; + else if (source[i] === '}' && --depth === 0) return source.slice(start, i + 1); + } + throw new Error('unbalanced function body'); +} + +/** The last `function NAME(...)` matching `pattern` before `before`. */ +function lastFunctionBefore(source: string, pattern: RegExp, before: number): { name: string; start: number } { + let found: { name: string; start: number } | undefined; + for (const match of source.slice(0, before).matchAll(pattern)) found = { name: match[1], start: match.index }; + if (!found) throw new Error(`no function matching ${pattern} in the bundle`); + return found; +} + +export interface CursorRuleParser { + /** Cursor's `.mdc` frontmatter parse: `{ frontmatter, body }`, or null without frontmatter. */ + parse(text: string): { frontmatter: Record; body: string } | null; + /** How Cursor turns `frontmatter.globs` into globs before matching. */ + globs(value: unknown): string[] | undefined; +} + +/** + * Cursor CLI's rule parser, out of `index.js` in a cursor-agent version + * directory (checked against 2026.09.22 and 2026.09.28). The minified names + * change per build, so the functions are found by their code: the line + * parser by its `metadata.disabledEnvironments` keys and `rawFrontmatter` + * result, its scalar reader by `"true"===`, and the glob splitter by its + * brace-depth comma split. + */ +export function loadCursorRuleParser(bundle: string): CursorRuleParser { + const file = fs.statSync(bundle).isDirectory() ? path.join(bundle, 'index.js') : bundle; + const source = fs.readFileSync(file, 'utf8'); + + const parseAt = source.indexOf('rawFrontmatter:`---\\n${'); + if (parseAt < 0) throw new Error(`no Cursor rule parser in ${file}`); + const parse = lastFunctionBefore(source, /function ([\w$]+)\((\w)\)\{const (\w)=\2\.trimStart\(\);if\(!\3\.startsWith\("---"\)\)return null/g, parseAt); + const scalar = lastFunctionBefore(source, /function ([\w$]+)\((\w)\)\{const (\w)=\2\.trim\(\);return"true"===\3\|\|"false"!==\3&&/g, parse.start); + const splitAt = source.search(/if\("\{"===(\w)\)(\w)\+\+;else if\("\}"===\1&&\2>0\)\2--;else if\(","===\1&&0===\2\)/); + if (splitAt < 0) throw new Error(`no Cursor glob splitter in ${file}`); + const split = lastFunctionBefore(source, /function ([\w$]+)\((\w)\)\{if\("string"==typeof \2\)/g, splitAt); + + const factory = new Function([ + functionSource(source, scalar.start), + functionSource(source, parse.start), + functionSource(source, split.start), + `return { parse: ${parse.name}, globs: ${split.name} };`, + ].join('\n')); + return factory() as CursorRuleParser; +} + +export interface CodebuddyRuleParser { + /** CodeBuddy's read of one rule file: ALWAYS, or MANUAL (path-scoped) with its globs. */ + parse(file: string): Promise<{ type: string; globs: string[] | undefined; alwaysApply: boolean; content: string }>; +} + +/** The source of the class method `head` starts, braces matched; `head` must be unique in `source`. */ +function methodSource(source: string, head: RegExp, file: string): string { + const at = source.search(head); + if (at < 0) throw new Error(`no ${head} in ${file}`); + return functionSource(source, at); +} + +/** + * CodeBuddy Code's memory parser, out of `dist/codebuddy.js` in the + * `@tencent-ai/codebuddy-code` package (checked against 2.160.0). WorkBuddy's + * engine (`dist/codebuddy-lite-wb.mjs`) ships the same parser. The parser + * class is found by its `scope`/`autoGeneratedGlobs` options and the + * frontmatter helpers by their names on `MarkdownUtils`, which the build keeps; + * the module aliases the parser reaches them through are rewritten to locals. + */ +export function loadCodebuddyRuleParser(bundle: string): CodebuddyRuleParser { + const file = fs.statSync(bundle).isDirectory() ? path.join(bundle, 'dist', 'codebuddy.js') : bundle; + const source = fs.readFileSync(file, 'utf8'); + + const helpers = ['extractFrontMatterWithContent', 'parseListField', 'splitByCommaRespectingBraces'] + .map((name) => methodSource(source, new RegExp(`static ${name}\\(`), file)); + const parse = methodSource(source, /async parse\([\w$]+,[\w$]+\)\{let [\w$]+,\{scope:[\w$]+,autoGeneratedGlobs:/, file); + const parseGlobs = methodSource(source, /parseGlobs\([\w$]+\)\{if\(![\w$]+\.paths\)return;/, file); + const local = (code: string): string => code + .replace(/\(0,[\w$]+\.readFile\)/g, 'readFile') + .replace(/[\w$]+\.MarkdownUtils\./g, 'MarkdownUtils.') + .replace(/[\w$]+\.[\w$]+\.(MANUAL|ALWAYS)\b/g, '"$1"'); + + const factory = new Function('readFile', [ + `class MarkdownUtils { ${helpers.join('\n')} }`, + `class Parser { ${local(parse)}\n${local(parseGlobs)} }`, + 'const parser = new Parser();', + 'return { parse: (file) => parser.parse(file, { scope: "project" }) };', + ].join('\n')); + return factory(fs.promises.readFile) as CodebuddyRuleParser; +} + +export interface JoycodeRuleParser { + /** JoyCode's read of one `.mdc` project rule (`parseMdcRule`). */ + parse(text: string, filename: string): { globs: string; alwaysApply: boolean; body: string }; + /** Whether JoyCode applies the rule while `file` is the active editor's file, in workspace `cwd`. */ + applies(text: string, filename: string, file: string, cwd: string): boolean; +} + +/** + * A require for the webpack modules of `source`, each evaluated from its own + * source: a module runs from its `ID(e,t,n){` header to the next one. + */ +function webpackRequire(source: string): (id: string) => Record { + const next = /\},\d+\((?:e(?:,t(?:,n)?)?)?\)\{/g; + const cache = new Map }>(); + const require = (id: string): Record => { + const cached = cache.get(id); + if (cached) return cached.exports; + const head = new RegExp(`[{,]${id}\\(((?:e(?:,t(?:,n)?)?)?)\\)\\{`).exec(source); + if (!head) throw new Error(`no webpack module ${id} in the bundle`); + const start = head.index + head[0].length; + next.lastIndex = start; + const end = next.exec(source); + if (!end) throw new Error(`no end of webpack module ${id}`); + const module = { exports: {} as Record }; + cache.set(id, module); + const params = head[1] ? head[1].split(',') : []; + new Function(...params, source.slice(start, end.index + 1).slice(0, -1)).call(module.exports, module, module.exports, require); + return module.exports; + }; + return require; +} + +/** + * JoyCode's project-rule reader, out of `dist/extension.js` in the + * `JoyCoder.joycoder-fe` extension (checked against 3.8.71). `parseMdcRule` + * and its helpers are found by the getters their module exports them under, + * and the minimatch JoyCode bundles by its export list. The decision is + * JoyCode's `collectProjectRuleEntries`: an `alwaysApply` rule applies; + * otherwise `globs` is split on every comma and each part matched against + * the file's basename, absolute path and workspace-relative path. The loader + * fails if the bundle no longer splits that way. + */ +export function loadJoycodeRuleParser(bundle: string): JoycodeRuleParser { + const file = fs.statSync(bundle).isDirectory() ? path.join(bundle, 'dist', 'extension.js') : bundle; + const source = fs.readFileSync(file, 'utf8'); + + const getter = (name: string): { name: string; at: number } => { + const match = new RegExp(`get ${name}\\(\\)\\{return ([\\w$]+)\\}`).exec(source); + if (!match) throw new Error(`no ${name} in ${file}`); + return { name: match[1], at: match.index }; + }; + const declaration = ({ name, at }: { name: string; at: number }): string => { + const start = source.indexOf(`function ${name}(`, at); + if (start < 0) throw new Error(`no function ${name} after its getter in ${file}`); + return functionSource(source, start); + }; + const parse = getter('parseMdcRule'); + const factory = new Function([ + declaration(getter('truncateByChars')), + declaration(getter('extractBodySummary')), + declaration(parse), + `return ${parse.name};`, + ].join('\n')); + const parseMdcRule = factory() as (text: string, filename: string) => { globs: string; alwaysApply: boolean; body: string }; + + if (!/else if\(([\w$]+)\)\{const ([\w$]+)=\1\.split\(","\)\.map\(([\w$]+)=>\3\.trim\(\)\);/.test(source)) { + throw new Error(`JoyCode no longer splits a rule's globs on every comma in ${file}`); + } + const minimatchAt = source.indexOf('t.Minimatch=t.match=t.makeRe=t.braceExpand='); + const minimatchId = minimatchAt < 0 ? undefined + : [...source.slice(0, minimatchAt).matchAll(/[{,](\d+)\(e,t,n\)\{"use strict"/g)].pop()?.[1]; + if (!minimatchId) throw new Error(`no bundled minimatch in ${file}`); + const minimatch = webpackRequire(source)(minimatchId).minimatch as (target: string, pattern: string) => boolean; + + return { + parse: (text, filename) => { + const { globs, alwaysApply, body } = parseMdcRule(text, filename); + return { globs, alwaysApply, body }; + }, + applies: (text, filename, target, cwd) => { + const { globs, alwaysApply } = parseMdcRule(text, filename); + if (alwaysApply) return true; + if (!globs) return false; + const targets = [path.basename(target), target, path.relative(cwd, target)]; + return globs.split(',').map((glob) => glob.trim()) + .some((glob) => targets.some((candidate) => minimatch(candidate, glob))); + }, + }; +} diff --git a/src/__tests__/hooks-cmd.test.ts b/src/__tests__/hooks-cmd.test.ts index e18532116..542f57c4a 100644 --- a/src/__tests__/hooks-cmd.test.ts +++ b/src/__tests__/hooks-cmd.test.ts @@ -709,7 +709,7 @@ describe('hooksList', () => { expect(omp).toHaveLength(4); expect(omp.join('\n')).not.toContain('[Skill]'); expect(omp.join('\n')).not.toContain('[TodoWrite]'); - // OpenClaw's handler maps session:start and command:new only + // OpenClaw's handler maps its events onto session-start and prompt-submit only // (openclaw-hooks.ts EVENT_MAP). expect(builtinBlock(builtin, 'openclaw')).toEqual([ 'SessionStart โ†’ teamai hook-dispatch session-start --tool ', diff --git a/src/__tests__/init.test.ts b/src/__tests__/init.test.ts index 2f5c8079a..7e49c1750 100644 --- a/src/__tests__/init.test.ts +++ b/src/__tests__/init.test.ts @@ -203,6 +203,7 @@ vi.mock('../utils/fs.js', () => ({ return p; }, readFileSafe: vi.fn().mockResolvedValue(null), + readJsonObject: vi.fn().mockResolvedValue({ kind: 'missing' }), remove: (p: string) => mockRemove(p), })); @@ -816,6 +817,46 @@ describe('init', () => { }); }); + // #946 project rules use the hooks automatically trusted by #955. + describe('the Codex trust result', () => { + async function initWithCodex(enabledAgents: string[]): Promise { + const { log } = await import('../utils/logger.js'); + const { TeamaiConfigSchema } = await import('../types.js'); + const { toolPaths } = TeamaiConfigSchema.parse({ team: 'my-team', repo: 'https://git.woa.com/HyperAI/teamai-test.git' }); + let cloneDone = false; + pathExistsFn = (p: string) => (p === localPath ? cloneDone : p === path.join(HOME, '.codex')); + mockGfRepoClone.mockImplementation(() => { + cloneDone = true; + }); + vi.mocked(await import('../config.js')).loadTeamConfig.mockResolvedValue({ + team: 'my-team', + repo: 'https://git.woa.com/HyperAI/teamai-test.git', + provider: 'tgit', + reviewers: [], + sharing: { skills: {}, rules: { enforced: [] }, docs: { localDir: '~/.teamai/docs' }, env: { injectShellProfile: true } }, + toolPaths: { codex: toolPaths.codex, claude: toolPaths.claude }, + } as never); + questionAnswers = ['n', '1']; + await init({ repo: 'https://git.woa.com/HyperAI/teamai-test.git', scope: 'user', agent: enabledAgents.join(',') }); + return vi.mocked(log.warn).mock.calls.map((call) => String(call[0])).join('\n'); + } + + it('reports automatic trust without an unconditional manual-trust warning', async () => { + const { reportCodexTrust, trustCodexForScope } = await import('../hooks.js'); + const warned = await initWithCodex(['codex', 'claude']); + + expect(trustCodexForScope).toHaveBeenCalledWith(expect.anything(), expect.anything(), { filterAgents: ['codex', 'claude'], force: true }); + expect(reportCodexTrust).toHaveBeenCalledWith(undefined, 'all'); + expect(warned).not.toContain('open /hooks'); + }); + + it('is not printed when Codex is not enabled', async () => { + const warned = await initWithCodex(['claude']); + + expect(warned).not.toContain('open /hooks'); + }); + }); + describe('scope path display', () => { it('persists explicit user-resource inheritance in project config', async () => { const projectLocalPath = path.join(process.cwd(), '.teamai', 'team-repo'); diff --git a/src/__tests__/instruction-targets.test.ts b/src/__tests__/instruction-targets.test.ts index 1e3705920..da6c373b2 100644 --- a/src/__tests__/instruction-targets.test.ts +++ b/src/__tests__/instruction-targets.test.ts @@ -314,6 +314,31 @@ describe('instruction targets shared by several tools (#945)', () => { } }); + it('keeps a hook tool\'s retired file while its hook is not installed, unlike a tool with no project channel (#946)', async () => { + const root = fs.realpathSync.native(fs.mkdtempSync(path.join(os.tmpdir(), 'teamai-946-no-channel-'))); + vi.stubEnv('HOME', path.join(root, 'home')); + try { + const projectRoot = path.join(root, 'project'); + // Codex is installed in the project, but no user-level Codex runs its hooks. + fs.mkdirSync(path.join(projectRoot, '.codex', 'skills'), { 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' }, codex: { skills: '.codex/skills', claudemd: 'AGENTS.md' }, + } }); + + const retired = (await retiredFilesOfReached(teamConfig, localConfig, ['claude'])).map((target) => target.path); + + expect(retired).not.toContain(path.join(projectRoot, 'AGENTS.md')); + } 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')); @@ -757,13 +782,16 @@ describe('every tool and toolPaths shape keeps its instructions (#945)', () => { for (const value of Object.values(paths)) { if (typeof value === 'string') fs.mkdirSync(path.join(base, toolInstallRoot(value)), { recursive: true }); } + // OpenClaw is installed where its workspace resolves (#946). + if (tool === 'openclaw') fs.mkdirSync(path.join(process.env.HOME, '.openclaw', 'workspace'), { 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) { + // OpenClaw reads no project file of its own, only the shared AGENTS.md (#946). + if (paths.claudemd !== undefined && !(scope === 'project' && tool === 'openclaw')) { expect([...targets.flatMap((t) => t.tools), ...hooks.map((h) => h.tool)]).toContain(tool); } } finally { diff --git a/src/__tests__/joycode.test.ts b/src/__tests__/joycode.test.ts index 472c3b6cc..ad5f286ff 100644 --- a/src/__tests__/joycode.test.ts +++ b/src/__tests__/joycode.test.ts @@ -1,8 +1,8 @@ import { describe, expect, it } from 'vitest'; import { KNOWN_AGENTS } from '../known-agents.js'; import { ALL_SUPPORTED_TOOLS } from '../resources/agent-format.js'; -import { ruleFileExtensionForTool, usesCursorMdcRules } from '../resources/rule-format.js'; -import { TeamaiConfigSchema } from '../types.js'; +import { ruleFileExtensionForTool, usesMdcRules } from '../resources/rule-format.js'; +import { scopedToolPaths, TeamaiConfigSchema } from '../types.js'; describe('JoyCode support', () => { it('ships the standard .joycode resource paths', () => { @@ -12,7 +12,10 @@ describe('JoyCode support', () => { skills: '.joycode/skills', rules: '.joycode/rules', agents: '.joycode/agents', + // User rules are a block in ~/.joycode/rules.txt (#946). + userScope: { rules: null }, }); + expect(scopedToolPaths(config, { scope: 'user' }).joycode).not.toHaveProperty('rules'); }); it('registers JoyCode for discovery and native agent rendering', () => { @@ -23,8 +26,8 @@ describe('JoyCode support', () => { expect(ALL_SUPPORTED_TOOLS).toContain('joycode'); }); - it('uses Cursor-compatible .mdc rule files', () => { + it('uses .mdc rule files', () => { expect(ruleFileExtensionForTool('joycode')).toBe('.mdc'); - expect(usesCursorMdcRules('joycode')).toBe(true); + expect(usesMdcRules('joycode')).toBe(true); }); }); diff --git a/src/__tests__/kiro.test.ts b/src/__tests__/kiro.test.ts index 9fd4f2dae..3d045c85b 100644 --- a/src/__tests__/kiro.test.ts +++ b/src/__tests__/kiro.test.ts @@ -1,7 +1,7 @@ import os from 'node:os'; import path from 'node:path'; import fse from 'fs-extra'; -import { afterEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { KNOWN_AGENTS } from '../known-agents.js'; import { resolveMcpTargets } from '../mcp-reconcile.js'; import { @@ -13,7 +13,11 @@ import { } from '../resources/agent-format.js'; import { AgentsHandler } from '../resources/agents.js'; import { detectMcpFormat } from '../resources/mcp-format.js'; -import { ruleFileExtensionForTool, usesCursorMdcRules } from '../resources/rule-format.js'; +import { ruleFileExtensionForTool, usesMdcRules } from '../resources/rule-format.js'; +import { RulesHandler } from '../resources/rules.js'; +import { openLedger } from '../resources/delivered-copies.js'; +import { loadStateForScope, saveStateForScope } from '../config.js'; +import { checkoutKey } from '../pull.js'; import { TeamaiConfigSchema } from '../types.js'; import type { LocalConfig } from '../types.js'; import { resolveHookCwd } from '../utils/hook-cwd.js'; @@ -48,7 +52,7 @@ describe('Kiro support', () => { expect(ALL_SUPPORTED_TOOLS).toContain('kiro'); expect(agentFileExtensionForTool('kiro')).toBe('.json'); expect(ruleFileExtensionForTool('kiro')).toBe('.md'); - expect(usesCursorMdcRules('kiro')).toBe(false); + expect(usesMdcRules('kiro')).toBe(false); }); it('renders agentSpawn session-start into Kiro JSON while preserving custom hooks', () => { @@ -185,3 +189,76 @@ describe('Kiro support', () => { } }); }); + +describe('Kiro gets namespaced team rules flat, as it reads one level of steering (#946)', () => { + let tmp: string; + let homeDir: string; + let repoPath: string; + let localConfig: LocalConfig; + const handler = new RulesHandler(); + const teamConfig = TeamaiConfigSchema.parse({ team: 'test', repo: 'test/repo' }); + + const NS = 'Namespaced rule.\n'; + const KIRO_NS = '---\ninclusion: always\n---\n\nNamespaced rule.\n'; + const userSteering = () => path.join(homeDir, '.kiro/steering'); + + beforeEach(async () => { + tmp = await fse.mkdtemp(path.join(os.tmpdir(), 'teamai-kiro-flat-')); + homeDir = path.join(tmp, 'home'); + repoPath = path.join(tmp, 'repo'); + await fse.outputFile(path.join(repoPath, 'rules', 'fe', 'style.md'), NS); + await fse.ensureDir(userSteering()); + vi.stubEnv('HOME', homeDir); + localConfig = { + repo: { localPath: repoPath, remote: 'test/repo' }, + username: 'test', + scope: 'user', + additionalRoles: [], + enabledAgents: ['kiro'], + } as unknown as LocalConfig; + }); + + afterEach(async () => { + vi.unstubAllEnvs(); + await fse.remove(tmp); + }); + + it('writes fe.style.md at the top of ~/.kiro/steering', async () => { + await handler.pullAllRules(teamConfig, localConfig); + + expect(await fse.readFile(path.join(userSteering(), 'fe.style.md'), 'utf8')).toBe(KIRO_NS); + expect(await fse.pathExists(path.join(userSteering(), 'fe'))).toBe(false); + }); + + it('writes fe.style.md at the top of .kiro/steering in project scope', async () => { + const projectRoot = path.join(tmp, 'project'); + await fse.ensureDir(path.join(projectRoot, '.kiro')); + localConfig.scope = 'project'; + localConfig.projectRoot = projectRoot; + + await handler.pullAllRules(teamConfig, localConfig); + + expect(await fse.readFile(path.join(projectRoot, '.kiro/steering/fe.style.md'), 'utf8')).toBe(KIRO_NS); + expect(await fse.pathExists(path.join(projectRoot, '.kiro/steering/fe'))).toBe(false); + }); + + it('pushes an edit of the flat file into rules/fe/style.md, without the Kiro frontmatter', async () => { + // As a pull does: the delivery record proves the flat copy is teamai's (#946). + const ledger = openLedger({}); + await handler.pullAllRules(teamConfig, localConfig, undefined, [], ledger); + await saveStateForScope({ + ...await loadStateForScope(localConfig), + lastPullByWorkspace: { [await checkoutKey(homeDir)]: { rev: 'r1', targets: [], delivered: ledger.hashes } }, + }, localConfig); + const copy = path.join(userSteering(), 'fe.style.md'); + await fse.writeFile(copy, KIRO_NS.replace('Namespaced rule.', 'Edited namespaced rule.')); + + const items = await handler.scanLocalForPush(teamConfig, localConfig); + expect(items).toMatchObject([ + { name: 'fe/style', status: 'modified', sourcePath: copy, relativePath: 'rules/fe/style.md', namespace: 'fe' }, + ]); + await handler.pushItem(items[0], teamConfig, localConfig); + + expect(await fse.readFile(path.join(repoPath, 'rules', 'fe', 'style.md'), 'utf8')).toBe('Edited namespaced rule.\n'); + }); +}); diff --git a/src/__tests__/omp-rule-parser.test.ts b/src/__tests__/omp-rule-parser.test.ts new file mode 100644 index 000000000..cf4009268 --- /dev/null +++ b/src/__tests__/omp-rule-parser.test.ts @@ -0,0 +1,64 @@ +import os from 'node:os'; +import path from 'node:path'; +import fse from 'fs-extra'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { teamRuleToOmpRule } from '../resources/omp-rule.js'; +import { bucketRules, loadRulesDir } from './fixtures/rule-parsers/omp/rules.js'; + +/** + * The OMP render read back by Oh My Pi's own rule parser, vendored from 18.2.1 + * (fixtures/rule-parsers/omp), so this runs everywhere, CI included (#946). + */ +describe('Oh My Pi reads the rule render as intended', () => { + let dir: string; + + beforeEach(async () => { + dir = await fse.mkdtemp(path.join(os.tmpdir(), 'teamai-omp-parser-')); + }); + + afterEach(async () => { + await fse.remove(dir); + }); + + const BODY = 'Use named exports.\n\n---\n\nA rule with a horizontal rule in it.'; + + it.each([ + ['an inline list', `---\npaths: ["src/**/*.ts", "test/**"]\n---\n\n${BODY}`, ['src/**/*.ts', 'test/**']], + ['a block list', `---\npaths:\n - "src/**/*.ts"\n - test/**\n---\n\n${BODY}`, ['src/**/*.ts', 'test/**']], + ['a brace glob', `---\npaths:\n - "src/{a,b}/**"\n---\n\n${BODY}`, ['src/{a,b}/**']], + ['an unquoted alias-like glob', `---\npaths: **/*.ts\n---\n\n${BODY}`, ['**/*.ts']], + ])('offers a rule scoped by %s in the rulebook with its globs', async (_label, source, globs) => { + await fse.writeFile(path.join(dir, 'scoped.md'), teamRuleToOmpRule(source)); + + const { alwaysApplyRules, rulebookRules } = bucketRules(loadRulesDir(dir)); + + expect(alwaysApplyRules).toEqual([]); + expect(rulebookRules).toMatchObject([ + { name: 'scoped', globs, description: `Team rule for files matching ${globs.join(', ')}`, content: BODY }, + ]); + }); + + it('always applies an unscoped rule', async () => { + await fse.writeFile(path.join(dir, 'plain.md'), teamRuleToOmpRule(`${BODY}\n`)); + + const { alwaysApplyRules, rulebookRules } = bucketRules(loadRulesDir(dir)); + + expect(alwaysApplyRules).toMatchObject([{ name: 'plain', content: BODY }]); + expect(rulebookRules).toEqual([]); + }); + + it('loads only the top level of its rules directory', async () => { + await fse.outputFile(path.join(dir, 'fe.style.md'), teamRuleToOmpRule('Flat.\n')); + await fse.outputFile(path.join(dir, 'fe', 'style.md'), teamRuleToOmpRule('Nested.\n')); + + expect(loadRulesDir(dir).map((rule) => rule.name)).toEqual(['fe.style']); + }); + + it('drops a rule with neither alwaysApply nor a description, which a verbatim scoped copy is', async () => { + await fse.writeFile(path.join(dir, 'verbatim.md'), `---\npaths: ["src/**"]\n---\n\n${BODY}`); + + const { alwaysApplyRules, rulebookRules } = bucketRules(loadRulesDir(dir)); + + expect([...alwaysApplyRules, ...rulebookRules]).toEqual([]); + }); +}); diff --git a/src/__tests__/omp.test.ts b/src/__tests__/omp.test.ts index eea5eda12..1532cb7f2 100644 --- a/src/__tests__/omp.test.ts +++ b/src/__tests__/omp.test.ts @@ -1,7 +1,7 @@ import os from 'node:os'; import path from 'node:path'; import fse from 'fs-extra'; -import { afterEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { KNOWN_AGENTS } from '../known-agents.js'; import { resolveMcpTargets } from '../mcp-reconcile.js'; import { @@ -10,8 +10,13 @@ import { } from '../resources/agent-format.js'; import { AgentsHandler } from '../resources/agents.js'; import { detectMcpFormat } from '../resources/mcp-format.js'; -import { ruleFileExtensionForTool, usesCursorMdcRules } from '../resources/rule-format.js'; +import { ruleFileExtensionForTool, usesMdcRules } from '../resources/rule-format.js'; import { RulesHandler } from '../resources/rules.js'; +import { checkoutKey } from '../pull.js'; +import { openLedger, recordDelivered, type DeliveredHashes } from '../resources/delivered-copies.js'; +import { log } from '../utils/logger.js'; +import { resetWarnOnce } from '../utils/warn-once.js'; +import { loadStateForScope, saveStateForScope } from '../config.js'; import { TeamaiConfigSchema, scopedToolPaths } from '../types.js'; import type { LocalConfig, ResourceItem } from '../types.js'; @@ -55,7 +60,7 @@ describe('OMP (Oh My Pi) support', () => { expect(ALL_SUPPORTED_TOOLS).toContain('omp'); expect(agentFileExtensionForTool('omp')).toBe('.md'); expect(ruleFileExtensionForTool('omp')).toBe('.md'); - expect(usesCursorMdcRules('omp')).toBe(false); + expect(usesMdcRules('omp')).toBe(false); }); it('uses the mcpServers JSON format in the OMP agent dir', () => { @@ -116,7 +121,8 @@ describe('OMP rules directory is user-owned', () => { expect(await fse.readFile(path.join(homeDir, '.omp/agent/rules/notes.md'), 'utf8')).toBe('Personal rule.'); expect(await fse.readFile(path.join(homeDir, '.omp/agent/rules/nested/private.md'), 'utf8')).toBe('Personal nested rule.'); - expect(await fse.readFile(path.join(homeDir, '.omp/agent/rules/team.md'), 'utf8')).toBe('Team rule.'); + // OMP's own render of the team rule (#946). + expect(await fse.readFile(path.join(homeDir, '.omp/agent/rules/team.md'), 'utf8')).toBe('---\nalwaysApply: true\n---\n\nTeam rule.\n'); } finally { vi.unstubAllEnvs(); await fse.remove(tmp); @@ -232,3 +238,275 @@ describe('OMP receives legacy markdown team agents', () => { } }); }); + +describe('OMP gets its own rule render, namespaced rules flat (#946)', () => { + let tmp: string; + let homeDir: string; + let repoPath: string; + let projectRoot: string; + let localConfig: LocalConfig; + const handler = new RulesHandler(); + const teamConfig = TeamaiConfigSchema.parse({ team: 'test', repo: 'test/repo' }); + + const SCOPED = '---\npaths:\n - "src/**"\n---\n\nUse named exports.\n'; + const OMP_SCOPED = '---\ndescription: "Team rule for files matching src/**"\nglobs: ["src/**"]\n---\n\nUse named exports.\n'; + const NS = 'Namespaced rule.\n'; + const OMP_NS = '---\nalwaysApply: true\n---\n\nNamespaced rule.\n'; + const userRules = () => path.join(homeDir, '.omp/agent/rules'); + + beforeEach(async () => { + tmp = await fse.mkdtemp(path.join(os.tmpdir(), 'teamai-omp-render-')); + homeDir = path.join(tmp, 'home'); + repoPath = path.join(tmp, 'repo'); + projectRoot = path.join(tmp, 'project'); + await fse.outputFile(path.join(repoPath, 'rules', 'scoped.md'), SCOPED); + await fse.outputFile(path.join(repoPath, 'rules', 'fe', 'style.md'), NS); + await fse.ensureDir(userRules()); + await fse.ensureDir(path.join(homeDir, '.claude/rules')); + vi.stubEnv('HOME', homeDir); + resetWarnOnce(); + localConfig = { + repo: { localPath: repoPath, remote: 'test/repo' }, + username: 'test', + scope: 'user', + additionalRoles: [], + enabledAgents: ['omp', 'claude'], + } as unknown as LocalConfig; + }); + + afterEach(async () => { + vi.unstubAllEnvs(); + vi.restoreAllMocks(); + await fse.remove(tmp); + }); + + it('writes the OMP render to ~/.omp/agent/rules, a namespaced rule at the top level', async () => { + await handler.pullAllRules(teamConfig, localConfig); + + expect(await fse.readFile(path.join(userRules(), 'scoped.md'), 'utf8')).toBe(OMP_SCOPED); + expect(await fse.readFile(path.join(userRules(), 'fe.style.md'), 'utf8')).toBe(OMP_NS); + expect(await fse.pathExists(path.join(userRules(), 'fe'))).toBe(false); + // Claude reads its rules directory recursively, so its copy stays nested. + expect(await fse.readFile(path.join(homeDir, '.claude/rules/fe/style.md'), 'utf8')).toBe(NS); + }); + + it('writes the same to .omp/rules in project scope', async () => { + await fse.ensureDir(path.join(projectRoot, '.omp')); + localConfig.scope = 'project'; + localConfig.projectRoot = projectRoot; + + await handler.pullAllRules(teamConfig, localConfig); + + expect(await fse.readFile(path.join(projectRoot, '.omp/rules/scoped.md'), 'utf8')).toBe(OMP_SCOPED); + expect(await fse.readFile(path.join(projectRoot, '.omp/rules/fe.style.md'), 'utf8')).toBe(OMP_NS); + }); + + it('keeps the flat copy on the next pull, which has it on record', async () => { + const first = openLedger({}); + await handler.pullAllRules(teamConfig, localConfig, undefined, [], first); + + await handler.pullAllRules(teamConfig, localConfig, undefined, [], openLedger(first.hashes)); + + expect(await fse.readFile(path.join(userRules(), 'fe.style.md'), 'utf8')).toBe(OMP_NS); + }); + + it('a clean pull leaves nothing to push', async () => { + await handler.pullAllRules(teamConfig, localConfig); + + expect(await handler.scanLocalForPush(teamConfig, localConfig)).toEqual([]); + }); + + it('pushes an edit of a flat copy into rules//.md, without the OMP frontmatter', async () => { + // As a pull does: the delivery record proves the flat copy is teamai's (#946). + const ledger = openLedger({}); + await handler.pullAllRules(teamConfig, localConfig, undefined, [], ledger); + await saveStateForScope({ + ...await loadStateForScope(localConfig), + lastPullByWorkspace: { [await checkoutKey(homeDir)]: { rev: 'r1', targets: [], delivered: ledger.hashes } }, + }, localConfig); + // Only the OMP copy is edited; Claude's stays as delivered. + const copy = path.join(userRules(), 'fe.style.md'); + await fse.writeFile(copy, OMP_NS.replace('Namespaced rule.', 'Edited namespaced rule.')); + + const items = await handler.scanLocalForPush(teamConfig, localConfig); + expect(items).toMatchObject([ + { name: 'fe/style', status: 'modified', sourcePath: copy, relativePath: 'rules/fe/style.md', namespace: 'fe' }, + ]); + await handler.pushItem(items[0], teamConfig, localConfig); + + expect(await fse.readFile(path.join(repoPath, 'rules', 'fe', 'style.md'), 'utf8')).toBe('Edited namespaced rule.\n'); + }); + + it('reclaims the nested copy an older teamai delivered, and keeps one the member edited', async () => { + // What an older teamai wrote: the team rules verbatim, namespaced ones nested. + const nested = path.join(userRules(), 'fe', 'style.md'); + const editedNested = path.join(userRules(), 'be', 'api.md'); + await fse.outputFile(path.join(repoPath, 'rules', 'be', 'api.md'), 'Backend rule.\n'); + await fse.outputFile(nested, NS); + await fse.outputFile(editedNested, 'Backend rule.\n'); + const previous: DeliveredHashes = {}; + await recordDelivered(previous, nested); + await recordDelivered(previous, editedNested); + await fse.writeFile(editedNested, 'My own backend wording.\n'); + const ledger = openLedger(previous); + + await handler.pullAllRules(teamConfig, localConfig, undefined, [], ledger); + + expect(await fse.pathExists(path.join(userRules(), 'fe'))).toBe(false); + expect(ledger.hashes[nested]).toBeUndefined(); + expect(await fse.readFile(editedNested, 'utf8')).toBe('My own backend wording.\n'); + expect(await fse.readFile(path.join(userRules(), 'fe.style.md'), 'utf8')).toBe(OMP_NS); + expect(await fse.readFile(path.join(userRules(), 'be.api.md'), 'utf8')).toBe('---\nalwaysApply: true\n---\n\nBackend rule.\n'); + }); + + it('does not own a flat copy on record that the member edited since (#946)', async () => { + await handler.pullAllRules(teamConfig, localConfig); + const flat = path.join(userRules(), 'fe.style.md'); + const previous: DeliveredHashes = {}; + await recordDelivered(previous, flat); + await fse.writeFile(flat, OMP_NS.replace('Namespaced rule.', 'My own wording.')); + + const copies = await handler.ownedFlatCopies(teamConfig, localConfig, await handler.scanTeamForPull(teamConfig, localConfig), previous); + + expect(copies).toEqual({ owned: [], edited: [{ tool: 'omp', file: flat }] }); + }); + + it('removes the flat copy when the namespaced rule is removed', async () => { + await handler.pullAllRules(teamConfig, localConfig); + + const removed = await handler.removeItem('fe/style', teamConfig, localConfig); + + expect(removed).toContain(path.join(userRules(), 'fe.style.md')); + expect(await fse.pathExists(path.join(userRules(), 'fe.style.md'))).toBe(false); + }); + + it('leaves a namespaced rule out when a root rule has its flat name, and names both', async () => { + await fse.outputFile(path.join(repoPath, 'rules', 'fe.style.md'), 'Root rule.\n'); + const warn = vi.spyOn(log, 'warn'); + + await handler.pullAllRules(teamConfig, localConfig); + + expect(await fse.readFile(path.join(userRules(), 'fe.style.md'), 'utf8')).toBe('---\nalwaysApply: true\n---\n\nRoot rule.\n'); + expect(warn.mock.calls.map(([message]) => String(message))).toContain( + 'Skipped rule fe/style for omp: omp reads only the top level of its rules directory, where its file would be ' + + 'fe.style.md, which is the root rule fe.style. Rename one of them in the team repo.', + ); + }); + + it('writes neither of two namespaced rules with the same flat name, and says why', async () => { + await fse.outputFile(path.join(repoPath, 'rules', 'fe.style', 'x.md'), 'Other.\n'); + await fse.outputFile(path.join(repoPath, 'rules', 'fe', 'style.x.md'), 'One.\n'); + const warn = vi.spyOn(log, 'warn'); + + await handler.pullAllRules(teamConfig, localConfig); + + expect(await fse.pathExists(path.join(userRules(), 'fe.style.x.md'))).toBe(false); + expect(warn.mock.calls.map(([message]) => String(message))).toContain( + 'Skipped rules fe.style/x and fe/style.x for omp: omp reads only the top level of its rules directory, where ' + + 'both would be fe.style.x.md, so neither is written. Rename one of them in the team repo.', + ); + }); + + it('judges a flat-name clash only among the rules this member receives', async () => { + await fse.outputFile(path.join(repoPath, 'rules', 'fe.style', 'x.md'), 'Inactive namespace.\n'); + await fse.outputFile(path.join(repoPath, 'rules', 'fe', 'style.x.md'), 'Active namespace.\n'); + await fse.outputFile(path.join(repoPath, 'manifest', 'roles.yaml'), [ + 'version: 1', 'roles:', ' - id: dev', ' resources:', ' knowledge: [fe]', ' skills: []', + ' learnings: []', ' agents: []', '', + ].join('\n')); + localConfig.primaryRole = 'dev'; + const { buildRolePullContext, resolveDesiredRules } = await import('../resources/desired.js'); + const { items } = await resolveDesiredRules(teamConfig, localConfig, await buildRolePullContext(localConfig)); + + await handler.pullAllRules(teamConfig, localConfig, items); + + expect(await fse.readFile(path.join(userRules(), 'fe.style.x.md'), 'utf8')).toBe('---\nalwaysApply: true\n---\n\nActive namespace.\n'); + }); + + it('pushes the author\'s root copy of a rule they placed in a namespace, and leaves no flat copy beside it', async () => { + const state = await loadStateForScope(localConfig); + state.placedRules = { style: 'rules/fe/style.md' }; + await saveStateForScope(state, localConfig); + // A flat copy an earlier pull wrote before the placement was recorded. + await fse.outputFile(path.join(userRules(), 'fe.style.md'), OMP_NS); + + await handler.pullAllRules(teamConfig, localConfig); + + const authorCopy = path.join(userRules(), 'style.md'); + expect(await fse.readFile(authorCopy, 'utf8')).toBe(OMP_NS); + expect(await fse.pathExists(path.join(userRules(), 'fe.style.md'))).toBe(false); + await fse.writeFile(authorCopy, OMP_NS.replace('Namespaced rule.', 'Edited by the author.')); + const items = await handler.scanLocalForPush(teamConfig, localConfig); + expect(items.filter((item) => item.sourcePath === authorCopy)).toMatchObject([ + { name: 'style', relativePath: 'rules/fe/style.md', status: 'modified' }, + ]); + }); + + it('keeps a file of the author\'s own that has the flat name of a rule they placed in a namespace (#946)', async () => { + const state = await loadStateForScope(localConfig); + state.placedRules = { style: 'rules/fe/style.md' }; + await saveStateForScope(state, localConfig); + // Not on record and not the render: the member wrote it. + const own = path.join(userRules(), 'fe.style.md'); + await fse.outputFile(own, 'My own frontend notes.\n'); + + await handler.pullAllRules(teamConfig, localConfig, undefined, [], openLedger({})); + + expect(await fse.readFile(path.join(userRules(), 'style.md'), 'utf8')).toBe(OMP_NS); + expect(await fse.readFile(own, 'utf8')).toBe('My own frontend notes.\n'); + }); + + it('reclaims an unrecorded nested copy an older teamai wrote verbatim, on a machine with no delivery record', async () => { + const nested = path.join(userRules(), 'fe', 'style.md'); + await fse.outputFile(nested, NS); + + await handler.pullAllRules(teamConfig, localConfig, undefined, [], openLedger(undefined)); + + expect(await fse.pathExists(nested)).toBe(false); + expect(await fse.readFile(path.join(userRules(), 'fe.style.md'), 'utf8')).toBe(OMP_NS); + }); + + it('keeps an edited nested copy and says OMP does not read it', async () => { + const nested = path.join(userRules(), 'fe', 'style.md'); + await fse.outputFile(nested, 'My own wording.\n'); + const warn = vi.spyOn(log, 'warn'); + + await handler.pullAllRules(teamConfig, localConfig, undefined, [], openLedger({})); + + expect(await fse.readFile(nested, 'utf8')).toBe('My own wording.\n'); + expect(warn.mock.calls.map(([message]) => String(message))).toContain( + `Kept ${nested}: omp reads only the top level of its rules directory, so it does not read this copy of fe/style, ` + + `which teamai now delivers as ${path.join(userRules(), 'fe.style.md')}. To keep your edit, copy it into that ` + + 'file and share it with `teamai push`; then delete this one.', + ); + }); + + it("does not overwrite a member's own file that has a namespaced rule's flat name", async () => { + const mine = path.join(userRules(), 'fe.style.md'); + await fse.outputFile(mine, 'My own dotted rule.\n'); + const warn = vi.spyOn(log, 'warn'); + + await handler.pullAllRules(teamConfig, localConfig, undefined, [], openLedger({})); + + expect(await fse.readFile(mine, 'utf8')).toBe('My own dotted rule.\n'); + expect(warn.mock.calls.map(([message]) => String(message))).toContain( + `Kept ${mine}: teamai did not write it, and it is where omp would read team rule fe/style. ` + + 'Rename your file, then run `teamai pull --force`.', + ); + }); + + it("removes a tombstoned rule's flat copy only on record, not a member's own file of that name", async () => { + await fse.outputFile(path.join(repoPath, 'rules', '.removed'), 'be/api\nfe/old\n'); + const recorded = path.join(userRules(), 'fe.old.md'); + const mine = path.join(userRules(), 'be.api.md'); + await fse.outputFile(recorded, '---\nalwaysApply: true\n---\n\nOld.\n'); + await fse.outputFile(mine, 'My own dotted rule.\n'); + const previous: DeliveredHashes = {}; + await recordDelivered(previous, recorded); + + await handler.pullAllRules(teamConfig, localConfig, undefined, [], openLedger(previous)); + + expect(await fse.pathExists(recorded)).toBe(false); + expect(await fse.readFile(mine, 'utf8')).toBe('My own dotted rule.\n'); + }); +}); diff --git a/src/__tests__/openclaw-hooks.test.ts b/src/__tests__/openclaw-hooks.test.ts index 6d7eff53a..4c786ef1d 100644 --- a/src/__tests__/openclaw-hooks.test.ts +++ b/src/__tests__/openclaw-hooks.test.ts @@ -2,9 +2,15 @@ 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 { injectOpenClawHooks, removeOpenClawHooks, OPENCLAW_HOOK_DIR } from '../openclaw-hooks.js'; +import { pathToFileURL } from 'node:url'; +import { transformSync } from 'esbuild'; +import { parseDocument } from 'yaml'; +import { + applyOpenClawAgentHook, removeOpenClawAgentHook, injectOpenClawHooks, removeOpenClawHooks, resolveOpenclawWorkspaceDir, OPENCLAW_HOOK_DIR, +} from '../openclaw-hooks.js'; import { reconcileHooksToAllTools } from '../hooks.js'; import { log } from '../utils/logger.js'; +import { OPENCLAW_EVENT_KEYS, openclawEvents, type OpenClawHookEvent } from './fixtures/openclaw/events.js'; let tmpDir: string; let wsDir: string; @@ -40,23 +46,10 @@ describe('injectOpenClawHooks', () => { expect(hookMd).toContain('metadata:'); expect(hookMd).toContain('"openclaw"'); - expect(hookMd).toContain('session:start'); - expect(hookMd).toContain('command:new'); expect(handler).toContain('hook-dispatch'); expect(handler).toContain('openclaw'); - // Maps OpenClaw events to teamai dispatch events. - expect(handler).toContain('session-start'); - expect(handler).toContain('prompt-submit'); }); - it('enables hooks.internal.enabled in openclaw.json, preserving existing fields', async () => { - await injectOpenClawHooks(wsDir, 'openclaw'); - - const cfg = JSON.parse(fs.readFileSync(path.join(tmpDir, 'openclaw.json'), 'utf-8')); - expect(cfg.hooks.internal.enabled).toBe(true); - // Deep-merge must not clobber pre-existing fields. - expect(cfg.agents.defaults.workspace).toBe(wsDir); - }); it('is idempotent (re-inject overwrites cleanly)', async () => { await injectOpenClawHooks(wsDir, 'openclaw'); @@ -80,6 +73,327 @@ describe('injectOpenClawHooks', () => { }); }); +/** HOOK.md's frontmatter, parsed the way OpenClaw's loader parses it (YAML core schema). */ +function readHookFrontmatter(dir: string): { errors: string[]; data: Record } { + const raw = fs.readFileSync(path.join(dir, 'HOOK.md'), 'utf-8'); + const block = /^---\n([\s\S]*?)\n---/.exec(raw)?.[1] ?? ''; + const doc = parseDocument(block, { schema: 'core', prettyErrors: false }); + return { errors: doc.errors.map((e) => e.message), data: (doc.toJS() ?? {}) as Record }; +} + +describe('injectOpenClawHooks enables the hook in openclaw.json', () => { + const cfgPath = (): string => path.join(tmpDir, 'openclaw.json'); + const writeCfg = (cfg: unknown): void => fs.writeFileSync(cfgPath(), JSON.stringify(cfg, null, 2)); + const readCfg = (): Record => JSON.parse(fs.readFileSync(cfgPath(), 'utf-8')); + + let warn: ReturnType; + beforeEach(() => { warn = vi.spyOn(log, 'warn').mockImplementation(() => {}); }); + afterEach(() => { warn.mockRestore(); }); + + it('adds the entry when the config has no master flag, keeping every other field', async () => { + await injectOpenClawHooks(wsDir, 'openclaw'); + + const cfg = readCfg(); + // Workspace hooks load only with their own entry enabled. + expect(cfg.hooks.internal.entries['teamai-status-report']).toEqual({ enabled: true }); + // The entry alone enables it; the master flag stays unset so removing the + // entry restores the config exactly. + expect(cfg.hooks.internal.enabled).toBeUndefined(); + expect(cfg.agents.defaults.workspace).toBe(wsDir); + expect(warn).not.toHaveBeenCalled(); + }); + + it('adds the entry beside existing named entries', async () => { + writeCfg({ + agents: { defaults: { workspace: wsDir } }, + hooks: { internal: { enabled: true, entries: { 'session-memory': { enabled: true, env: { A: '1' } } } } }, + }); + + await injectOpenClawHooks(wsDir, 'openclaw'); + + expect(readCfg().hooks.internal).toEqual({ + enabled: true, + entries: { + 'session-memory': { enabled: true, env: { A: '1' } }, + 'teamai-status-report': { enabled: true }, + }, + }); + }); + + it('is a no-op once the entry is enabled', async () => { + await injectOpenClawHooks(wsDir, 'openclaw'); + const before = fs.readFileSync(cfgPath(), 'utf-8'); + const mtime = fs.statSync(cfgPath()).mtimeMs; + + await injectOpenClawHooks(wsDir, 'openclaw'); + + expect(fs.readFileSync(cfgPath(), 'utf-8')).toBe(before); + expect(fs.statSync(cfgPath()).mtimeMs).toBe(mtime); + }); + + it.each([ + ['open-ended discovery (master flag on, no named entries)', { enabled: true }, 'allowlist'], + ['the entry disabled', { entries: { 'teamai-status-report': { enabled: false } } }, 'disabled'], + ['internal hooks switched off', { enabled: false }, 'switched off'], + ])('leaves the config unchanged and warns with %s', async (_label, internal, wording) => { + writeCfg({ agents: { defaults: { workspace: wsDir } }, hooks: { internal } }); + const before = fs.readFileSync(cfgPath(), 'utf-8'); + + await injectOpenClawHooks(wsDir, 'openclaw'); + + expect(fs.readFileSync(cfgPath(), 'utf-8')).toBe(before); + expect(warn).toHaveBeenCalledWith(expect.stringContaining(wording)); + expect(warn).toHaveBeenCalledWith(expect.stringContaining('openclaw hooks enable teamai-status-report')); + }); + + it('leaves a config it cannot parse unchanged and warns', async () => { + const json5 = '{\n // comment\n agents: { defaults: { workspace: "x" } },\n}\n'; + fs.writeFileSync(cfgPath(), json5); + + await injectOpenClawHooks(wsDir, 'openclaw'); + + expect(fs.readFileSync(cfgPath(), 'utf-8')).toBe(json5); + expect(warn).toHaveBeenCalledWith(expect.stringContaining('openclaw hooks enable teamai-status-report')); + }); + + it('creates no state dir that OpenClaw does not have', async () => { + process.env.OPENCLAW_STATE_DIR = path.join(tmpDir, 'missing-state'); + + await injectOpenClawHooks(wsDir, 'openclaw'); + + expect(fs.existsSync(path.join(tmpDir, 'missing-state'))).toBe(false); + }); +}); + +describe('the OpenClaw workspace and state dir follow OpenClaw\'s resolution', () => { + let home: string; + beforeEach(() => { + home = path.join(tmpDir, 'home'); + fs.mkdirSync(home, { recursive: true }); + vi.stubEnv('HOME', home); + delete process.env.OPENCLAW_STATE_DIR; + }); + afterEach(() => { vi.unstubAllEnvs(); }); + + it('uses ~/.openclaw- for OPENCLAW_PROFILE, for the hook and its entry', async () => { + vi.stubEnv('OPENCLAW_PROFILE', 'work'); + const state = path.join(home, '.openclaw-work'); + fs.mkdirSync(path.join(state, 'workspace'), { recursive: true }); + fs.mkdirSync(path.join(home, '.openclaw', 'workspace'), { recursive: true }); + + expect(await resolveOpenclawWorkspaceDir()).toBe(path.join(state, 'workspace')); + await injectOpenClawHooks(undefined, 'openclaw'); + + expect(fs.existsSync(path.join(state, 'workspace', 'hooks', OPENCLAW_HOOK_DIR, 'handler.ts'))).toBe(true); + const cfg = JSON.parse(fs.readFileSync(path.join(state, 'openclaw.json'), 'utf-8')); + expect(cfg.hooks.internal.entries['teamai-status-report'].enabled).toBe(true); + expect(fs.existsSync(path.join(home, '.openclaw', 'openclaw.json'))).toBe(false); + }); + + it('treats the "default" profile as no profile', async () => { + vi.stubEnv('OPENCLAW_PROFILE', 'default'); + fs.mkdirSync(path.join(home, '.openclaw', 'workspace'), { recursive: true }); + + expect(await resolveOpenclawWorkspaceDir()).toBe(path.join(home, '.openclaw', 'workspace')); + }); + + it('uses OPENCLAW_WORKSPACE_DIR over the state dir default', async () => { + const custom = path.join(tmpDir, 'custom-ws'); + fs.mkdirSync(custom, { recursive: true }); + fs.mkdirSync(path.join(home, '.openclaw', 'workspace'), { recursive: true }); + vi.stubEnv('OPENCLAW_WORKSPACE_DIR', custom); + + expect(await resolveOpenclawWorkspaceDir()).toBe(custom); + }); + + it('uses agents.defaults.workspace from the config over OPENCLAW_WORKSPACE_DIR, as OpenClaw does', async () => { + const configured = path.join(tmpDir, 'configured-ws'); + const custom = path.join(tmpDir, 'custom-ws'); + fs.mkdirSync(configured, { recursive: true }); + fs.mkdirSync(custom, { recursive: true }); + fs.mkdirSync(path.join(home, '.openclaw'), { recursive: true }); + fs.writeFileSync(path.join(home, '.openclaw', 'openclaw.json'), JSON.stringify({ agents: { defaults: { workspace: configured } } })); + vi.stubEnv('OPENCLAW_WORKSPACE_DIR', custom); + + expect(await resolveOpenclawWorkspaceDir()).toBe(configured); + }); + + it('uses /workspace when the state dir is set', async () => { + const state = path.join(tmpDir, 'state'); + fs.mkdirSync(path.join(state, 'workspace'), { recursive: true }); + vi.stubEnv('OPENCLAW_STATE_DIR', state); + vi.stubEnv('OPENCLAW_PROFILE', 'work'); + + expect(await resolveOpenclawWorkspaceDir()).toBe(path.join(state, 'workspace')); + }); + + it('returns null when the workspace OpenClaw would use does not exist', async () => { + // Another workspace existing does not make it the one OpenClaw reads. + vi.stubEnv('OPENCLAW_PROFILE', 'work'); + fs.mkdirSync(path.join(home, '.openclaw', 'workspace'), { recursive: true }); + + expect(await resolveOpenclawWorkspaceDir()).toBeNull(); + }); +}); + +describe('HOOK.md', () => { + it('parses as YAML and subscribes only to events OpenClaw emits', async () => { + await injectOpenClawHooks(wsDir, 'openclaw'); + const { errors, data } = readHookFrontmatter(path.join(wsDir, 'hooks', OPENCLAW_HOOK_DIR)); + + // A frontmatter error marks the hook's metadata invalid, and OpenClaw then + // refuses to load it. + expect(errors).toEqual([]); + const openclaw = (data.metadata as { openclaw: { events: string[]; hookKey: string } }).openclaw; + expect(openclaw.events.length).toBeGreaterThan(0); + for (const key of openclaw.events) expect(OPENCLAW_EVENT_KEYS).toContain(key); + expect(openclaw.events).not.toContain('session:start'); + // The key `hooks.internal.entries.` enables it under. + expect(openclaw.hookKey).toBe('teamai-status-report'); + }); +}); + +describe('the generated handler', () => { + let binDir: string; + let logFile: string; + let origPath: string | undefined; + + beforeEach(() => { + // A `teamai` first on PATH that records its argv and stdin, one line each call. + binDir = path.join(tmpDir, 'bin'); + logFile = path.join(tmpDir, 'teamai-calls.log'); + fs.mkdirSync(binDir, { recursive: true }); + fs.writeFileSync( + path.join(binDir, 'teamai'), + `#!/bin/sh\nstdin=$(cat)\nprintf '%s\\t%s\\n' "$*" "$stdin" >> "${logFile}"\n`, + { mode: 0o755 }, + ); + origPath = process.env.PATH; + process.env.PATH = `${binDir}${path.delimiter}${origPath ?? ''}`; + }); + + afterEach(() => { + process.env.PATH = origPath; + }); + + /** Transpile handler.ts the way a TypeScript-stripping loader would, beside the original. */ + async function loadHandler(): Promise<(event: unknown) => Promise> { + await injectOpenClawHooks(wsDir, 'openclaw'); + const dir = path.join(wsDir, 'hooks', OPENCLAW_HOOK_DIR); + const { code } = transformSync(fs.readFileSync(path.join(dir, 'handler.ts'), 'utf-8'), { loader: 'ts', format: 'esm' }); + const out = path.join(dir, 'handler.test-build.mjs'); + fs.writeFileSync(out, code); + const mod = await import(pathToFileURL(out).href) as { default: (event: unknown) => Promise }; + return mod.default; + } + + function calls(): Array<{ argv: string; stdin: Record }> { + if (!fs.existsSync(logFile)) return []; + return fs.readFileSync(logFile, 'utf-8').trim().split('\n').filter(Boolean).map((line) => { + const [argv, stdin] = line.split('\t'); + return { argv, stdin: JSON.parse(stdin) as Record }; + }); + } + + /** Wait until the shim has logged `n` calls, then a little longer to catch extras. */ + async function settle(n: number): Promise { + await vi.waitFor(() => expect(calls().length).toBeGreaterThanOrEqual(n), { timeout: 5000, interval: 25 }); + await new Promise((r) => setTimeout(r, 200)); + } + + const workspace = (): string => path.join(tmpDir, 'agent-ws'); + + it.each([ + ['command:new', 'session-start'], + ['command:reset', 'session-start'], + ['session:auto-reset', 'session-start'], + ['gateway:startup', 'session-start'], + ])('runs hook-dispatch for %s in the event\'s workspace', async (key, dispatch) => { + const handler = await loadHandler(); + const event = openclawEvents(workspace())[key]; + + await handler(event); + await settle(1); + + expect(calls()).toEqual([{ + argv: `hook-dispatch ${dispatch} --tool openclaw`, + stdin: { cwd: workspace(), session_id: event.sessionKey }, + }]); + }); + + it('runs prompt-submit for message:received in the hook\'s own workspace, which the event does not carry', async () => { + const handler = await loadHandler(); + + await handler(openclawEvents(workspace())['message:received']); + await settle(1); + + const [call] = calls(); + expect(calls()).toHaveLength(1); + expect(call.argv).toBe('hook-dispatch prompt-submit --tool openclaw'); + expect(fs.realpathSync(call.stdin.cwd as string)).toBe(fs.realpathSync(wsDir)); + }); + + it('runs nothing for unmapped events or an event without type and action', async () => { + const handler = await loadHandler(); + const events = openclawEvents(workspace()); + const ignored: unknown[] = [events['command:stop'], events['message:sent'], {}, { context: {} }, undefined]; + + for (const event of ignored) await handler(event as OpenClawHookEvent); + // A mapped event last, so the wait proves the earlier ones had their chance. + await handler(events['command:new']); + await settle(1); + + expect(calls().map((c) => c.argv)).toEqual(['hook-dispatch session-start --tool openclaw']); + }); +}); + +describe('applyOpenClawAgentHook', () => { + it.each([ + ['SessionStart', ['command:new', 'command:reset']], + ['UserPromptSubmit', ['message:received']], + ])('subscribes a %s hook to the events OpenClaw emits for it', async (event, expected) => { + await applyOpenClawAgentHook({ slug: 'team-check', event, command: 'echo hi' }); + + // Server-pushed hooks are managed hooks, under /hooks. + const { errors, data } = readHookFrontmatter(path.join(tmpDir, 'hooks', 'team-check')); + expect(errors).toEqual([]); + expect((data.metadata as { openclaw: { events: string[] } }).openclaw.events).toEqual(expected); + }); + + it('selects the hook in openclaw.json, so the teamai entry does not leave it out of the allowlist', async () => { + const cfgPath = path.join(tmpDir, 'openclaw.json'); + await injectOpenClawHooks(wsDir, 'openclaw'); + await applyOpenClawAgentHook({ slug: 'team-check', event: 'SessionStart', command: 'echo hi' }); + + const { data } = readHookFrontmatter(path.join(tmpDir, 'hooks', 'team-check')); + const hookKey = (data.metadata as { openclaw: { hookKey: string } }).openclaw.hookKey; + expect(JSON.parse(fs.readFileSync(cfgPath, 'utf-8')).hooks.internal.entries).toEqual({ + 'teamai-status-report': { enabled: true }, + [hookKey]: { enabled: true }, + }); + + await removeOpenClawAgentHook({ slug: 'team-check' }); + expect(fs.existsSync(path.join(tmpDir, 'hooks', 'team-check'))).toBe(false); + expect(JSON.parse(fs.readFileSync(cfgPath, 'utf-8')).hooks.internal.entries).toEqual({ + 'teamai-status-report': { enabled: true }, + }); + }); + + it('adds no entry where OpenClaw already loads every managed hook it discovers', async () => { + const cfgPath = path.join(tmpDir, 'openclaw.json'); + const cfg = { agents: { defaults: { workspace: wsDir } }, hooks: { internal: { enabled: true } } }; + fs.writeFileSync(cfgPath, JSON.stringify(cfg)); + const warn = vi.spyOn(log, 'warn').mockImplementation(() => {}); + try { + await applyOpenClawAgentHook({ slug: 'team-check', event: 'SessionStart', command: 'echo hi' }); + expect(JSON.parse(fs.readFileSync(cfgPath, 'utf-8'))).toEqual(cfg); + expect(warn).not.toHaveBeenCalled(); + } finally { + warn.mockRestore(); + } + }); +}); + describe('removeOpenClawHooks', () => { it('removes the injected hook dir and is a no-op when absent', async () => { const hooksDir = path.join(wsDir, 'hooks'); @@ -90,6 +404,21 @@ describe('removeOpenClawHooks', () => { // second removal does not throw await expect(removeOpenClawHooks(hooksDir)).resolves.toBeUndefined(); }); + + it('also removes the copy in the state dir a profile selects', async () => { + const home = path.join(tmpDir, 'home'); + const profileHook = path.join(home, '.openclaw-work', 'hooks', OPENCLAW_HOOK_DIR); + fs.mkdirSync(profileHook, { recursive: true }); + delete process.env.OPENCLAW_STATE_DIR; + vi.stubEnv('HOME', home); + vi.stubEnv('OPENCLAW_PROFILE', 'work'); + try { + await removeOpenClawHooks(path.join(wsDir, 'hooks')); + } finally { + vi.unstubAllEnvs(); + } + expect(fs.existsSync(profileHook)).toBe(false); + }); }); describe('reconcileHooksToAllTools routes the OpenClaw family to its adapter', () => { @@ -109,6 +438,31 @@ describe('reconcileHooksToAllTools routes the OpenClaw family to its adapter', ( expect(fs.existsSync(hookDir)).toBe(false); }); + it('removeAll takes the hook entry back out of openclaw.json', async () => { + const cfgPath = path.join(tmpDir, 'openclaw.json'); + const manifest = path.join(tmpDir, 'managed-hooks.json'); + const original = { agents: { defaults: { workspace: wsDir } }, hooks: { internal: { entries: { other: { enabled: true } } } } }; + fs.writeFileSync(cfgPath, JSON.stringify(original)); + + await reconcileHooksToAllTools(toolPaths, tmpDir, [], manifest); + expect(JSON.parse(fs.readFileSync(cfgPath, 'utf-8')).hooks.internal.entries['teamai-status-report']).toEqual({ enabled: true }); + + await reconcileHooksToAllTools(toolPaths, tmpDir, [], manifest, { removeAll: true }); + expect(JSON.parse(fs.readFileSync(cfgPath, 'utf-8'))).toEqual(original); + }); + + it('removeAll keeps the entry when removing it would turn on every discovered hook', async () => { + // Master flag on with this as the only named entry: without it OpenClaw + // would load every hook it discovers. + const cfgPath = path.join(tmpDir, 'openclaw.json'); + const cfg = { agents: { defaults: { workspace: wsDir } }, hooks: { internal: { enabled: true, entries: { 'teamai-status-report': { enabled: true } } } } }; + fs.writeFileSync(cfgPath, JSON.stringify(cfg)); + + await reconcileHooksToAllTools(toolPaths, tmpDir, [], path.join(tmpDir, 'managed-hooks.json'), { removeAll: true }); + + expect(JSON.parse(fs.readFileSync(cfgPath, 'utf-8'))).toEqual(cfg); + }); + it('does nothing when the workspace cannot be resolved', async () => { delete process.env.OPENCLAW_STATE_DIR; const home = path.join(tmpDir, 'empty-home'); diff --git a/src/__tests__/opencode-config.test.ts b/src/__tests__/opencode-config.test.ts index 00f5a29b1..f162f319e 100644 --- a/src/__tests__/opencode-config.test.ts +++ b/src/__tests__/opencode-config.test.ts @@ -7,7 +7,7 @@ vi.mock('../utils/logger.js', () => ({ log: { info: vi.fn(), success: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn(), dim: vi.fn() }, })); -import { reconcileOpencodeInstructions, opencodeRulesGlob } from '../resources/opencode-config.js'; +import { reconcileOpencodeInstructions, reconcileOpencodeInstructionSet, opencodeProjectRuleGlobs, opencodeRuleGlobs, opencodeRulesGlob } from '../resources/opencode-config.js'; describe('opencodeRulesGlob', () => { it('project scope: config at root, rules under .opencode/rules', () => { @@ -21,6 +21,66 @@ describe('opencodeRulesGlob', () => { }); }); +describe('opencodeRuleGlobs (#946)', () => { + const config = '/home/u/.config/opencode/opencode.json'; + const rules = '/home/u/.config/opencode/rules'; + + it('user scope: the absolute root glob plus one per namespace directory, root first', () => { + const { globs } = opencodeRuleGlobs(config, rules, [`${rules}/fe`, rules, `${rules}/be/api`, `${rules}/fe`], []); + expect(globs).toEqual([`${rules}/*.md`, `${rules}/be/api/*.md`, `${rules}/fe/*.md`]); + }); + + it('user scope: owns its globs, every team namespace\'s and the old relative one, not the member\'s', () => { + const { owns } = opencodeRuleGlobs(config, rules, [], [`${rules}/fe`]); + expect(owns(`${rules}/*.md`)).toBe(true); + expect(owns(`${rules}/fe/*.md`)).toBe(true); + expect(owns('rules/*.md')).toBe(true); + expect(owns(`${rules}/mine/*.md`)).toBe(false); + expect(owns('CONVENTIONS.md')).toBe(false); + }); +}); + +describe('opencodeProjectRuleGlobs (#946)', () => { + it('one recursive glob from the project root, in .opencode/opencode.json', () => { + const { configFile, globs, owns } = opencodeProjectRuleGlobs('/repo', '/repo/.opencode/rules', '/repo/opencode.json'); + expect(configFile).toBe(path.join('/repo', '.opencode', 'opencode.json')); + expect(globs).toEqual(['.opencode/rules/**/*.md']); + expect(owns('.opencode/rules/**/*.md')).toBe(true); + expect(owns('.opencode/rules/*.md')).toBe(false); + }); + + it('retires the glob earlier releases wrote to the root opencode.json', () => { + const { retired } = opencodeProjectRuleGlobs('/repo', '/repo/.opencode/rules', '/repo/opencode.json'); + expect(retired?.configFile).toBe('/repo/opencode.json'); + expect(retired?.owns('.opencode/rules/*.md')).toBe(true); + expect(retired?.owns('.opencode/rules/**/*.md')).toBe(false); + expect(opencodeProjectRuleGlobs('/repo', '/repo/.opencode/rules', null).retired).toBeNull(); + }); +}); + +describe('reconcileOpencodeInstructionSet (#946)', () => { + let tmpDir: string; + let configFile: string; + + beforeEach(async () => { + tmpDir = await fse.mkdtemp(path.join(os.tmpdir(), 'teamai-oc-set-')); + configFile = path.join(tmpDir, 'opencode.json'); + }); + + afterEach(async () => { + await fse.remove(tmpDir); + }); + + it('replaces owned entries with the desired ones in place of nothing else, and is idempotent', async () => { + await fse.writeJson(configFile, { model: 'm', instructions: ['A.md', 'old/*.md', { x: 1 }, 'keep.md'] }); + const owns = (entry: string): boolean => entry.endsWith('/*.md'); + + expect(await reconcileOpencodeInstructionSet(configFile, ['/abs/*.md', '/abs/ns/*.md'], owns)).toBe(true); + expect(await fse.readJson(configFile)).toEqual({ model: 'm', instructions: ['A.md', { x: 1 }, 'keep.md', '/abs/*.md', '/abs/ns/*.md'] }); + expect(await reconcileOpencodeInstructionSet(configFile, ['/abs/*.md', '/abs/ns/*.md'], owns)).toBe(false); + }); +}); + describe('reconcileOpencodeInstructions', () => { let tmpDir: string; let configFile: string; diff --git a/src/__tests__/pi-adapter.test.ts b/src/__tests__/pi-adapter.test.ts index d145121a2..7c1147837 100644 --- a/src/__tests__/pi-adapter.test.ts +++ b/src/__tests__/pi-adapter.test.ts @@ -9,11 +9,11 @@ describe('Pi adapter configuration', () => { mcp: '.pi/agent/mcp.json', mcpProject: '.pi/mcp.json', skills: '.pi/skills', - rules: '.pi/rules', claudemd: 'AGENTS.md', + // Pi reads no rules directory: its user rules are a block in + // ~/.pi/agent/AGENTS.md, its project rules come from teamai's extension (#946). userScope: { skills: '.pi/agent/skills', - rules: '.pi/agent/rules', claudemd: '.pi/agent/AGENTS.md', }, }); @@ -21,9 +21,10 @@ describe('Pi adapter configuration', () => { expect(scopedToolPaths(config, { scope: 'project' }).pi).toEqual(config.toolPaths.pi); expect(scopedToolPaths(config, { scope: 'user' }).pi).toMatchObject({ skills: '.pi/agent/skills', - rules: '.pi/agent/rules', claudemd: '.pi/agent/AGENTS.md', }); + expect(scopedToolPaths(config, { scope: 'user' }).pi).not.toHaveProperty('rules'); + expect(scopedToolPaths(config, { scope: 'project' }).pi).not.toHaveProperty('rules'); }); it('registers Pi for discovery and single-repo selection', () => { diff --git a/src/__tests__/post-pull.test.ts b/src/__tests__/post-pull.test.ts index 7442a4477..8d5858813 100644 --- a/src/__tests__/post-pull.test.ts +++ b/src/__tests__/post-pull.test.ts @@ -2,6 +2,8 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; +import { EventEmitter } from 'node:events'; +import { PassThrough } from 'node:stream'; import type { TeamaiConfig } from '../types.js'; @@ -59,29 +61,17 @@ function teamConfig(postPull?: { path: string }): TeamaiConfig { return { scripts: postPull ? { postPull } : undefined } as TeamaiConfig; } -/** A spawned script child that reports the given outcome once its listeners attach. */ +/** A script child whose output always arrives before close, regardless of worker load. */ function fakeScript(code: number, output = '') { - const handlers = new Map void)[]>(); - let scheduled = false; - const fire = (event: string, arg: unknown) => handlers.get(event)?.forEach((cb) => cb(arg)); - const stream = { - on(event: string, cb: (chunk: Buffer) => void) { - if (event === 'data' && output) setTimeout(() => cb(Buffer.from(output)), 0); - return stream; - }, - }; - const child = { - stdout: stream, - stderr: stream, - on(event: string, cb: (...args: unknown[]) => void) { - handlers.set(event, [...(handlers.get(event) ?? []), cb]); - if (!scheduled) { - scheduled = true; - setTimeout(() => fire('close', code), 5); - } - return child; - }, - }; + const child = Object.assign(new EventEmitter(), { + stdout: new PassThrough(), + stderr: new PassThrough(), + }); + queueMicrotask(() => { + child.stdout.end(output); + child.stderr.end(); + child.emit('close', code); + }); return child; } @@ -119,7 +109,7 @@ describe('runDeclaredPostPull (interactive)', () => { describe('runPostPull (in-process)', () => { it('launches with the repo cwd/env and default budget, and records a clean exit', async () => { - mockSpawn.mockReturnValueOnce(fakeScript(0) as never); + mockSpawn.mockImplementationOnce(() => fakeScript(0) as never); await runPostPull(path.join(dir, 'ok.mjs'), dir); @@ -134,7 +124,7 @@ describe('runPostPull (in-process)', () => { }); it('records a non-zero exit together with the output tail', async () => { - mockSpawn.mockReturnValueOnce(fakeScript(3, 'boom: missing config\n') as never); + mockSpawn.mockImplementationOnce(() => fakeScript(3, 'boom: missing config\n') as never); await runPostPull(path.join(dir, 'fail.mjs'), dir, 30); @@ -178,7 +168,7 @@ describe('runDeclaredPostPull', () => { it('runs a declared script, resolved against the repo root', async () => { writeScript('nested/post.mjs', 'export {};\n'); mockLoadTeamConfig.mockResolvedValue(teamConfig({ path: 'nested/post.mjs' })); - mockSpawn.mockReturnValueOnce(fakeScript(0) as never); + mockSpawn.mockImplementationOnce(() => fakeScript(0) as never); await runDeclaredPostPull(dir); diff --git a/src/__tests__/pre-push-sync.test.ts b/src/__tests__/pre-push-sync.test.ts index 42dff39cc..7153d5aec 100644 --- a/src/__tests__/pre-push-sync.test.ts +++ b/src/__tests__/pre-push-sync.test.ts @@ -29,7 +29,10 @@ vi.mock('../utils/git.js', () => ({ import { syncTeamUpdatesToLocal } from '../utils/pre-push-sync.js'; import { fileHash } from '../utils/fs.js'; +import { recordDelivered } from '../resources/delivered-copies.js'; import { teamRuleToCopilotInstructions } from '../resources/copilot-instructions.js'; +import { teamRuleToOmpRule } from '../resources/omp-rule.js'; +import { teamRuleToKiroSteering } from '../resources/kiro-steering.js'; import type { TeamaiConfig, LocalConfig } from '../types.js'; describe('syncTeamUpdatesToLocal โ€” rules', () => { @@ -322,15 +325,16 @@ describe('syncTeamUpdatesToLocal โ€” rules', () => { }); it('should sync all installed tool directories', async () => { - // Add a second tool - await fse.ensureDir(path.join(homeDir, '.workbuddy', 'rules')); - teamConfig.toolPaths.workbuddy = { skills: '.workbuddy/skills', rules: '.workbuddy/rules' }; + // Add a second tool that takes the team rule verbatim (WorkBuddy now gets + // CodeBuddy's render, #946) + await fse.ensureDir(path.join(homeDir, '.tclaude', 'rules')); + teamConfig.toolPaths.tclaude = { skills: '.tclaude/skills', rules: '.tclaude/rules' }; // Team repo has v2 await fse.writeFile(path.join(repoPath, 'rules', 'shared.md'), 'v2'); // Both tool dirs have v1 await fse.writeFile(path.join(homeDir, '.claude/rules', 'shared.md'), 'v1'); - await fse.writeFile(path.join(homeDir, '.workbuddy/rules', 'shared.md'), 'v1'); + await fse.writeFile(path.join(homeDir, '.tclaude/rules', 'shared.md'), 'v1'); // Old team repo was v1 mockGetFileContentAtRev.mockResolvedValue(Buffer.from('v1')); @@ -338,7 +342,7 @@ describe('syncTeamUpdatesToLocal โ€” rules', () => { // Both should now have v2 const claudeContent = await fse.readFile(path.join(homeDir, '.claude/rules', 'shared.md'), 'utf-8'); - const wbContent = await fse.readFile(path.join(homeDir, '.workbuddy/rules', 'shared.md'), 'utf-8'); + const wbContent = await fse.readFile(path.join(homeDir, '.tclaude/rules', 'shared.md'), 'utf-8'); expect(claudeContent).toBe('v2'); expect(wbContent).toBe('v2'); }); @@ -449,6 +453,52 @@ describe('syncTeamUpdatesToLocal โ€” rules', () => { expect(mockGetFileContentAtRev).not.toHaveBeenCalled(); }); + it('refreshes an unedited Kiro render to the team update, in Kiro\'s format (#946)', async () => { + await fse.ensureDir(path.join(homeDir, '.kiro', 'steering')); + teamConfig.toolPaths.kiro = { rules: '.kiro/steering' }; + const oldRule = '---\npaths: ["src/**"]\n---\n\nv1 content\n'; + const newRule = '---\npaths: ["src/**"]\n---\n\nv2 content\n'; + await fse.writeFile(path.join(repoPath, 'rules', 'my-rule.md'), newRule); + const localFile = path.join(homeDir, '.kiro/steering', 'my-rule.md'); + await fse.writeFile(localFile, teamRuleToKiroSteering(oldRule)); + mockGetFileContentAtRev.mockResolvedValue(Buffer.from(oldRule)); + + await syncTeamUpdatesToLocal(teamConfig, localConfig, 'abc1234'); + + expect(await fse.readFile(localFile, 'utf-8')).toBe(teamRuleToKiroSteering(newRule)); + }); + + it('leaves a member flat-name file alone even when its body matches an older team rule (#946)', async () => { + await fse.ensureDir(path.join(homeDir, '.omp', 'agent', 'rules')); + teamConfig.toolPaths.omp = { rules: '.omp/rules', userScope: { rules: '.omp/agent/rules' } }; + await fse.outputFile(path.join(repoPath, 'rules', 'fe', 'style.md'), 'v2 content\n'); + const localFile = path.join(homeDir, '.omp/agent/rules', 'fe.style.md'); + const personal = teamRuleToOmpRule('v1 content\n'); + await fse.writeFile(localFile, personal); + mockGetFileContentAtRev.mockResolvedValue(Buffer.from('v1 content\n')); + const delivered = {}; + + await syncTeamUpdatesToLocal(teamConfig, localConfig, 'abc1234', undefined, delivered); + + expect(await fse.readFile(localFile, 'utf-8')).toBe(personal); + expect(delivered).toEqual({}); + }); + + it('refreshes an unedited flat OMP copy of a namespaced rule from rules//.md (#946)', async () => { + await fse.ensureDir(path.join(homeDir, '.omp', 'agent', 'rules')); + teamConfig.toolPaths.omp = { rules: '.omp/rules', userScope: { rules: '.omp/agent/rules' } }; + await fse.outputFile(path.join(repoPath, 'rules', 'fe', 'style.md'), 'v2 content\n'); + const localFile = path.join(homeDir, '.omp/agent/rules', 'fe.style.md'); + await fse.writeFile(localFile, teamRuleToOmpRule('v1 content\n')); + mockGetFileContentAtRev.mockResolvedValue(Buffer.from('v1 content\n')); + + const delivered = {}; + await recordDelivered(delivered, localFile); + await syncTeamUpdatesToLocal(teamConfig, localConfig, 'abc1234', undefined, delivered); + + expect(await fse.readFile(localFile, 'utf-8')).toBe(teamRuleToOmpRule('v2 content\n')); + }); + describe.each(['project', 'user'] as const)('Copilot rules in %s scope', (scope) => { let instructionsDir: string; const oldRule = '---\npaths: ["src/**/*.ts"]\n---\n\nv1 content\n'; diff --git a/src/__tests__/project-hook-rules.test.ts b/src/__tests__/project-hook-rules.test.ts new file mode 100644 index 000000000..30086357f --- /dev/null +++ b/src/__tests__/project-hook-rules.test.ts @@ -0,0 +1,369 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import path from 'node:path'; +import os from 'node:os'; +import fse from 'fs-extra'; + +vi.mock('../config.js', async (importOriginal) => ({ + ...(await importOriginal()), + detectProjectConfig: vi.fn().mockResolvedValue(null), +})); + +import { buildHandlerRegistry } from '../hook-handlers.js'; +import { RulesHandler, ruleChannelNotes } from '../resources/rules.js'; +import { buildChecks, resolveDoctorContext } from '../doctor.js'; +import { detectProjectConfig } from '../config.js'; +import { reconcileHooks } from '../hooks.js'; +import { reconcileDshHooks, resolveDshHookConfigPath, resolveDshPatchPath } from '../dsh-hooks.js'; +import { injectPiHooks } from '../pi-hooks.js'; +import { TeamaiConfigSchema } from '../types.js'; +import type { LocalConfig } from '../types.js'; + +/** + * #946: in a project, ZCode and DeepSeek Harness get the project's team rules + * from their session-start hook, and Pi from teamai's extension, which adds + * what `hook-dispatch instructions` returns to each run's system prompt. In + * user scope the rules are a block in a file only each reads, so neither + * channel adds any there. + */ + +const PROJECT_RULE = 'The project codeword is PROJ-RULE-7.\n'; +const SCOPED = '---\npaths:\n - "src/**"\n---\nPrefer named exports.\n'; +const USER_RULE = 'The personal codeword is USER-RULE-3.\n'; + +let tmpDir: string; +let homeDir: string; +let repoPath: string; +let userRepo: string; +let projectRoot: string; + +function config(scope: 'user' | 'project', enabledAgents?: string[]): LocalConfig { + return { + repo: { localPath: scope === 'project' ? repoPath : userRepo, remote: 'https://example.invalid/x/team.git' }, + username: 'u', + additionalRoles: [], + scope, + ...(scope === 'project' ? { projectRoot } : {}), + ...(enabledAgents ? { enabledAgents } : {}), + } as unknown as LocalConfig; +} + +/** What the handler registered for `event` named `name` adds to the session, or null. */ +async function context(event: string, name: string, tool: string, localConfig: LocalConfig, stdin: Record = {}): Promise { + const registration = buildHandlerRegistry().find((r) => r.event === event && r.handler.name === name); + if (!registration) throw new Error(`no ${name} handler on ${event}`); + const output = await registration.handler.execute({ cwd: projectRoot, ...stdin }, tool, localConfig); + if (output === null) return null; + const parsed = JSON.parse(output) as { hookSpecificOutput: { hookEventName: string; additionalContext: string } }; + return parsed.hookSpecificOutput.additionalContext; +} + +const sessionStart = (tool: string, localConfig: LocalConfig, source = 'startup') => + context('session-start', 'team-rules', tool, localConfig, { hook_event_name: 'SessionStart', source }); +const piInstructions = (localConfig: LocalConfig) => context('instructions', 'instructions', 'pi', localConfig); + +const count = (text: string | null, needle: string) => (text ?? '').split(needle).length - 1; + +beforeEach(async () => { + tmpDir = await fse.mkdtemp(path.join(os.tmpdir(), 'teamai-project-hook-rules-')); + homeDir = path.join(tmpDir, 'home'); + projectRoot = path.join(tmpDir, 'project'); + repoPath = path.join(tmpDir, 'team-repo'); + userRepo = path.join(tmpDir, 'user-team-repo'); + vi.stubEnv('HOME', homeDir); + vi.stubEnv('DSH_HOME', ''); + await fse.outputFile(path.join(repoPath, 'teamai.yaml'), 'team: test\nrepo: https://example.invalid/x/team.git\n'); + await fse.outputFile(path.join(repoPath, 'rules', 'codeword.md'), PROJECT_RULE); + await fse.outputFile(path.join(repoPath, 'rules', 'scoped.md'), SCOPED); + await fse.outputFile(path.join(userRepo, 'teamai.yaml'), 'team: personal\nrepo: https://example.invalid/x/personal.git\n'); + await fse.outputFile(path.join(userRepo, 'rules', 'personal.md'), USER_RULE); + await fse.ensureDir(projectRoot); +}); + +afterEach(async () => { + vi.unstubAllEnvs(); + await fse.remove(tmpDir); +}); + +describe('ZCode and DeepSeek Harness get the project\'s rules from their session-start hook (#946)', () => { + it.each(['zcode', 'dsh'])('%s: adds the project rules once at session start, a path-scoped rule after its globs', async (tool) => { + const text = await sessionStart(tool, config('project')); + + expect(count(text, 'PROJ-RULE-7')).toBe(1); + expect(text).toContain('Applies to files matching: src/**\nPrefer named exports.'); + expect(text).not.toContain('paths:'); + }); + + it.each(['zcode', 'dsh'])('%s: adds nothing in a session outside a project, whose rules are in its own AGENTS.md', async (tool) => { + expect(await sessionStart(tool, config('user'))).toBeNull(); + }); + + it.each(['zcode', 'dsh'])('%s: adds nothing on resume, whose history already holds them', async (tool) => { + expect(await sessionStart(tool, config('project'), 'resume')).toBeNull(); + }); + + it.each(['zcode', 'dsh'])('%s: adds nothing once the project excludes it', async (tool) => { + expect(await sessionStart(tool, config('project', ['claude']))).toBeNull(); + }); + + it.each(['zcode', 'dsh'])('%s: adds the rules only, not the instruction blocks, which #945 gives it no channel for', async (tool) => { + await fse.outputFile(path.join(repoPath, 'culture.md'), '---\ncompany:\n name: Acme\n---\n\nBe kind to teammates.\n'); + + const text = await sessionStart(tool, config('project')); + + expect(text).toContain('PROJ-RULE-7'); + expect(text).not.toContain('Be kind to teammates.'); + }); +}); + +describe('Pi gets the project\'s rules from teamai\'s extension, beside the instruction blocks (#946)', () => { + it('adds the project rules once, a path-scoped rule after its globs, after the instruction blocks', async () => { + await fse.outputFile(path.join(repoPath, 'culture.md'), '---\ncompany:\n name: Acme\n---\n\nBe kind to teammates.\n'); + + const text = (await piInstructions(config('project')))!; + + expect(count(text, 'PROJ-RULE-7')).toBe(1); + expect(text).toContain('Applies to files matching: src/**\nPrefer named exports.'); + expect(text.indexOf('Be kind to teammates.')).toBeLessThan(text.indexOf('PROJ-RULE-7')); + }); + + it('adds the project rules when the project has no instruction blocks', async () => { + expect(count(await piInstructions(config('project')), 'PROJ-RULE-7')).toBe(1); + }); + + it('adds nothing in a session outside a project, whose rules are in ~/.pi/agent/AGENTS.md', async () => { + expect(await piInstructions(config('user'))).toBeNull(); + }); + + it('adds nothing once the project excludes Pi', async () => { + expect(await piInstructions(config('project', ['claude']))).toBeNull(); + }); + + it('adds no rules at session start, whose output Pi\'s extension does not read', async () => { + expect(await sessionStart('pi', config('project'))).toBeNull(); + }); +}); + +describe('OMP and Hermes get no rules through their extension or plugin (#946)', () => { + it.each(['omp', 'hermes'])('%s: the instructions text carries no team rules', async (tool) => { + await fse.outputFile(path.join(repoPath, 'culture.md'), '---\ncompany:\n name: Acme\n---\n\nBe kind to teammates.\n'); + + const text = await context('instructions', 'instructions', tool, config('project')); + + expect(text).toContain('Be kind to teammates.'); + expect(text).not.toContain('PROJ-RULE-7'); + }); +}); + +describe('with teamai in both scopes, each rule reaches the tool once (#946)', () => { + /** The file each tool reads its user rules from, and the home dir that says it is installed. */ + const TOOLS = [ + { tool: 'zcode', root: '.zcode', file: '.zcode/AGENTS.md', project: (cfg: LocalConfig) => sessionStart('zcode', cfg) }, + { tool: 'dsh', root: '.dsh', file: '.dsh/AGENTS.md', project: (cfg: LocalConfig) => sessionStart('dsh', cfg) }, + { tool: 'pi', root: '.pi/agent', file: '.pi/agent/AGENTS.md', project: piInstructions }, + ]; + const teamConfig = () => TeamaiConfigSchema.parse({ team: 'test', repo: 'https://example.invalid/x/team.git' }); + + it.each(TOOLS)('$tool: the user rules from ~/$file only, the project rules from its project channel only', async ({ tool, root, file, project }) => { + await fse.ensureDir(path.join(homeDir, root)); + await fse.ensureDir(path.join(projectRoot, root.split('/')[0])); + await new RulesHandler().pullAllRules(teamConfig(), config('user', [tool])); + await new RulesHandler().pullAllRules(teamConfig(), config('project', [tool])); + + const userFile = await fse.readFile(path.join(homeDir, file), 'utf8'); + const hook = await project(config('project', [tool])); + // Pi rebuilds its prompt every run and fetches this text once per session, + // so a second run gets the same text again, not a second copy. + const secondRun = await project(config('project', [tool])); + + expect(count(userFile, 'USER-RULE-3')).toBe(1); + expect(count(hook, 'USER-RULE-3')).toBe(0); + expect(count(hook, 'PROJ-RULE-7')).toBe(1); + expect(count(userFile, 'PROJ-RULE-7')).toBe(0); + expect(secondRun).toBe(hook); + }); + + it('a project pull writes Pi no rule copies, which it would never read', async () => { + await fse.ensureDir(path.join(projectRoot, '.pi')); + + await new RulesHandler().pullAllRules(teamConfig(), config('project', ['pi'])); + + expect(await fse.pathExists(path.join(projectRoot, '.pi', 'rules'))).toBe(false); + }); +}); + +describe('doctor checks the hook or extension that adds the project rules (#946)', () => { + const zcodeConfig = () => path.join(homeDir, '.zcode', 'cli', 'config.json'); + + async function checks(enabled: string[]) { + vi.mocked(detectProjectConfig).mockResolvedValue(config('project', enabled)); + try { + const ctx = await resolveDoctorContext(); + if (!ctx) throw new Error('expected a resolved doctor context'); + return await buildChecks(ctx); + } finally { + vi.mocked(detectProjectConfig).mockResolvedValue(null); + } + } + const find = async (enabled: string[], name: string) => (await checks(enabled)).find((c) => c.name === name); + + const ZCODE = 'Project rules reach zcode through its SessionStart hook'; + const DSH = 'Project rules reach dsh through its session-start hook'; + const PI = 'pi adds the team instructions and rules to its prompt'; + + it('zcode: passes once teamai\'s SessionStart hook is in ~/.zcode/cli/config.json and hooks are on', async () => { + await reconcileHooks(zcodeConfig(), 'zcode'); + + expect(await (await find(['zcode'], ZCODE))!.check()).toBe(true); + }); + + it('zcode: is not held to the Codex hook limit, which ZCode has no field for', async () => { + await reconcileHooks(zcodeConfig(), 'zcode'); + + const names = (await checks(['zcode'])).map((c) => c.name); + expect(names).toContain(ZCODE); + expect(names.filter((name) => name.includes('whole through its session hooks'))).toEqual([]); + }); + + it('zcode: fails while ZCode\'s config-file hooks are off, naming the file and the switch', async () => { + await reconcileHooks(zcodeConfig(), 'zcode'); + const cfg = await fse.readJson(zcodeConfig()); + await fse.writeJson(zcodeConfig(), { ...cfg, hooks: { ...cfg.hooks, enabled: false } }); + + const failing = (await find(['zcode'], ZCODE))!; + expect(await failing.check()).toBe(false); + expect(failing.fix).toContain(zcodeConfig()); + expect(failing.fix).toContain('hooks.enabled'); + expect(failing.fix).toContain('teamai hooks inject'); + expect(failing.fix).not.toContain('--force'); + }); + + it('zcode: fails naming the parse error when ~/.zcode/cli/config.json is not JSON, not as a missing hook', async () => { + await fse.outputFile(zcodeConfig(), '{ "hooks": { "enabled": true, '); + + const failing = (await find(['zcode'], ZCODE))!; + expect(await failing.check()).toBe(false); + expect(failing.fix).toContain(`${zcodeConfig()} is not valid JSON (`); + expect(failing.fix).not.toContain('has no teamai SessionStart hook'); + }); + + it('zcode: fails when the SessionStart hook is missing', async () => { + await fse.outputJson(zcodeConfig(), { hooks: { enabled: true, events: {} } }); + + expect(await (await find(['zcode'], ZCODE))!.check()).toBe(false); + }); + + it('dsh: passes once the hook config and the patch are written', async () => { + await fse.ensureDir(path.join(homeDir, '.dsh')); + await reconcileDshHooks([], {}); + + const passing = (await find(['dsh'], DSH))!; + expect(await passing.check()).toBe(true); + }); + + it('dsh: is checked where $DSH_HOME says dsh lives, as pull finds it', async () => { + const dshHome = path.join(tmpDir, 'dsh-home'); + await fse.ensureDir(dshHome); + vi.stubEnv('DSH_HOME', dshHome); + + const failing = (await find(['dsh'], DSH))!; + expect(await failing.check()).toBe(false); + expect(failing.fix).toContain(resolveDshPatchPath()); + }); + + it('dsh: names both files when both are missing', async () => { + await fse.ensureDir(path.join(homeDir, '.dsh')); + + const failing = (await find(['dsh'], DSH))!; + expect(failing.fix).toContain(resolveDshPatchPath()); + expect(failing.fix).toContain(resolveDshHookConfigPath()); + }); + + it('dsh: fails when the patch is gone, naming it and the --patch flag', async () => { + await fse.ensureDir(path.join(homeDir, '.dsh')); + await reconcileDshHooks([], {}); + await fse.remove(resolveDshPatchPath()); + + const failing = (await find(['dsh'], DSH))!; + expect(await failing.check()).toBe(false); + expect(failing.fix).toContain(resolveDshPatchPath()); + expect(failing.fix).toContain('--patch'); + expect(failing.fix).toContain('teamai hooks inject'); + }); + + it('pi: fails while teamai\'s extension is missing, naming the rules, and passes once it is installed', async () => { + await fse.ensureDir(path.join(homeDir, '.pi', 'agent')); + await fse.ensureDir(path.join(projectRoot, '.pi')); + + const failing = (await find(['pi'], PI))!; + expect(await failing.check()).toBe(false); + expect(failing.fix).toContain('get no team instructions or rules'); + + await injectPiHooks(); + expect(await (await find(['pi'], PI))!.check()).toBe(true); + }); + + it('asks nothing of a tool that is not installed, or that the project excludes', async () => { + const names = (await checks(['claude'])).map((c) => c.name); + expect(names).not.toContain(ZCODE); + expect(names).not.toContain(DSH); + + const uninstalled = (await checks(['zcode', 'dsh'])).map((c) => c.name); + expect(uninstalled).not.toContain(ZCODE); + expect(uninstalled).not.toContain(DSH); + }); + + it('asks nothing of these hooks in user scope, where the rules are in a file', async () => { + await reconcileHooks(zcodeConfig(), 'zcode'); + vi.mocked(detectProjectConfig).mockResolvedValue(null); + await fse.outputFile( + path.join(homeDir, '.teamai', 'config.yaml'), + `repo:\n localPath: ${userRepo}\n remote: https://example.invalid/x/personal.git\nusername: u\nenabledAgents: [zcode]\n`, + ); + const ctx = (await resolveDoctorContext())!; + + expect((await buildChecks(ctx)).map((c) => c.name)).not.toContain(ZCODE); + }); +}); + +describe('init and doctor name the limits of the project hooks (#946)', () => { + it('notes that ZCode drops the hook text at compaction', async () => { + await fse.ensureDir(path.join(homeDir, '.zcode')); + + const notes = await ruleChannelNotes(config('project', ['zcode'])); + + expect(notes.some((note) => note.startsWith('ZCode') && note.includes('compact'))).toBe(true); + }); + + it('notes that DeepSeek Harness needs the patch, can miss the first request, and drops the text at compaction', async () => { + await fse.ensureDir(path.join(homeDir, '.dsh')); + + const note = (await ruleChannelNotes(config('project', ['dsh']))).find((n) => n.startsWith('DeepSeek Harness')); + + expect(note).toContain(`--patch "${resolveDshPatchPath()}"`); + expect(note).toContain('first request'); + expect(note).toContain('compact'); + }); + + it('notes DeepSeek Harness where $DSH_HOME says it lives', async () => { + const dshHome = path.join(tmpDir, 'dsh-home'); + await fse.ensureDir(dshHome); + vi.stubEnv('DSH_HOME', dshHome); + + expect((await ruleChannelNotes(config('project', ['dsh']))).some((n) => n.startsWith('DeepSeek Harness'))).toBe(true); + }); + + it.each([ + ['in user scope', 'user' as const, ['zcode', 'dsh'], true], + ['when they are excluded', 'project' as const, ['claude'], true], + ['when they are not installed', 'project' as const, ['zcode', 'dsh'], false], + ])('says nothing %s', async (_label, scope, enabled, installed) => { + if (installed) { + await fse.ensureDir(path.join(homeDir, '.zcode')); + await fse.ensureDir(path.join(homeDir, '.dsh')); + } + + const notes = await ruleChannelNotes(config(scope, enabled)); + + expect(notes.some((note) => note.startsWith('ZCode') || note.startsWith('DeepSeek Harness'))).toBe(false); + }); +}); diff --git a/src/__tests__/pull-flat-tombstone.test.ts b/src/__tests__/pull-flat-tombstone.test.ts new file mode 100644 index 000000000..0b08b441b --- /dev/null +++ b/src/__tests__/pull-flat-tombstone.test.ts @@ -0,0 +1,66 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import path from 'node:path'; +import os from 'node:os'; +import fse from 'fs-extra'; + +vi.mock('../config.js', async (importOriginal) => ({ + ...(await importOriginal()), + requireInit: vi.fn(), + loadState: vi.fn().mockResolvedValue({ lastPull: null, lastPullRev: null }), + saveState: vi.fn(), + loadStateForScope: vi.fn(async () => ({})), + saveStateForScope: vi.fn(), + loadLocalConfigForScope: vi.fn(), + loadTeamConfig: vi.fn(), + detectProjectConfig: vi.fn().mockResolvedValue(null), + autoDetectInit: vi.fn(), +})); +vi.mock('../utils/git.js', async (importOriginal) => ({ + ...(await importOriginal()), + pullRepo: vi.fn().mockResolvedValue('already up to date'), + getHeadRev: vi.fn().mockResolvedValue('abc1234'), + createGit: vi.fn(), +})); +vi.mock('../update.js', () => ({ acquireLock: vi.fn().mockResolvedValue(true), releaseLock: vi.fn().mockResolvedValue(undefined) })); +vi.mock('../utils/logger.js', () => ({ + log: { persist: vi.fn(), info: vi.fn(), success: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn(), dim: vi.fn() }, + spinner: vi.fn(() => ({ start: vi.fn().mockReturnThis(), succeed: vi.fn().mockReturnThis(), fail: vi.fn().mockReturnThis(), warn: vi.fn().mockReturnThis(), info: vi.fn().mockReturnThis(), stop: vi.fn().mockReturnThis() })), +})); + +import { pull } from '../pull.js'; +import { loadLocalConfigForScope, loadStateForScope, loadTeamConfig, saveStateForScope } from '../config.js'; +import { TeamaiConfigSchema, type LocalConfig, type State } from '../types.js'; + +describe('a removed root rule whose name is the flat name of a live namespaced rule (#946)', () => { + let tmp: string; let home: string; let saved: State; + beforeEach(async () => { + tmp = await fse.mkdtemp(path.join(os.tmpdir(), 'teamai-flat-tombstone-')); + home = path.join(tmp, 'home'); + const repo = path.join(tmp, 'repo'); + await fse.ensureDir(path.join(home, '.kiro')); + await fse.ensureDir(path.join(home, '.omp', 'agent')); + await fse.outputFile(path.join(repo, 'rules', 'fe', 'style.md'), 'Use tabs.\n'); + // The team once had a root rule `fe.style` and removed it. + await fse.outputFile(path.join(repo, 'rules', '.removed'), 'fe.style\n'); + vi.stubEnv('HOME', home); + saved = {} as State; + vi.mocked(saveStateForScope).mockImplementation(async (s) => { saved = structuredClone(s); }); + vi.mocked(loadStateForScope).mockImplementation(async () => structuredClone(saved) as never); + vi.mocked(loadTeamConfig).mockResolvedValue(TeamaiConfigSchema.parse({ team: 't', repo: 'https://example.invalid/x/t.git' })); + vi.mocked(loadLocalConfigForScope).mockResolvedValue({ + repo: { localPath: repo, remote: 'https://example.invalid/x/t.git' }, username: 'u', updatePolicy: 'auto', + additionalRoles: [], scope: 'user', enabledAgents: ['kiro', 'omp'], + } as LocalConfig); + }); + afterEach(async () => { vi.unstubAllEnvs(); await fse.remove(tmp); }); + + it.each(['.kiro/steering', '.omp/agent/rules'])('keeps the live fe/style copy in %s over a full and an already-synced pull', async (dir) => { + const flat = path.join(home, dir, 'fe.style.md'); + + await pull({}); + expect(await fse.readFile(flat, 'utf8')).toContain('Use tabs.'); + + await pull({}); + expect(await fse.readFile(flat, 'utf8')).toContain('Use tabs.'); + }); +}); diff --git a/src/__tests__/pull-hermes-stale-soul.test.ts b/src/__tests__/pull-hermes-stale-soul.test.ts new file mode 100644 index 000000000..3f4b5b3b1 --- /dev/null +++ b/src/__tests__/pull-hermes-stale-soul.test.ts @@ -0,0 +1,95 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import path from 'node:path'; +import os from 'node:os'; +import fse from 'fs-extra'; + +vi.mock('../config.js', async (importOriginal) => ({ + ...(await importOriginal()), + requireInit: vi.fn(), + loadState: vi.fn().mockResolvedValue({ lastPull: null, lastPullRev: null }), + saveState: vi.fn(), + loadStateForScope: vi.fn(async () => ({})), + saveStateForScope: vi.fn(), + loadLocalConfigForScope: vi.fn(), + loadTeamConfig: vi.fn(), + detectProjectConfig: vi.fn().mockResolvedValue(null), + autoDetectInit: vi.fn(), +})); +vi.mock('../utils/git.js', async (importOriginal) => ({ + ...(await importOriginal()), + pullRepo: vi.fn().mockResolvedValue('already up to date'), + getHeadRev: vi.fn().mockResolvedValue('abc1234'), + createGit: vi.fn(), +})); +vi.mock('../update.js', () => ({ acquireLock: vi.fn().mockResolvedValue(true), releaseLock: vi.fn().mockResolvedValue(undefined) })); +vi.mock('../utils/logger.js', () => ({ + log: { persist: vi.fn(), info: vi.fn(), success: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn(), dim: vi.fn() }, + spinner: vi.fn(() => ({ start: vi.fn().mockReturnThis(), succeed: vi.fn().mockReturnThis(), fail: vi.fn().mockReturnThis(), warn: vi.fn().mockReturnThis(), info: vi.fn().mockReturnThis(), stop: vi.fn().mockReturnThis() })), +})); + + +import { pull } from '../pull.js'; +import { detectProjectConfig, loadStateForScope, loadTeamConfig, saveStateForScope } from '../config.js'; +import { TeamaiConfigSchema, type LocalConfig, type State } from '../types.js'; +import { upsertSoulRules, readSoulRules } from '../hermes-config.js'; + +describe('the SOUL.md rules block an older project pull wrote, on a machine with no user-scope install (#946)', () => { + let tmp: string; let home: string; let saved: State; let projectRoot: string; let repo: string; + beforeEach(async () => { + tmp = await fse.mkdtemp(path.join(os.tmpdir(), 'teamai-hermes-stale-soul-')); + home = path.join(tmp, 'home'); projectRoot = path.join(tmp, 'proj'); repo = path.join(tmp, 'repo'); + await fse.ensureDir(path.join(home, '.hermes')); + await fse.ensureDir(path.join(projectRoot, '.claude')); + await fse.outputFile(path.join(repo, 'rules', 'r.md'), 'New project rule.\n'); + vi.stubEnv('HOME', home); + vi.stubEnv('HERMES_HOME', path.join(home, '.hermes')); + saved = {} as State; + vi.mocked(saveStateForScope).mockImplementation(async (s) => { saved = structuredClone(s); }); + vi.mocked(loadStateForScope).mockImplementation(async () => structuredClone(saved) as never); + vi.mocked(loadTeamConfig).mockResolvedValue(TeamaiConfigSchema.parse({ team: 't', repo: 'https://example.invalid/x/t.git' })); + vi.mocked(detectProjectConfig).mockResolvedValue({ + repo: { localPath: repo, remote: 'https://example.invalid/x/t.git' }, username: 'u', updatePolicy: 'auto', + additionalRoles: [], scope: 'project', projectRoot, enabledAgents: ['hermes', 'claude'], + } as LocalConfig); + // What a pre-#946 project pull wrote into the global SOUL.md. + await upsertSoulRules('Old project rule that the team since deleted.'); + }); + afterEach(async () => { vi.unstubAllEnvs(); vi.mocked(detectProjectConfig).mockResolvedValue(null); await fse.remove(tmp); }); + + it('keeps the block when a user config exists but cannot be parsed', async () => { + await fse.outputFile(path.join(home, '.teamai', 'config.yaml'), 'invalid: ['); + + await pull({}); + + expect(await readSoulRules()).toBe('Old project rule that the team since deleted.'); + }); + + it('leaves the block alone on a dry run', async () => { + await pull({ dryRun: true }); + + expect(await readSoulRules()).toBe('Old project rule that the team since deleted.'); + }); + + it('keeps the block when Hermes is excluded in the project', async () => { + vi.mocked(detectProjectConfig).mockResolvedValue({ + ...await detectProjectConfig(), enabledAgents: ['claude'], + } as LocalConfig); + + await pull({}); + + expect(await readSoulRules()).toBe('Old project rule that the team since deleted.'); + }); + + it('is removed by a project pull', async () => { + const soul = path.join(home, '.hermes', 'SOUL.md'); + await fse.appendFile(soul, '\nMy own standing instructions.\n'); + + await pull({}); + + expect(await readSoulRules()).toBeNull(); + expect(await fse.readFile(soul, 'utf8')).toContain('My own standing instructions.'); + await upsertSoulRules('Another stale project block.'); + await pull({}); + expect(await readSoulRules()).toBeNull(); + }); +}); diff --git a/src/__tests__/pull-namespace-override.test.ts b/src/__tests__/pull-namespace-override.test.ts index 6540af054..4151ad329 100644 --- a/src/__tests__/pull-namespace-override.test.ts +++ b/src/__tests__/pull-namespace-override.test.ts @@ -245,6 +245,8 @@ describe('pull: an active namespace item replaces the root item of the same name if (!base) throw new Error('no team config'); vi.mocked(loadTeamConfig).mockResolvedValue({ ...base, + // A team entry that keeps a user rules directory for JoyCode, shared + // with the member's own rules; the default reads none since #946. toolPaths: { ...base.toolPaths, joycode: { skills: '.joycode/skills', rules: '.joycode/rules', agents: '.joycode/agents' } }, }); await fse.ensureDir(path.join(homeDir, '.joycode', 'rules')); @@ -269,6 +271,33 @@ describe('pull: an active namespace item replaces the root item of the same name expect(await read('.joycode/rules/mine.mdc')).toBe('my own rule\n'); }); + // Before #946 JoyCode got Cursor's render: that copy is what teamai + // delivered, not a member edit, so it goes without a warning. + it('withdraws a replaced root rule\'s copy still in the render an older teamai wrote for the tool (#946)', async () => { + const base = await loadTeamConfig(repoPath); + if (!base) throw new Error('no team config'); + vi.mocked(loadTeamConfig).mockResolvedValue({ + ...base, + // A team entry that keeps a user rules directory for JoyCode, shared + // with the member's own rules; the default reads none since #946. + toolPaths: { ...base.toolPaths, joycode: { skills: '.joycode/skills', rules: '.joycode/rules', agents: '.joycode/agents' } }, + }); + await fse.ensureDir(path.join(homeDir, '.joycode', 'rules')); + await team('rules/scoped.md', '---\npaths: ["src/**", "test/**"]\n---\n\n# Shared scoped\n'); + await team('rules/frontend/scoped.md', '# Front scoped\n'); + await fse.outputFile( + path.join(homeDir, '.joycode/rules/scoped.mdc'), + '---\nglobs: "src/**, test/**"\nalwaysApply: false\n---\n\n# Shared scoped\n', + ); + + as(['frontend']); + await pull({}); + + expect(await exists('.joycode/rules/scoped.mdc')).toBe(false); + expect(await exists('.joycode/rules/frontend/scoped.mdc')).toBe(true); + expect(logged('warn', /scoped\.mdc/)).toBe(false); + }); + // The admin edits the root rule and adds its namespace override in one push: // the member's copy is the version of the last pull, not a member edit. // HOME's copy may come from a project pull that inherits the user scope, @@ -281,6 +310,8 @@ describe('pull: an active namespace item replaces the root item of the same name if (!base) throw new Error('no team config'); vi.mocked(loadTeamConfig).mockResolvedValue({ ...base, + // A team entry that keeps a user rules directory for JoyCode, shared + // with the member's own rules; the default reads none since #946. toolPaths: { ...base.toolPaths, joycode: { skills: '.joycode/skills', rules: '.joycode/rules', agents: '.joycode/agents' } }, }); await fse.ensureDir(path.join(homeDir, '.joycode', 'rules')); diff --git a/src/__tests__/pull-rule-format-upgrade.test.ts b/src/__tests__/pull-rule-format-upgrade.test.ts new file mode 100644 index 000000000..af41dbbf5 --- /dev/null +++ b/src/__tests__/pull-rule-format-upgrade.test.ts @@ -0,0 +1,649 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import crypto from 'node:crypto'; +import path from 'node:path'; +import os from 'node:os'; +import fse from 'fs-extra'; + +vi.mock('../config.js', async (importOriginal) => ({ + ...(await importOriginal()), + requireInit: vi.fn(), + loadState: vi.fn().mockResolvedValue({ lastPull: null, lastPullRev: null }), + saveState: vi.fn(), + loadStateForScope: vi.fn(async () => ({})), + saveStateForScope: vi.fn(), + loadLocalConfigForScope: vi.fn(), + loadTeamConfig: vi.fn(), + detectProjectConfig: vi.fn().mockResolvedValue(null), + autoDetectInit: vi.fn(), +})); + +vi.mock('../utils/git.js', async (importOriginal) => ({ + ...(await importOriginal()), + pullRepo: vi.fn().mockResolvedValue('already up to date'), + getHeadRev: vi.fn().mockResolvedValue('abc1234'), + createGit: vi.fn(), +})); + +// pull() takes a real ~/.teamai/.sync-lock; parallel workers would race on it. +vi.mock('../update.js', () => ({ + acquireLock: vi.fn().mockResolvedValue(true), + releaseLock: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock('../utils/logger.js', () => ({ + log: { + persist: vi.fn(), + info: vi.fn(), + success: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + dim: vi.fn(), + }, + spinner: vi.fn(() => ({ + start: vi.fn().mockReturnThis(), + succeed: vi.fn().mockReturnThis(), + fail: vi.fn().mockReturnThis(), + warn: vi.fn().mockReturnThis(), + info: vi.fn().mockReturnThis(), + stop: vi.fn().mockReturnThis(), + })), +})); + +// Pass-through, so a test can count how often a pull resolves the rules: +// each resolve filters the team rules by tag once. +vi.mock('../utils/tags.js', async (importOriginal) => { + const original = await importOriginal(); + return { ...original, filterByTags: vi.fn(original.filterByTags) }; +}); + +import { pull } from '../pull.js'; +import { filterByTags } from '../utils/tags.js'; +import { log } from '../utils/logger.js'; +import { loadLocalConfigForScope, loadStateForScope, loadTeamConfig, saveStateForScope } from '../config.js'; +import { TeamaiConfigSchema, type LocalConfig, type State } from '../types.js'; + +const sha256 = (text: string) => crypto.createHash('sha256').update(text).digest('hex'); + +/** + * A CLI upgrade that gives a tool its own rules format must reach a machine + * whose team revision has not moved, or its rules stay verbatim until the + * team next changes (#946). + */ +describe('a pull at an unchanged team revision after the rule formats change (#946)', () => { + let tmpDir: string; + let homeDir: string; + let saved: State; + + const SCOPED = '---\npaths:\n - "src/**"\n---\n\nUse named exports.\n'; + const KIRO = '---\ninclusion: fileMatch\nfileMatchPattern: ["src/**"]\n---\n\nUse named exports.\n'; + const kiroCopy = () => path.join(homeDir, '.kiro', 'steering', 'scoped.md'); + const qoderCopy = () => path.join(homeDir, '.qoder', 'rules', 'scoped.md'); + const delivered = () => Object.values(saved.lastPullByWorkspace ?? {})[0]?.delivered ?? {}; + + beforeEach(async () => { + tmpDir = await fse.mkdtemp(path.join(os.tmpdir(), 'teamai-rule-format-upgrade-')); + homeDir = path.join(tmpDir, 'home'); + const repoPath = path.join(tmpDir, 'team-repo'); + await fse.ensureDir(path.join(homeDir, '.kiro')); + await fse.ensureDir(path.join(homeDir, '.qoder')); + await fse.outputFile(path.join(repoPath, 'rules', 'scoped.md'), SCOPED); + vi.stubEnv('HOME', homeDir); + saved = {} as State; + vi.mocked(saveStateForScope).mockImplementation(async (state) => { + saved = structuredClone(state); + }); + vi.mocked(loadStateForScope).mockImplementation(async () => structuredClone(saved) as never); + vi.mocked(loadTeamConfig).mockResolvedValue( + TeamaiConfigSchema.parse({ team: 'test', repo: 'https://example.invalid/x/team.git' }), + ); + vi.mocked(loadLocalConfigForScope).mockResolvedValue({ + repo: { localPath: repoPath, remote: 'https://example.invalid/x/team.git' }, + username: 'u', + updatePolicy: 'auto', + additionalRoles: [], + scope: 'user', + enabledAgents: ['kiro', 'qoder'], + } as LocalConfig); + await pull({}); + // What an older CLI left at this revision: the team rule verbatim, on + // record as delivered; the member then edited the Qoder copy. + for (const copy of [kiroCopy(), qoderCopy()]) await fse.writeFile(copy, SCOPED); + const record = Object.values(saved.lastPullByWorkspace ?? {})[0]; + record.delivered = { ...record.delivered, [kiroCopy()]: sha256(SCOPED), [qoderCopy()]: sha256(SCOPED) }; + await fse.writeFile(qoderCopy(), 'My own wording.\n'); + vi.mocked(log.success).mockClear(); + vi.mocked(log.warn).mockClear(); + }); + + afterEach(async () => { + vi.mocked(saveStateForScope).mockReset(); + vi.mocked(loadStateForScope).mockImplementation(async () => ({}) as never); + vi.unstubAllEnvs(); + await fse.remove(tmpDir); + }); + + it('re-renders the unedited copy, records it, and leaves the edited one', async () => { + await pull({}); + + const successes = vi.mocked(log.success).mock.calls.map(([message]) => String(message)); + expect(successes.some((message) => message.includes('Already synced at abc1234'))).toBe(true); + expect(await fse.readFile(kiroCopy(), 'utf8')).toBe(KIRO); + expect(await fse.readFile(qoderCopy(), 'utf8')).toBe('My own wording.\n'); + expect(delivered()[kiroCopy()]).toBe(sha256(KIRO)); + expect(delivered()[qoderCopy()]).toBe(sha256(SCOPED)); + expect(successes).toContain('[user] Rewrote 1 rule(s) in their tool\'s own format: scoped'); + // Kept and named, as a full sync names it (#822). + const warnings = vi.mocked(log.warn).mock.calls.map(([message]) => String(message)); + expect(warnings.filter((message) => message.includes(`Kept ${qoderCopy()}`))).toHaveLength(1); + }); +}); + +/** + * OMP reads only the top of its rules directory, so a namespaced rule moved + * from `fe/style.md` to `fe.style.md`. The new path has no record, so the + * re-render above cannot reach it; the old copy is what moves it. + */ +describe('a pull at an unchanged team revision after OMP rules go flat (#946)', () => { + let tmpDir: string; + let homeDir: string; + let saved: State; + + const NS = 'Namespaced rule.\n'; + const OMP_NS = '---\nalwaysApply: true\n---\n\nNamespaced rule.\n'; + const rulesDir = () => path.join(homeDir, '.omp', 'agent', 'rules'); + const record = () => Object.values(saved.lastPullByWorkspace ?? {})[0]; + + beforeEach(async () => { + tmpDir = await fse.mkdtemp(path.join(os.tmpdir(), 'teamai-omp-flat-upgrade-')); + homeDir = path.join(tmpDir, 'home'); + const repoPath = path.join(tmpDir, 'team-repo'); + await fse.ensureDir(rulesDir()); + await fse.outputFile(path.join(repoPath, 'rules', 'fe', 'style.md'), NS); + await fse.outputFile(path.join(repoPath, 'rules', 'be', 'api.md'), 'Backend rule.\n'); + vi.stubEnv('HOME', homeDir); + saved = {} as State; + vi.mocked(saveStateForScope).mockImplementation(async (state) => { + saved = structuredClone(state); + }); + vi.mocked(loadStateForScope).mockImplementation(async () => structuredClone(saved) as never); + vi.mocked(loadTeamConfig).mockResolvedValue( + TeamaiConfigSchema.parse({ team: 'test', repo: 'https://example.invalid/x/team.git' }), + ); + vi.mocked(loadLocalConfigForScope).mockResolvedValue({ + repo: { localPath: repoPath, remote: 'https://example.invalid/x/team.git' }, + username: 'u', + updatePolicy: 'auto', + additionalRoles: [], + scope: 'user', + enabledAgents: ['omp'], + } as LocalConfig); + await pull({}); + // What an older CLI left at this revision: each rule verbatim and nested, + // on record; the member then edited the backend copy. + const delivered: Record = {}; + for (const name of ['fe.style.md', 'be.api.md']) await fse.remove(path.join(rulesDir(), name)); + for (const [rel, text] of [['fe/style.md', NS], ['be/api.md', 'Backend rule.\n']]) { + await fse.outputFile(path.join(rulesDir(), rel), text); + delivered[path.join(rulesDir(), rel)] = sha256(text); + } + record().delivered = delivered; + await fse.writeFile(path.join(rulesDir(), 'be', 'api.md'), 'My own backend wording.\n'); + vi.mocked(log.success).mockClear(); + vi.mocked(log.warn).mockClear(); + }); + + afterEach(async () => { + vi.mocked(saveStateForScope).mockReset(); + vi.mocked(loadStateForScope).mockImplementation(async () => ({}) as never); + vi.unstubAllEnvs(); + await fse.remove(tmpDir); + }); + + it('writes the flat copy OMP reads, and reclaims the nested one unless the member edited it', async () => { + await pull({}); + + const successes = vi.mocked(log.success).mock.calls.map(([message]) => String(message)); + expect(successes.some((message) => message.includes('Already synced at abc1234'))).toBe(true); + const flat = path.join(rulesDir(), 'fe.style.md'); + expect(await fse.readFile(flat, 'utf8')).toBe(OMP_NS); + expect(await fse.pathExists(path.join(rulesDir(), 'fe'))).toBe(false); + // The member's edit stays where it is; the flat copy still gets the team rule. + expect(await fse.readFile(path.join(rulesDir(), 'be', 'api.md'), 'utf8')).toBe('My own backend wording.\n'); + expect(await fse.readFile(path.join(rulesDir(), 'be.api.md'), 'utf8')).toBe('---\nalwaysApply: true\n---\n\nBackend rule.\n'); + expect(record().delivered?.[flat]).toBe(sha256(OMP_NS)); + expect(record().delivered?.[path.join(rulesDir(), 'fe', 'style.md')]).toBeUndefined(); + // The edited nested copy is named: OMP does not read it. + const warnings = vi.mocked(log.warn).mock.calls.map(([message]) => String(message)); + expect(warnings.filter((message) => message.includes(`Kept ${path.join(rulesDir(), 'be', 'api.md')}`))).toHaveLength(1); + }); + + it('does the same on a machine an older CLI left with no delivery record', async () => { + delete record().delivered; + // A root rule's verbatim copy too: the team rule's own bytes, so unedited. + await fse.outputFile(path.join(tmpDir, 'team-repo', 'rules', 'root.md'), 'Root rule.\n'); + await fse.outputFile(path.join(rulesDir(), 'root.md'), 'Root rule.\n'); + await fse.outputFile(path.join(rulesDir(), 'mine.md'), 'Not a team rule.\n'); + + await pull({}); + + expect(await fse.readFile(path.join(rulesDir(), 'root.md'), 'utf8')).toBe('---\nalwaysApply: true\n---\n\nRoot rule.\n'); + expect(await fse.readFile(path.join(rulesDir(), 'mine.md'), 'utf8')).toBe('Not a team rule.\n'); + expect(await fse.readFile(path.join(rulesDir(), 'fe.style.md'), 'utf8')).toBe(OMP_NS); + expect(await fse.pathExists(path.join(rulesDir(), 'fe'))).toBe(false); + expect(await fse.readFile(path.join(rulesDir(), 'be', 'api.md'), 'utf8')).toBe('My own backend wording.\n'); + }); +}); + +/** + * Kiro reads only the top of its steering directory (kirodotdev/Kiro#10448), + * so a namespaced rule moved from `fe/style.md` to `fe.style.md`, as OMP's did. + */ +describe('a pull at an unchanged team revision after Kiro steering goes flat (#946)', () => { + let tmpDir: string; + let homeDir: string; + let saved: State; + + const NS = 'Namespaced rule.\n'; + const KIRO_NS = '---\ninclusion: always\n---\n\nNamespaced rule.\n'; + const steering = () => path.join(homeDir, '.kiro', 'steering'); + const record = () => Object.values(saved.lastPullByWorkspace ?? {})[0]; + + beforeEach(async () => { + tmpDir = await fse.mkdtemp(path.join(os.tmpdir(), 'teamai-kiro-flat-upgrade-')); + homeDir = path.join(tmpDir, 'home'); + const repoPath = path.join(tmpDir, 'team-repo'); + await fse.ensureDir(steering()); + await fse.outputFile(path.join(repoPath, 'rules', 'fe', 'style.md'), NS); + await fse.outputFile(path.join(repoPath, 'rules', 'be', 'api.md'), 'Backend rule.\n'); + vi.stubEnv('HOME', homeDir); + saved = {} as State; + vi.mocked(saveStateForScope).mockImplementation(async (state) => { + saved = structuredClone(state); + }); + vi.mocked(loadStateForScope).mockImplementation(async () => structuredClone(saved) as never); + vi.mocked(loadTeamConfig).mockResolvedValue( + TeamaiConfigSchema.parse({ team: 'test', repo: 'https://example.invalid/x/team.git' }), + ); + vi.mocked(loadLocalConfigForScope).mockResolvedValue({ + repo: { localPath: repoPath, remote: 'https://example.invalid/x/team.git' }, + username: 'u', + updatePolicy: 'auto', + additionalRoles: [], + scope: 'user', + enabledAgents: ['kiro'], + } as LocalConfig); + await pull({}); + // What an older CLI left at this revision: each rule verbatim and nested, + // on record; the member then edited the backend copy. + const delivered: Record = {}; + for (const name of ['fe.style.md', 'be.api.md']) await fse.remove(path.join(steering(), name)); + for (const [rel, text] of [['fe/style.md', NS], ['be/api.md', 'Backend rule.\n']]) { + await fse.outputFile(path.join(steering(), rel), text); + delivered[path.join(steering(), rel)] = sha256(text); + } + record().delivered = delivered; + await fse.writeFile(path.join(steering(), 'be', 'api.md'), 'My own backend wording.\n'); + vi.mocked(log.success).mockClear(); + vi.mocked(log.warn).mockClear(); + }); + + afterEach(async () => { + vi.mocked(saveStateForScope).mockReset(); + vi.mocked(loadStateForScope).mockImplementation(async () => ({}) as never); + vi.unstubAllEnvs(); + await fse.remove(tmpDir); + }); + + it('writes the flat file Kiro reads, and reclaims the nested one unless the member edited it', async () => { + await pull({}); + + const successes = vi.mocked(log.success).mock.calls.map(([message]) => String(message)); + expect(successes.some((message) => message.includes('Already synced at abc1234'))).toBe(true); + const flat = path.join(steering(), 'fe.style.md'); + expect(await fse.readFile(flat, 'utf8')).toBe(KIRO_NS); + expect(await fse.pathExists(path.join(steering(), 'fe'))).toBe(false); + expect(await fse.readFile(path.join(steering(), 'be', 'api.md'), 'utf8')).toBe('My own backend wording.\n'); + expect(await fse.readFile(path.join(steering(), 'be.api.md'), 'utf8')).toBe('---\ninclusion: always\n---\n\nBackend rule.\n'); + expect(record().delivered?.[flat]).toBe(sha256(KIRO_NS)); + expect(record().delivered?.[path.join(steering(), 'fe', 'style.md')]).toBeUndefined(); + // The edited nested copy is named: Kiro does not read it. + const warnings = vi.mocked(log.warn).mock.calls.map(([message]) => String(message)); + expect(warnings.filter((message) => message.includes(`Kept ${path.join(steering(), 'be', 'api.md')}`))).toHaveLength(1); + }); + + it('does the same on a machine an older CLI left with no delivery record', async () => { + delete record().delivered; + + await pull({}); + + expect(await fse.readFile(path.join(steering(), 'fe.style.md'), 'utf8')).toBe(KIRO_NS); + expect(await fse.pathExists(path.join(steering(), 'fe'))).toBe(false); + expect(await fse.readFile(path.join(steering(), 'be', 'api.md'), 'utf8')).toBe('My own backend wording.\n'); + const warnings = vi.mocked(log.warn).mock.calls.map(([message]) => String(message)); + expect(warnings.filter((message) => message.includes(`Kept ${path.join(steering(), 'be', 'api.md')}`))).toHaveLength(1); + }); +}); + +/** + * JoyCode got Cursor's `.mdc`, whose quoted globs it never matches; a project + * whose team revision has not moved must still get JoyCode's own render (#946). + */ +describe('a pull at an unchanged team revision after JoyCode gets its own render (#946)', () => { + let tmpDir: string; + let projectRoot: string; + let saved: State; + + const SCOPED = '---\npaths:\n - "src/**"\n - "test/**"\n---\n\nUse named exports.\n'; + const CURSOR = '---\nglobs: "src/**, test/**"\nalwaysApply: false\n---\n\nUse named exports.\n'; + const JOYCODE = '---\nglobs: src/**, test/**\nalwaysApply: false\n---\n\nUse named exports.\n'; + const copy = (name: string) => path.join(projectRoot, '.joycode', 'rules', `${name}.mdc`); + const delivered = () => Object.values(saved.lastPullByWorkspace ?? {})[0]?.delivered ?? {}; + + beforeEach(async () => { + tmpDir = await fse.mkdtemp(path.join(os.tmpdir(), 'teamai-joycode-upgrade-')); + projectRoot = path.join(tmpDir, 'project'); + const repoPath = path.join(tmpDir, 'team-repo'); + await fse.ensureDir(path.join(projectRoot, '.joycode')); + for (const name of ['scoped', 'edited']) await fse.outputFile(path.join(repoPath, 'rules', `${name}.md`), SCOPED); + vi.stubEnv('HOME', path.join(tmpDir, 'home')); + saved = {} as State; + vi.mocked(saveStateForScope).mockImplementation(async (state) => { + saved = structuredClone(state); + }); + vi.mocked(loadStateForScope).mockImplementation(async () => structuredClone(saved) as never); + vi.mocked(loadTeamConfig).mockResolvedValue( + TeamaiConfigSchema.parse({ team: 'test', repo: 'https://example.invalid/x/team.git' }), + ); + vi.mocked(loadLocalConfigForScope).mockResolvedValue({ + repo: { localPath: repoPath, remote: 'https://example.invalid/x/team.git' }, + username: 'u', + updatePolicy: 'auto', + additionalRoles: [], + scope: 'project', + projectRoot, + enabledAgents: ['joycode'], + } as LocalConfig); + await pull({}); + // What an older CLI left at this revision: Cursor's render, on record as + // delivered; the member then edited one copy. + const record = Object.values(saved.lastPullByWorkspace ?? {})[0]; + for (const name of ['scoped', 'edited']) { + await fse.writeFile(copy(name), CURSOR); + record.delivered = { ...record.delivered, [copy(name)]: sha256(CURSOR) }; + } + await fse.writeFile(copy('edited'), CURSOR.replace('Use named exports.', 'My own wording.')); + vi.mocked(log.success).mockClear(); + vi.mocked(log.warn).mockClear(); + }); + + afterEach(async () => { + vi.mocked(saveStateForScope).mockReset(); + vi.mocked(loadStateForScope).mockImplementation(async () => ({}) as never); + vi.unstubAllEnvs(); + await fse.remove(tmpDir); + }); + + it('re-renders the unedited copy for JoyCode, and keeps and names the edited one', async () => { + await pull({}); + + const successes = vi.mocked(log.success).mock.calls.map(([message]) => String(message)); + expect(successes.some((message) => message.includes('Already synced at abc1234'))).toBe(true); + expect(await fse.readFile(copy('scoped'), 'utf8')).toBe(JOYCODE); + expect(delivered()[copy('scoped')]).toBe(sha256(JOYCODE)); + expect(await fse.readFile(copy('edited'), 'utf8')).toContain('My own wording.'); + const warnings = vi.mocked(log.warn).mock.calls.map(([message]) => String(message)); + expect(warnings.filter((message) => message.includes(`Kept ${copy('edited')}`))).toHaveLength(1); + expect(warnings.filter((message) => message.includes(copy('edited')) && message.includes('JoyCode never matches'))).toHaveLength(1); + expect(warnings.some((message) => message.includes(copy('scoped')))).toBe(false); + }); + + it('says why the kept copy applies to no file on a full sync too', async () => { + await pull({ force: true }); + + const warnings = vi.mocked(log.warn).mock.calls.map(([message]) => String(message)); + expect(await fse.readFile(copy('edited'), 'utf8')).toContain('My own wording.'); + expect(warnings.filter((message) => message.includes(copy('edited')) && message.includes('JoyCode never matches'))).toHaveLength(1); + }); +}); + +/** + * A CLI upgrade that moves OpenCode's rules globs must reach a machine whose + * team revision has not moved, or OpenCode keeps loading through the old + * entry until the team next changes (#946). + */ +describe('a pull at an unchanged team revision after the OpenCode rules globs move (#946)', () => { + let tmpDir: string; + let homeDir: string; + let projectRoot: string; + let saved: State; + + beforeEach(async () => { + tmpDir = await fse.mkdtemp(path.join(os.tmpdir(), 'teamai-oc-glob-upgrade-')); + homeDir = path.join(tmpDir, 'home'); + projectRoot = path.join(tmpDir, 'project'); + await fse.ensureDir(path.join(homeDir, '.config', 'opencode')); + await fse.ensureDir(path.join(projectRoot, '.opencode')); + vi.stubEnv('HOME', homeDir); + saved = {} as State; + vi.mocked(saveStateForScope).mockImplementation(async (state) => { + saved = structuredClone(state); + }); + vi.mocked(loadStateForScope).mockImplementation(async () => structuredClone(saved) as never); + vi.mocked(loadTeamConfig).mockResolvedValue( + TeamaiConfigSchema.parse({ team: 'test', repo: 'https://example.invalid/x/team.git' }), + ); + }); + + afterEach(async () => { + vi.mocked(saveStateForScope).mockReset(); + vi.mocked(loadStateForScope).mockImplementation(async () => ({}) as never); + vi.unstubAllEnvs(); + await fse.remove(tmpDir); + }); + + /** Pull once at revision abc1234, then put back what an older CLI left there. */ + async function pullThenDowngrade(scope: 'user' | 'project', configFile: string, old: unknown): Promise { + const repoPath = path.join(tmpDir, 'team-repo'); + await fse.outputFile(path.join(repoPath, 'rules', 'team-rule.md'), 'Use named exports.\n'); + vi.mocked(loadLocalConfigForScope).mockResolvedValue({ + repo: { localPath: repoPath, remote: 'https://example.invalid/x/team.git' }, + username: 'u', + updatePolicy: 'auto', + additionalRoles: [], + scope, + projectRoot: scope === 'project' ? projectRoot : undefined, + enabledAgents: ['opencode'], + } as LocalConfig); + await pull({}); + await fse.outputJson(configFile, old); + vi.mocked(log.success).mockClear(); + } + + const alreadySynced = (): boolean => vi.mocked(log.success).mock.calls + .some(([message]) => String(message).includes('Already synced at abc1234')); + + it('user scope: replaces the old relative glob with the absolute one', async () => { + const config = path.join(homeDir, '.config', 'opencode', 'opencode.json'); + await pullThenDowngrade('user', config, { model: 'mine', instructions: ['rules/*.md'] }); + + await pull({}); + + expect(alreadySynced()).toBe(true); + const rules = path.join(homeDir, '.config', 'opencode', 'rules'); + expect(await fse.readJson(config)).toEqual({ model: 'mine', instructions: [`${rules}/*.md`] }); + }); + + it('project scope: moves the glob from the root opencode.json to .opencode/opencode.json', async () => { + const root = path.join(projectRoot, 'opencode.json'); + const dot = path.join(projectRoot, '.opencode', 'opencode.json'); + await pullThenDowngrade('project', root, { theme: 'dark', instructions: ['.opencode/rules/*.md'] }); + await fse.outputJson(dot, {}); + + await pull({}); + + expect(alreadySynced()).toBe(true); + expect(await fse.readJson(dot)).toEqual({ instructions: ['.opencode/rules/**/*.md'] }); + expect(await fse.readJson(root)).toEqual({ theme: 'dark' }); + }); +}); + +/** + * When no team rule reaches a tool any more, the copies teamai delivered go, + * including the ones an older CLI wrote before the tool got its own render + * (#946). + */ +describe('a rules sync that delivers no rule reclaims an older-format copy (#946)', () => { + let tmpDir: string; + + afterEach(async () => { + vi.unstubAllEnvs(); + await fse.remove(tmpDir); + }); + + it('removes the verbatim Kiro copy of a rule this member no longer receives, and keeps an edited one', async () => { + tmpDir = await fse.mkdtemp(path.join(os.tmpdir(), 'teamai-unselected-old-render-')); + const homeDir = path.join(tmpDir, 'home'); + const repoPath = path.join(tmpDir, 'team-repo'); + const SCOPED = '---\npaths:\n - "src/**"\n---\n\nUse named exports.\n'; + await fse.outputFile(path.join(repoPath, 'rules', 'scoped.md'), SCOPED); + await fse.outputFile(path.join(repoPath, 'rules', 'other.md'), 'Other rule.\n'); + const old = path.join(homeDir, '.kiro', 'steering', 'scoped.md'); + const edited = path.join(homeDir, '.kiro', 'steering', 'other.md'); + await fse.outputFile(old, SCOPED); + await fse.outputFile(edited, 'Other rule, my way.\n'); + vi.stubEnv('HOME', homeDir); + const { RulesHandler } = await import('../resources/rules.js'); + const localConfig = { + repo: { localPath: repoPath, remote: 'https://example.invalid/x/team.git' }, + username: 'u', + updatePolicy: 'auto', + additionalRoles: [], + scope: 'user', + enabledAgents: ['kiro'], + } as LocalConfig; + + await new RulesHandler().pullAllRules( + TeamaiConfigSchema.parse({ team: 'test', repo: 'https://example.invalid/x/team.git' }), localConfig, [], + ); + + expect(await fse.pathExists(old)).toBe(false); + expect(await fse.readFile(edited, 'utf8')).toBe('Other rule, my way.\n'); + }); +}); + +/** + * An older CLI let a project pull rewrite the global SOUL.md block with the + * project's rules. The upgrade must repair it on the next user pull, even + * at an unchanged team revision (#946). + */ +describe('a user pull at an unchanged team revision repairs the Hermes SOUL.md block (#946)', () => { + let tmpDir: string; + let homeDir: string; + let saved: State; + const soul = () => path.join(homeDir, '.hermes', 'SOUL.md'); + + beforeEach(async () => { + tmpDir = await fse.mkdtemp(path.join(os.tmpdir(), 'teamai-soul-repair-')); + homeDir = path.join(tmpDir, 'home'); + const repoPath = path.join(tmpDir, 'team-repo'); + await fse.ensureDir(path.join(homeDir, '.hermes')); + await fse.outputFile(path.join(repoPath, 'rules', 'codeword.md'), 'The user codeword is HERON-7.\n'); + vi.stubEnv('HOME', homeDir); + vi.stubEnv('HERMES_HOME', ''); + saved = {} as State; + vi.mocked(saveStateForScope).mockImplementation(async (state) => { + saved = structuredClone(state); + }); + vi.mocked(loadStateForScope).mockImplementation(async () => structuredClone(saved) as never); + vi.mocked(loadTeamConfig).mockResolvedValue( + TeamaiConfigSchema.parse({ team: 'test', repo: 'https://example.invalid/x/team.git' }), + ); + vi.mocked(loadLocalConfigForScope).mockResolvedValue({ + repo: { localPath: repoPath, remote: 'https://example.invalid/x/team.git' }, + username: 'u', + updatePolicy: 'auto', + additionalRoles: [], + scope: 'user', + enabledAgents: ['hermes'], + } as LocalConfig); + await pull({}); + vi.mocked(log.success).mockClear(); + }); + + afterEach(async () => { + vi.mocked(saveStateForScope).mockReset(); + vi.mocked(loadStateForScope).mockImplementation(async () => ({}) as never); + vi.unstubAllEnvs(); + await fse.remove(tmpDir); + }); + + it('rewrites a block an older project pull filled with the project\'s rules', async () => { + const before = await fse.readFile(soul(), 'utf8'); + expect(before).toContain('HERON-7'); + await fse.writeFile(soul(), before.replace('The user codeword is HERON-7.', 'The project codeword is OTTER-9.')); + + await pull({}); + + const successes = vi.mocked(log.success).mock.calls.map(([message]) => String(message)); + expect(successes.some((message) => message.includes('Already synced at abc1234'))).toBe(true); + expect(await fse.readFile(soul(), 'utf8')).toBe(before); + }); +}); + +/** + * OMP writes namespaced rules flat, and judges a clash of flat names among + * the rules this member receives. Pull already holds that list, so the + * number of times it resolves the rules must not grow with the rules (#946). + */ +describe('a pull for OMP with namespaced rules resolves the rules a fixed number of times (#946)', () => { + let tmpDir: string; + + afterEach(async () => { + vi.mocked(saveStateForScope).mockReset(); + vi.mocked(loadStateForScope).mockImplementation(async () => ({}) as never); + vi.unstubAllEnvs(); + await fse.remove(tmpDir); + }); + + async function resolvesFor(namespaced: number): Promise { + tmpDir = await fse.mkdtemp(path.join(os.tmpdir(), 'teamai-omp-resolves-')); + const homeDir = path.join(tmpDir, 'home'); + const repoPath = path.join(tmpDir, 'team-repo'); + await fse.ensureDir(path.join(homeDir, '.omp', 'agent')); + await fse.outputFile(path.join(repoPath, 'rules', 'root.md'), 'Root rule.\n'); + for (let i = 0; i < namespaced; i++) await fse.outputFile(path.join(repoPath, 'rules', 'fe', `r${i}.md`), `Rule ${i}.\n`); + vi.stubEnv('HOME', homeDir); + let saved = {} as State; + vi.mocked(saveStateForScope).mockImplementation(async (state) => { + saved = structuredClone(state); + }); + vi.mocked(loadStateForScope).mockImplementation(async () => structuredClone(saved) as never); + vi.mocked(loadTeamConfig).mockResolvedValue( + TeamaiConfigSchema.parse({ team: 'test', repo: 'https://example.invalid/x/team.git' }), + ); + vi.mocked(loadLocalConfigForScope).mockResolvedValue({ + repo: { localPath: repoPath, remote: 'https://example.invalid/x/team.git' }, + username: 'u', + updatePolicy: 'auto', + additionalRoles: [], + scope: 'user', + enabledAgents: ['omp'], + } as LocalConfig); + vi.mocked(filterByTags).mockClear(); + + await pull({ force: true }); + + expect(await fse.pathExists(path.join(homeDir, '.omp', 'agent', 'rules', 'fe.r0.md'))).toBe(true); + const calls = vi.mocked(filterByTags).mock.calls.filter((call) => call[3] === 'rules').length; + await fse.remove(tmpDir); + return calls; + } + + it('resolves as often with five namespaced rules as with one', async () => { + const one = await resolvesFor(1); + const five = await resolvesFor(5); + + expect(five).toBe(one); + }); +}); diff --git a/src/__tests__/pull-tombstone.test.ts b/src/__tests__/pull-tombstone.test.ts index 9c8da00c8..9d32cc272 100644 --- a/src/__tests__/pull-tombstone.test.ts +++ b/src/__tests__/pull-tombstone.test.ts @@ -45,7 +45,8 @@ vi.mock('../utils/logger.js', () => ({ })), })); -import { pull, cleanupInactiveNamespaceSkills } from '../pull.js'; +import { pull, cleanupInactiveNamespaceSkills, checkoutKey } from '../pull.js'; +import { fileHash } from '../utils/fs.js'; import { loadLocalConfigForScope, loadTeamConfig, detectProjectConfig, loadStateForScope } from '../config.js'; import type { TeamaiConfig, LocalConfig } from '../types.js'; @@ -191,6 +192,33 @@ describe('pull role-aware sync and cleanup', () => { expect(await fse.pathExists(path.join(homeDir, '.codex/rules', 'old-rule.md'))).toBe(false); }); + it("cleans up a tombstoned rule's flat OMP copy on record, not a member's file of that name or a live root rule (#946)", async () => { + // The beforeEach config, mocked; OMP added. + const teamConfig = (await loadTeamConfig(repoPath))!; + vi.mocked(loadTeamConfig).mockResolvedValue({ + ...teamConfig, + toolPaths: { ...teamConfig.toolPaths, omp: { rules: '.omp/rules', userScope: { rules: '.omp/agent/rules' } } }, + }); + const ompRules = path.join(homeDir, '.omp', 'agent', 'rules'); + await fse.writeFile(path.join(repoPath, 'rules', '.removed'), 'fe/old\nbe/api\nfe/live\n'); + await fse.writeFile(path.join(repoPath, 'rules', 'fe.live.md'), 'Live root rule.\n'); + const recorded = path.join(ompRules, 'fe.old.md'); + const mine = path.join(ompRules, 'be.api.md'); + await fse.outputFile(recorded, '---\nalwaysApply: true\n---\n\nOld.\n'); + await fse.outputFile(mine, 'My own dotted rule.\n'); + const delivered = { [recorded]: (await fileHash(recorded))! }; + vi.mocked(loadStateForScope).mockImplementation(async () => ({ + lastPull: null, + lastPullByWorkspace: { [await checkoutKey(homeDir)]: { rev: 'old', targets: [], delivered } }, + }) as unknown as Awaited>); + + await pull({}); + + expect(await fse.pathExists(recorded)).toBe(false); + expect(await fse.readFile(mine, 'utf8')).toBe('My own dotted rule.\n'); + expect(await fse.readFile(path.join(ompRules, 'fe.live.md'), 'utf8')).toBe('---\nalwaysApply: true\n---\n\nLive root rule.\n'); + }); + it('should clean up local skill directories that are tombstoned', async () => { // Tombstone for "old-skill" await fse.writeFile(path.join(repoPath, 'skills', '.removed'), 'old-skill\n'); diff --git a/src/__tests__/pull-workbuddy-rules-move.test.ts b/src/__tests__/pull-workbuddy-rules-move.test.ts new file mode 100644 index 000000000..3e9922260 --- /dev/null +++ b/src/__tests__/pull-workbuddy-rules-move.test.ts @@ -0,0 +1,146 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import crypto from 'node:crypto'; +import path from 'node:path'; +import os from 'node:os'; +import fse from 'fs-extra'; + +vi.mock('../config.js', async (importOriginal) => ({ + ...(await importOriginal()), + requireInit: vi.fn(), + loadState: vi.fn().mockResolvedValue({ lastPull: null, lastPullRev: null }), + saveState: vi.fn(), + loadStateForScope: vi.fn(async () => ({})), + saveStateForScope: vi.fn(), + loadLocalConfigForScope: vi.fn(), + loadTeamConfig: vi.fn(), + detectProjectConfig: vi.fn().mockResolvedValue(null), + autoDetectInit: vi.fn(), +})); + +vi.mock('../utils/git.js', async (importOriginal) => ({ + ...(await importOriginal()), + pullRepo: vi.fn().mockResolvedValue('already up to date'), + getHeadRev: vi.fn().mockResolvedValue('abc1234'), + createGit: vi.fn(), +})); + +// pull() takes a real ~/.teamai/.sync-lock; parallel workers would race on it. +vi.mock('../update.js', () => ({ + acquireLock: vi.fn().mockResolvedValue(true), + releaseLock: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock('../utils/logger.js', () => ({ + log: { + persist: vi.fn(), + info: vi.fn(), + success: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + dim: vi.fn(), + }, + spinner: vi.fn(() => ({ + start: vi.fn().mockReturnThis(), + succeed: vi.fn().mockReturnThis(), + fail: vi.fn().mockReturnThis(), + warn: vi.fn().mockReturnThis(), + info: vi.fn().mockReturnThis(), + stop: vi.fn().mockReturnThis(), + })), +})); + +import { pull } from '../pull.js'; +import { log } from '../utils/logger.js'; +import { detectProjectConfig, loadStateForScope, loadTeamConfig, saveStateForScope } from '../config.js'; +import { TeamaiConfigSchema, type LocalConfig, type State } from '../types.js'; + +const sha256 = (text: string) => crypto.createHash('sha256').update(text).digest('hex'); + +/** + * WorkBuddy's project rules move from `.workbuddy/rules`, which it never read, + * to CodeBuddy's `.codebuddy/rules` (#946). The new path has no delivery + * record, so a pull at an unchanged team revision must still write it. + */ +describe('a pull at an unchanged team revision after WorkBuddy\'s project rules move (#946)', () => { + let tmpDir: string; + let projectRoot: string; + let saved: State; + + const SCOPED = '---\npaths: ["src/**"]\n---\n\nUse named exports.\n'; + const RENDER = '---\nalwaysApply: false\npaths:\n - "src/**"\n---\n\nUse named exports.\n'; + const oldCopy = () => path.join(projectRoot, '.workbuddy', 'rules', 'scoped.md'); + const newCopy = () => path.join(projectRoot, '.codebuddy', 'rules', 'scoped.md'); + const record = () => Object.values(saved.lastPullByWorkspace ?? {})[0]; + + beforeEach(async () => { + tmpDir = await fse.mkdtemp(path.join(os.tmpdir(), 'teamai-workbuddy-move-')); + const homeDir = path.join(tmpDir, 'home'); + projectRoot = path.join(tmpDir, 'project'); + const repoPath = path.join(tmpDir, 'team-repo'); + await fse.ensureDir(homeDir); + await fse.ensureDir(path.join(projectRoot, '.workbuddy')); + await fse.outputFile(path.join(repoPath, 'rules', 'scoped.md'), SCOPED); + vi.stubEnv('HOME', homeDir); + saved = {} as State; + vi.mocked(saveStateForScope).mockImplementation(async (state) => { + saved = structuredClone(state); + }); + vi.mocked(loadStateForScope).mockImplementation(async () => structuredClone(saved) as never); + vi.mocked(loadTeamConfig).mockResolvedValue( + TeamaiConfigSchema.parse({ team: 'test', repo: 'https://example.invalid/x/team.git' }), + ); + vi.mocked(detectProjectConfig).mockResolvedValue({ + repo: { localPath: repoPath, remote: 'https://example.invalid/x/team.git' }, + username: 'u', + updatePolicy: 'auto', + additionalRoles: [], + scope: 'project', + projectRoot, + enabledAgents: ['workbuddy'], + } as LocalConfig); + await pull({}); + // What an older CLI left at this revision: the verbatim copy in + // .workbuddy/rules, on record, and nothing in .codebuddy/rules. + await fse.remove(newCopy()); + await fse.outputFile(oldCopy(), SCOPED); + const { [newCopy()]: _dropped, ...rest } = record().delivered ?? {}; + record().delivered = { ...rest, [oldCopy()]: sha256(SCOPED) }; + vi.mocked(log.success).mockClear(); + vi.mocked(log.warn).mockClear(); + }); + + afterEach(async () => { + vi.mocked(saveStateForScope).mockReset(); + vi.mocked(loadStateForScope).mockImplementation(async () => ({}) as never); + vi.mocked(detectProjectConfig).mockResolvedValue(null); + vi.unstubAllEnvs(); + await fse.remove(tmpDir); + }); + + it('writes the rule to .codebuddy/rules, records it, and reclaims the .workbuddy/rules copy', async () => { + await pull({}); + + const successes = vi.mocked(log.success).mock.calls.map(([message]) => String(message)); + expect(successes.some((message) => message.includes('Already synced at abc1234'))).toBe(true); + expect(await fse.readFile(newCopy(), 'utf8')).toBe(RENDER); + expect(record().delivered?.[newCopy()]).toBe(sha256(RENDER)); + expect(await fse.pathExists(oldCopy())).toBe(false); + expect(record().delivered?.[oldCopy()]).toBeUndefined(); + expect(await fse.pathExists(path.dirname(oldCopy()))).toBe(false); + expect(vi.mocked(log.warn)).not.toHaveBeenCalled(); + }); + + it('also writes .codebuddy/rules for a .workbuddy/rules copy the member edited, which it keeps and names', async () => { + const edited = SCOPED.replace('named', 'my own'); + await fse.writeFile(oldCopy(), edited); + + await pull({}); + + expect(await fse.readFile(newCopy(), 'utf8')).toBe(RENDER); + expect(record().delivered?.[newCopy()]).toBe(sha256(RENDER)); + expect(await fse.readFile(oldCopy(), 'utf8')).toBe(edited); + const warnings = vi.mocked(log.warn).mock.calls.map(([message]) => String(message)); + expect(warnings.filter((message) => message.includes(`Kept ${oldCopy()}`))).toHaveLength(1); + }); +}); diff --git a/src/__tests__/push-flat-member-file.test.ts b/src/__tests__/push-flat-member-file.test.ts new file mode 100644 index 000000000..077a0a029 --- /dev/null +++ b/src/__tests__/push-flat-member-file.test.ts @@ -0,0 +1,67 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import path from 'node:path'; +import os from 'node:os'; +import fse from 'fs-extra'; + +vi.mock('../config.js', async (importOriginal) => ({ + ...(await importOriginal()), + requireInit: vi.fn(), + loadState: vi.fn().mockResolvedValue({ lastPull: null, lastPullRev: null }), + saveState: vi.fn(), + loadStateForScope: vi.fn(async () => ({})), + saveStateForScope: vi.fn(), + loadLocalConfigForScope: vi.fn(), + loadTeamConfig: vi.fn(), + detectProjectConfig: vi.fn().mockResolvedValue(null), + autoDetectInit: vi.fn(), +})); +vi.mock('../utils/git.js', async (importOriginal) => ({ + ...(await importOriginal()), + pullRepo: vi.fn().mockResolvedValue('already up to date'), + getHeadRev: vi.fn().mockResolvedValue('abc1234'), + createGit: vi.fn(), +})); +vi.mock('../update.js', () => ({ acquireLock: vi.fn().mockResolvedValue(true), releaseLock: vi.fn().mockResolvedValue(undefined) })); +vi.mock('../utils/logger.js', () => ({ + log: { persist: vi.fn(), info: vi.fn(), success: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn(), dim: vi.fn() }, + spinner: vi.fn(() => ({ start: vi.fn().mockReturnThis(), succeed: vi.fn().mockReturnThis(), fail: vi.fn().mockReturnThis(), warn: vi.fn().mockReturnThis(), info: vi.fn().mockReturnThis(), stop: vi.fn().mockReturnThis() })), +})); + + +import { pull } from '../pull.js'; +import { loadLocalConfigForScope, loadStateForScope, loadTeamConfig, saveStateForScope } from '../config.js'; +import { TeamaiConfigSchema, type LocalConfig, type State } from '../types.js'; +import { RulesHandler } from '../resources/rules.js'; + +describe('a member\'s own file at a namespaced rule\'s flat name (#946)', () => { + let tmp: string; let home: string; let saved: State; let local: LocalConfig; let repo: string; + beforeEach(async () => { + tmp = await fse.mkdtemp(path.join(os.tmpdir(), 'teamai-push-member-flat-')); + home = path.join(tmp, 'home'); + repo = path.join(tmp, 'repo'); + await fse.ensureDir(path.join(home, '.kiro', 'steering')); + await fse.outputFile(path.join(home, '.kiro', 'steering', 'fe.style.md'), '---\ninclusion: always\n---\n\nMy personal notes about FE style.\n'); + await fse.outputFile(path.join(repo, 'rules', 'fe', 'style.md'), 'Team: use tabs.\n'); + vi.stubEnv('HOME', home); + saved = {} as State; + vi.mocked(saveStateForScope).mockImplementation(async (s) => { saved = structuredClone(s); }); + vi.mocked(loadStateForScope).mockImplementation(async () => structuredClone(saved) as never); + vi.mocked(loadTeamConfig).mockResolvedValue(TeamaiConfigSchema.parse({ team: 't', repo: 'https://example.invalid/x/t.git' })); + local = { + repo: { localPath: repo, remote: 'https://example.invalid/x/t.git' }, username: 'u', updatePolicy: 'auto', + additionalRoles: [], scope: 'user', enabledAgents: ['kiro'], + } as LocalConfig; + vi.mocked(loadLocalConfigForScope).mockResolvedValue(local); + }); + afterEach(async () => { vi.unstubAllEnvs(); await fse.remove(tmp); }); + + it('is kept by pull and not offered by push as an edit of the team rule', async () => { + await pull({}); + expect(await fse.readFile(path.join(home, '.kiro', 'steering', 'fe.style.md'), 'utf8')).toContain('My personal notes'); + + const team = TeamaiConfigSchema.parse({ team: 't', repo: 'https://example.invalid/x/t.git' }); + const items = await new RulesHandler().scanLocalForPush(team, local); + + expect(items.find((i) => i.name === 'fe/style')).toBeUndefined(); + }); +}); diff --git a/src/__tests__/push-namespace-e2e.test.ts b/src/__tests__/push-namespace-e2e.test.ts index ccef65666..963c14ee1 100644 --- a/src/__tests__/push-namespace-e2e.test.ts +++ b/src/__tests__/push-namespace-e2e.test.ts @@ -21,6 +21,13 @@ const CLI = path.join(ROOT, 'dist', 'index.js'); const PUSH_AGENTS = ['claude', 'codex', 'codebuddy', 'opencode'] as const; type PushAgent = (typeof PUSH_AGENTS)[number]; +/** + * Whether a new file in the tool's rules directory is a new team rule. A tool + * with a rules format of its own keeps the member's own rules there, in that + * format, so push never offers one (CodeBuddy since #946, as Cursor). + */ +const pushesNewRule = (agent: PushAgent): boolean => agent !== 'codebuddy'; + const GIT_ENV = { GIT_AUTHOR_NAME: 'TeamAI CI', GIT_AUTHOR_EMAIL: 'ci@teamai.test', @@ -278,12 +285,14 @@ describe('push places new rules and agents in a namespace (issue #649)', () => { ); // The generic git provider cannot open a PR; the branch is still pushed. - expect(result.output).toContain('[rules] my-rule โ†’ rules/fe-know/my-rule.md'); + if (pushesNewRule(agent)) expect(result.output).toContain('[rules] my-rule โ†’ rules/fe-know/my-rule.md'); + else expect(result.output).not.toContain('[rules] my-rule'); expect(result.output).toContain('[agents] vr โ†’ agents/fe-agents/vr.yaml'); const { branch, files } = branchFiles(fixture); expect(branch, result.output).not.toBe(''); - expect(files).toContain('rules/fe-know/my-rule.md'); + if (pushesNewRule(agent)) expect(files).toContain('rules/fe-know/my-rule.md'); + else expect(files).not.toContain('rules/fe-know/my-rule.md'); expect(files).toContain('skills/fe-skills/my-skill/SKILL.md'); expect(files).toContain('agents/fe-agents/vr.yaml'); // The shared root is what shipped the rule to the whole team before #649. @@ -296,7 +305,9 @@ describe('push places new rules and agents in a namespace (issue #649)', () => { const state = readState(fixture) as { placedRules?: unknown; pendingPushes: Array<{ items: Array> }> }; expect(state.placedRules ?? {}).toEqual({}); expect(state.pendingPushes.at(-1)?.items).toEqual(expect.arrayContaining([ - expect.objectContaining({ type: 'rules', name: 'my-rule', relativePath: 'rules/fe-know/my-rule.md', placed: true }), + ...pushesNewRule(agent) + ? [expect.objectContaining({ type: 'rules', name: 'my-rule', relativePath: 'rules/fe-know/my-rule.md', placed: true })] + : [], expect.objectContaining({ type: 'agents', name: 'vr', relativePath: 'agents/fe-agents/vr.yaml', placed: true }), ])); }, 60_000); @@ -948,7 +959,7 @@ describe('push namespace placement reaches the PR providers (issue #649)', () => expect(result.output).toContain('Pull Request created: https://github.com/team/issue-649/pull/649'); expect(fs.readFileSync(ghLog, 'utf8')).toContain('pr create -R team/issue-649'); const { files } = branchFiles(fixture); - expect(files).toContain('rules/fe-know/my-rule.md'); + if (pushesNewRule(agent)) expect(files).toContain('rules/fe-know/my-rule.md'); expect(files).toContain('agents/fe-agents/vr.yaml'); expect(files).not.toContain('rules/my-rule.md'); }, 60_000); @@ -994,7 +1005,7 @@ describe('push namespace placement reaches the PR providers (issue #649)', () => ); expect(requestPaths).toEqual(['/api/v4/projects/team%2Fissue-649/merge_requests']); const { files } = branchFiles(fixture); - expect(files).toContain('rules/fe-know/my-rule.md'); + if (pushesNewRule(agent)) expect(files).toContain('rules/fe-know/my-rule.md'); expect(files).toContain('agents/fe-agents/vr.yaml'); expect(files).not.toContain('rules/my-rule.md'); } finally { diff --git a/src/__tests__/qoder-cn.test.ts b/src/__tests__/qoder-cn.test.ts index 8c6dd5de9..6312daec7 100644 --- a/src/__tests__/qoder-cn.test.ts +++ b/src/__tests__/qoder-cn.test.ts @@ -10,7 +10,7 @@ import { renderForTool, } from '../resources/agent-format.js'; import { detectMcpFormat } from '../resources/mcp-format.js'; -import { ruleFileExtensionForTool, usesCursorMdcRules } from '../resources/rule-format.js'; +import { ruleFileExtensionForTool, usesMdcRules } from '../resources/rule-format.js'; import { TeamaiConfigSchema, scopedToolPaths } from '../types.js'; import type { LocalConfig } from '../types.js'; @@ -76,7 +76,7 @@ describe('Qoder CN support', () => { expect(ALL_SUPPORTED_TOOLS).toContain('qoder-cn'); expect(agentFileExtensionForTool('qoder-cn')).toBe('.md'); expect(ruleFileExtensionForTool('qoder-cn')).toBe('.md'); - expect(usesCursorMdcRules('qoder-cn')).toBe(false); + expect(usesMdcRules('qoder-cn')).toBe(false); }); it('renders Qoder CN subagents exactly like Qoder', () => { diff --git a/src/__tests__/qoder.test.ts b/src/__tests__/qoder.test.ts index 9a1ab7b51..33eed63fc 100644 --- a/src/__tests__/qoder.test.ts +++ b/src/__tests__/qoder.test.ts @@ -9,7 +9,7 @@ import { ALL_SUPPORTED_TOOLS, } from '../resources/agent-format.js'; import { detectMcpFormat } from '../resources/mcp-format.js'; -import { ruleFileExtensionForTool, usesCursorMdcRules } from '../resources/rule-format.js'; +import { ruleFileExtensionForTool, usesMdcRules } from '../resources/rule-format.js'; import { TeamaiConfigSchema } from '../types.js'; import type { LocalConfig } from '../types.js'; @@ -37,7 +37,7 @@ describe('Qoder support', () => { expect(ALL_SUPPORTED_TOOLS).toContain('qoder'); expect(agentFileExtensionForTool('qoder')).toBe('.md'); expect(ruleFileExtensionForTool('qoder')).toBe('.md'); - expect(usesCursorMdcRules('qoder')).toBe(false); + expect(usesMdcRules('qoder')).toBe(false); }); it('uses the mcpServers JSON format in Qoder settings', () => { diff --git a/src/__tests__/rule-parsers.test.ts b/src/__tests__/rule-parsers.test.ts new file mode 100644 index 000000000..a112e85df --- /dev/null +++ b/src/__tests__/rule-parsers.test.ts @@ -0,0 +1,107 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { afterAll, describe, expect, it } from 'vitest'; +import { teamRuleToCodebuddyRule } from '../resources/codebuddy-rule.js'; +import { teamRuleToCursorMdc } from '../resources/cursor-mdc.js'; +import { teamRuleToJoycodeRule } from '../resources/joycode-rule.js'; +import { + loadCodebuddyRuleParser, loadCursorRuleParser, loadJoycodeRuleParser, ruleParserBundle, +} from './helpers/rule-parsers.js'; + +/** + * Each render read back by the tool's own parser, taken from its installed + * bundle (`TEAMAI_RULE_PARSER_BUNDLES`, see helpers/rule-parsers.ts). Skipped + * where the bundle is absent; the contract tests pin the bytes everywhere. + */ +const BODY = 'Use named exports.\n\n---\n\nA rule with a horizontal rule in it.'; + +const cursorBundle = ruleParserBundle('cursor'); + +describe.skipIf(!cursorBundle)('Cursor reads the .mdc render as intended', () => { + const parser = cursorBundle ? loadCursorRuleParser(cursorBundle) : undefined; + + it.each([ + ['an unscoped rule', BODY, true, undefined], + ['an inline list', `---\npaths: ["src/**/*.ts", "test/**"]\n---\n\n${BODY}`, false, ['src/**/*.ts', 'test/**']], + ['a block list', `---\npaths:\n - "src/**/*.ts"\n - test/**\n---\n\n${BODY}`, false, ['src/**/*.ts', 'test/**']], + ['a brace glob', `---\npaths:\n - "src/{a,b}/**"\n - "**/*.{ts,tsx}"\n---\n\n${BODY}`, false, ['src/{a,b}/**', '**/*.{ts,tsx}']], + ['an unquoted alias-like glob', `---\npaths: **/*.ts\n---\n\n${BODY}`, false, ['**/*.ts']], + ])('%s', (_label, source, alwaysApply, globs) => { + const parsed = parser!.parse(teamRuleToCursorMdc(source)); + + expect(parsed).not.toBeNull(); + expect(parsed!.frontmatter.alwaysApply).toBe(alwaysApply); + expect(parser!.globs(parsed!.frontmatter.globs)).toEqual(globs); + expect(parsed!.body).toBe(BODY); + }); +}); + +const codebuddyBundle = ruleParserBundle('codebuddy'); + +describe.skipIf(!codebuddyBundle)('CodeBuddy (and WorkBuddy) read the CodeBuddy render as intended', () => { + const parser = codebuddyBundle ? loadCodebuddyRuleParser(codebuddyBundle) : undefined; + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'teamai-codebuddy-parser-')); + afterAll(() => fs.rmSync(dir, { recursive: true, force: true })); + + it.each([ + ['an unscoped rule', BODY, 'ALWAYS', undefined], + ['an inline list', `---\npaths: ["src/**/*.ts", "test/**"]\n---\n\n${BODY}`, 'MANUAL', ['src/**/*.ts', 'test/**']], + ['a block list', `---\npaths:\n - "src/**/*.ts"\n - test/**\n---\n\n${BODY}`, 'MANUAL', ['src/**/*.ts', 'test/**']], + ['a comma-separated string', `---\npaths: src/**/*.ts, test/**\n---\n\n${BODY}`, 'MANUAL', ['src/**/*.ts', 'test/**']], + ['a brace glob', `---\npaths:\n - "src/{a,b}/**"\n - "**/*.{ts,tsx}"\n---\n\n${BODY}`, 'MANUAL', ['src/{a,b}/**', '**/*.{ts,tsx}']], + ['an unquoted alias-like glob', `---\npaths: **/*.ts\n---\n\n${BODY}`, 'MANUAL', ['**/*.ts']], + ])('%s', async (label, source, type, globs) => { + const file = path.join(dir, `${label.replaceAll(' ', '-')}.md`); + fs.writeFileSync(file, teamRuleToCodebuddyRule(source)); + + const parsed = await parser!.parse(file); + + expect(parsed.type).toBe(type); + expect(parsed.alwaysApply).toBe(type === 'ALWAYS'); + expect(parsed.globs).toEqual(globs); + expect(parsed.content).toBe(BODY.trim()); + }); + + // The verbatim copy teamai wrote before: CodeBuddy kept the brackets. + it('reads the old verbatim copy of an inline list with brackets in its globs', async () => { + const file = path.join(dir, 'verbatim.md'); + fs.writeFileSync(file, `---\npaths: ["src/**/*.ts", "test/**"]\n---\n\n${BODY}`); + + expect((await parser!.parse(file)).globs).toEqual(['["src/**/*.ts"', '"test/**"]']); + }); +}); + +const joycodeBundle = ruleParserBundle('joycode'); + +describe.skipIf(!joycodeBundle)('JoyCode reads its .mdc render as intended', () => { + const parser = joycodeBundle ? loadJoycodeRuleParser(joycodeBundle) : undefined; + const cwd = '/repo'; + const applied = (mdc: string, files: string[]): string[] => + files.filter((file) => parser!.applies(mdc, 'rule.mdc', path.join(cwd, file), cwd)); + const FILES = ['src/a/x.ts', 'src/b/y.tsx', 'src/c/z.ts', 'test/t.js', 'docs/d.md']; + + it.each([ + ['an unscoped rule', BODY, true, FILES], + ['an inline list', `---\npaths: ["src/**/*.ts", "test/**"]\n---\n\n${BODY}`, false, ['src/a/x.ts', 'src/c/z.ts', 'test/t.js']], + ['a block list', `---\npaths:\n - "src/**/*.ts"\n - test/**\n---\n\n${BODY}`, false, ['src/a/x.ts', 'src/c/z.ts', 'test/t.js']], + ['a brace glob', `---\npaths:\n - "src/{a,b}/**"\n - "**/*.{md,js}"\n---\n\n${BODY}`, false, ['src/a/x.ts', 'src/b/y.tsx', 'test/t.js', 'docs/d.md']], + ['an unquoted alias-like glob', `---\npaths: **/*.ts\n---\n\n${BODY}`, false, ['src/a/x.ts', 'src/c/z.ts']], + ])('%s', (_label, source, alwaysApply, files) => { + const mdc = teamRuleToJoycodeRule(source); + const parsed = parser!.parse(mdc, 'rule.mdc'); + + expect(parsed.alwaysApply).toBe(alwaysApply); + expect(parsed.body).toBe(BODY); + expect(applied(mdc, FILES)).toEqual(files); + }); + + // Cursor's render, which teamai wrote before: JoyCode keeps the quotes and + // splits the brace group, so the rule applies to no file. + it.each([ + ['an inline list', `---\npaths: ["src/**/*.ts", "test/**"]\n---\n\n${BODY}`], + ['a brace glob', `---\npaths:\n - "src/{a,b}/**"\n---\n\n${BODY}`], + ])('applies the old Cursor render of %s to no file', (_label, source) => { + expect(applied(teamRuleToCursorMdc(source), FILES)).toEqual([]); + }); +}); diff --git a/src/__tests__/rule-render-contracts.test.ts b/src/__tests__/rule-render-contracts.test.ts new file mode 100644 index 000000000..4083ee8d2 --- /dev/null +++ b/src/__tests__/rule-render-contracts.test.ts @@ -0,0 +1,188 @@ +import { describe, expect, it } from 'vitest'; +import { teamRuleToCodebuddyRule } from '../resources/codebuddy-rule.js'; +import { teamRuleToCopilotInstructions } from '../resources/copilot-instructions.js'; +import { teamRuleToJoycodeRule } from '../resources/joycode-rule.js'; +import { teamRuleToKiroSteering } from '../resources/kiro-steering.js'; +import { teamRuleToOmpRule } from '../resources/omp-rule.js'; +import { teamRuleToQoderRule } from '../resources/qoder-rule.js'; + +/** + * The exact bytes each tool's rule render writes (#946). Kiro and Qoder ship + * no parser to run, so these pin the documented form. This file is the test + * of kiro-steering.ts, qoder-rule.ts, omp-rule.ts, codebuddy-rule.ts, + * joycode-rule.ts, copilot-instructions.ts and team-rule.ts's `paths:` parse. + */ +const UNSCOPED = 'Use named exports.\n'; +const INLINE = '---\npaths: ["src/**/*.ts", "test/**"]\n---\n\nUse named exports.\n'; +const BLOCK = '---\npaths:\n - "src/**/*.ts"\n - test/**\n---\n\nUse named exports.\n'; +const BRACE = '---\npaths:\n - "src/{a,b}/**"\n---\n\nUse named exports.\n'; + +describe('Kiro steering render', () => { + it('makes an unscoped rule always included', () => { + expect(teamRuleToKiroSteering(UNSCOPED)).toBe('---\ninclusion: always\n---\n\nUse named exports.\n'); + }); + + it.each([ + ['an inline list', INLINE], + ['a block list', BLOCK], + ])('scopes %s with fileMatch and a fileMatchPattern list', (_label, source) => { + expect(teamRuleToKiroSteering(source)).toBe( + '---\ninclusion: fileMatch\nfileMatchPattern: ["src/**/*.ts", "test/**"]\n---\n\nUse named exports.\n', + ); + }); + + it('keeps a brace glob whole, since the pattern list does not split on commas', () => { + expect(teamRuleToKiroSteering(BRACE)).toBe( + '---\ninclusion: fileMatch\nfileMatchPattern: ["src/{a,b}/**"]\n---\n\nUse named exports.\n', + ); + }); +}); + +describe('Qoder rule render', () => { + it('makes an unscoped rule always on', () => { + expect(teamRuleToQoderRule(UNSCOPED)).toBe('---\ntrigger: always_on\n---\n\nUse named exports.\n'); + }); + + it.each([ + ['an inline list', INLINE], + ['a block list', BLOCK], + ])('scopes %s with trigger glob and one comma-joined glob line, as Qoder Desktop writes it', (_label, source) => { + expect(teamRuleToQoderRule(source)).toBe( + '---\ntrigger: glob\nglob: src/**/*.ts, test/**\n---\n\nUse named exports.\n', + ); + }); + + it('expands a brace glob, since the glob line is split on every comma', () => { + expect(teamRuleToQoderRule(BRACE)).toBe( + '---\ntrigger: glob\nglob: src/a/**, src/b/**\n---\n\nUse named exports.\n', + ); + }); + + it('expands a brace glob given as a comma-separated paths string', () => { + const source = '---\npaths: "src/{a,b}/**, test/**"\n---\n\nUse named exports.\n'; + expect(teamRuleToQoderRule(source)).toBe( + '---\ntrigger: glob\nglob: src/a/**, src/b/**, test/**\n---\n\nUse named exports.\n', + ); + }); +}); + +describe('Oh My Pi rule render', () => { + it('makes an unscoped rule always applied', () => { + expect(teamRuleToOmpRule(UNSCOPED)).toBe('---\nalwaysApply: true\n---\n\nUse named exports.\n'); + }); + + it.each([ + ['an inline list', INLINE], + ['a block list', BLOCK], + ])('scopes %s with globs and a description, as OMP drops a rule with neither', (_label, source) => { + expect(teamRuleToOmpRule(source)).toBe( + '---\ndescription: "Team rule for files matching src/**/*.ts, test/**"\nglobs: ["src/**/*.ts", "test/**"]\n---\n\n' + + 'Use named exports.\n', + ); + }); + + it('keeps a brace glob whole', () => { + expect(teamRuleToOmpRule(BRACE)).toBe( + '---\ndescription: "Team rule for files matching src/{a,b}/**"\nglobs: ["src/{a,b}/**"]\n---\n\nUse named exports.\n', + ); + }); + + it('describes a scoped rule by its first heading, wherever it is', () => { + const source = '---\npaths: ["src/**"]\n---\n\nIntro line.\n\n## API "client" rules\n\nUse the shared client.\n'; + expect(teamRuleToOmpRule(source)).toBe( + '---\ndescription: "API \\"client\\" rules"\nglobs: ["src/**"]\n---\n\nIntro line.\n\n## API "client" rules\n\nUse the shared client.\n', + ); + }); +}); + +describe('CodeBuddy rule render (CodeBuddy and WorkBuddy)', () => { + const SCOPED = '---\nalwaysApply: false\npaths:\n - "src/**/*.ts"\n - "test/**"\n---\n\nUse named exports.\n'; + + it('makes an unscoped rule always applied', () => { + expect(teamRuleToCodebuddyRule(UNSCOPED)).toBe('---\nalwaysApply: true\n---\n\nUse named exports.\n'); + }); + + // Its frontmatter parser reads lines, not YAML: an inline list keeps its + // brackets in the globs, so the render always writes a block list. + it.each([ + ['an inline list', INLINE], + ['a block list', BLOCK], + ['a comma-separated string', '---\npaths: src/**/*.ts, test/**\n---\n\nUse named exports.\n'], + ])('scopes %s with alwaysApply false and paths as a block list', (_label, source) => { + expect(teamRuleToCodebuddyRule(source)).toBe(SCOPED); + }); + + it('keeps a brace glob whole, since a list item is not split on commas', () => { + expect(teamRuleToCodebuddyRule(BRACE)).toBe( + '---\nalwaysApply: false\npaths:\n - "src/{a,b}/**"\n---\n\nUse named exports.\n', + ); + }); +}); + +describe('JoyCode rule render', () => { + it('makes an unscoped rule always applied', () => { + expect(teamRuleToJoycodeRule(UNSCOPED)).toBe('---\nalwaysApply: true\n---\n\nUse named exports.\n'); + }); + + // JoyCode reads the globs line as it stands, quotes included, and splits it + // on every comma: the globs are written unquoted and `{a,b}` is expanded. + it.each([ + ['an inline list', INLINE], + ['a block list', BLOCK], + ])('scopes %s with unquoted, comma-joined globs and alwaysApply false', (_label, source) => { + expect(teamRuleToJoycodeRule(source)).toBe( + '---\nglobs: src/**/*.ts, test/**\nalwaysApply: false\n---\n\nUse named exports.\n', + ); + }); + + it('expands a brace glob, since the globs line is split on every comma', () => { + const source = '---\npaths: ["src/{a,b}/**", "test/**"]\n---\n\nUse named exports.\n'; + expect(teamRuleToJoycodeRule(source)).toBe( + '---\nglobs: src/a/**, src/b/**, test/**\nalwaysApply: false\n---\n\nUse named exports.\n', + ); + }); +}); + +describe('Copilot instructions render', () => { + it('applies an unscoped rule to every file', () => { + expect(teamRuleToCopilotInstructions(UNSCOPED)).toBe("---\napplyTo: '**'\n---\n\nUse named exports.\n"); + }); + + it.each([ + ['an inline list', INLINE], + ['a block list', BLOCK], + ])('scopes %s with one comma-joined applyTo', (_label, source) => { + expect(teamRuleToCopilotInstructions(source)).toBe( + "---\napplyTo: 'src/**/*.ts, test/**'\n---\n\nUse named exports.\n", + ); + }); + + it('scopes an unquoted alias-like glob, as every other render does', () => { + const source = '---\npaths: **/*.ts\n---\n\nUse named exports.\n'; + expect(teamRuleToCopilotInstructions(source)).toBe("---\napplyTo: '**/*.ts'\n---\n\nUse named exports.\n"); + }); +}); + +describe('team rule paths, shared by every render', () => { + it.each([ + 'paths: **/*.ts # TypeScript files', + 'paths: **/*.ts # TypeScript\'s "source" files', + 'paths:\n - **/*.ts # TypeScript files', + 'paths: **/*#draft.ts # TypeScript files', + ])('keeps a YAML comment outside the repaired glob: %s', (frontmatter) => { + const source = `---\n${frontmatter}\n---\n\nUse named exports.\n`; + const glob = frontmatter.includes('#draft') ? '**/*#draft.ts' : '**/*.ts'; + const quoted = `---\npaths: ${JSON.stringify(glob)}\n---\n\nUse named exports.\n`; + expect(teamRuleToKiroSteering(source)).toBe(teamRuleToKiroSteering(quoted)); + expect(teamRuleToOmpRule(source)).toBe(teamRuleToOmpRule(quoted)); + }); + + // gray-matter caches a parse by content, failures included: the retry that + // quotes `**/*.ts` must not lose to a cached failure on the next render. + it('scopes an unquoted alias-like glob on every render, not just the first', () => { + const source = '---\npaths: **/*.ts\n---\n\nUse named exports.\n'; + const scoped = '---\ninclusion: fileMatch\nfileMatchPattern: ["**/*.ts"]\n---\n\nUse named exports.\n'; + expect(teamRuleToKiroSteering(source)).toBe(scoped); + expect(teamRuleToKiroSteering(source)).toBe(scoped); + }); +}); diff --git a/src/__tests__/rules.test.ts b/src/__tests__/rules.test.ts index 3c752ba7c..c71bfed95 100644 --- a/src/__tests__/rules.test.ts +++ b/src/__tests__/rules.test.ts @@ -15,6 +15,7 @@ vi.mock('../config.js', async (importOriginal) => ({ vi.mock('../utils/git.js', () => ({ pullRepo: vi.fn(), + listFilesAtRev: vi.fn(async () => []), pushRepoBranch: vi.fn().mockResolvedValue(true), generateBranchName: vi.fn().mockReturnValue('teamai/push/test/20260305-120000'), })); @@ -618,6 +619,8 @@ scope: 'user', }); it('reclaims delivered copies from a rule directory shared with user rules (JoyCode)', async () => { + // A team entry that keeps a user rules directory for JoyCode; the + // default one reads none in user scope since #946. teamConfig.toolPaths.joycode = { rules: '.joycode/rules' }; await fse.ensureDir(path.join(homeDir, '.joycode', 'rules')); const teamRulesDir = path.join(localConfig.repo.localPath, 'rules'); @@ -1060,22 +1063,95 @@ describe('RulesHandler.pullAllRules โ€” OpenCode instructions activation', () => // File landed under the user-scope OpenCode rules dir. expect(await fse.pathExists(path.join(ocRules(), 'team-rule.md'))).toBe(true); - // opencode.json now references the teamai glob (user scope โ†’ 'rules/*.md'). + // An absolute glob: OpenCode resolves a relative entry from the session + // cwd, so `rules/*.md` would load the project's rules instead (#946). const doc = await fse.readJson(ocConfig()); - expect(doc.instructions).toContain('rules/*.md'); + expect(doc.instructions).toEqual([`${ocRules()}/*.md`]); + }); + + // OpenCode globs only the basename of an absolute entry, so `**` never + // matches: each directory a namespaced rule lands in gets its own glob. + it('adds one glob per namespace directory a rule lands in (#946)', async () => { + const teamRules = path.join(localConfig.repo.localPath, 'rules'); + await fse.writeFile(path.join(teamRules, 'root-rule.md'), 'root'); + await fse.ensureDir(path.join(teamRules, 'fe')); + await fse.writeFile(path.join(teamRules, 'fe', 'style.md'), 'fe style'); + await fse.writeFile(path.join(teamRules, 'fe', 'tests.md'), 'fe tests'); + + await handler.pullAllRules(teamConfig, localConfig); + + expect(await fse.pathExists(path.join(ocRules(), 'fe', 'style.md'))).toBe(true); + expect((await fse.readJson(ocConfig())).instructions).toEqual([`${ocRules()}/*.md`, `${ocRules()}/fe/*.md`]); + }); + + it('replaces the relative rules/*.md an earlier release wrote, keeping the member\'s own entries (#946)', async () => { + await fse.writeFile(path.join(localConfig.repo.localPath, 'rules', 'team-rule.md'), 'team content'); + await fse.ensureDir(path.dirname(ocConfig())); + await fse.writeJson(ocConfig(), { model: 'mine', instructions: ['CONVENTIONS.md', 'rules/*.md'] }); + + await handler.pullAllRules(teamConfig, localConfig); + + expect(await fse.readJson(ocConfig())).toEqual({ model: 'mine', instructions: ['CONVENTIONS.md', `${ocRules()}/*.md`] }); + }); + + it('keeps a glob the member added for a directory of their own under the rules root (#946)', async () => { + await fse.writeFile(path.join(localConfig.repo.localPath, 'rules', 'team-rule.md'), 'team content'); + await fse.ensureDir(path.dirname(ocConfig())); + await fse.writeJson(ocConfig(), { instructions: [`${ocRules()}/mine/*.md`] }); + + await handler.pullAllRules(teamConfig, localConfig); + + expect((await fse.readJson(ocConfig())).instructions).toEqual([`${ocRules()}/mine/*.md`, `${ocRules()}/*.md`]); + }); + + it('drops the glob of a namespace this member no longer receives (#946)', async () => { + const teamRules = path.join(localConfig.repo.localPath, 'rules'); + await fse.writeFile(path.join(teamRules, 'root-rule.md'), 'root'); + await fse.ensureDir(path.join(teamRules, 'fe')); + await fse.writeFile(path.join(teamRules, 'fe', 'style.md'), 'fe style'); + await handler.pullAllRules(teamConfig, localConfig); + + // `fe/` stays in the team repo; this member's selection leaves it out. + const selected = (await handler.scanTeamForPull(teamConfig, localConfig)).filter((rule) => rule.name === 'root-rule'); + await handler.pullAllRules(teamConfig, localConfig, selected); + + expect((await fse.readJson(ocConfig())).instructions).toEqual([`${ocRules()}/*.md`]); + }); + + it('drops the glob of a namespace the team deleted since the last pull (#946)', async () => { + const teamRules = path.join(localConfig.repo.localPath, 'rules'); + await fse.writeFile(path.join(teamRules, 'root-rule.md'), 'root'); + await fse.ensureDir(path.join(teamRules, 'fe')); + await fse.writeFile(path.join(teamRules, 'fe', 'style.md'), 'fe style'); + await handler.pullAllRules(teamConfig, localConfig); + expect((await fse.readJson(ocConfig())).instructions).toContain(`${ocRules()}/fe/*.md`); + + // The team deletes `fe/`; the last pull's revision still has it. + await fse.remove(path.join(teamRules, 'fe')); + const { listFilesAtRev } = await import('../utils/git.js'); + vi.mocked(loadStateForScope).mockResolvedValueOnce({ lastPullRev: 'r1' } as State); + vi.mocked(listFilesAtRev).mockImplementation(async (_repo, rev) => (rev === 'r1' ? ['rules/root-rule.md', 'rules/fe/style.md'] : [])); + try { + await handler.pullAllRules(teamConfig, localConfig); + } finally { + vi.mocked(listFilesAtRev).mockReset(); + vi.mocked(listFilesAtRev).mockResolvedValue([]); + } + + expect((await fse.readJson(ocConfig())).instructions).toEqual([`${ocRules()}/*.md`]); }); it('removes the instructions glob when the team has no rules left', async () => { // First: one rule โ†’ glob present. await fse.writeFile(path.join(localConfig.repo.localPath, 'rules', 'r.md'), 'x'); await handler.pullAllRules(teamConfig, localConfig); - expect((await fse.readJson(ocConfig())).instructions).toContain('rules/*.md'); + expect((await fse.readJson(ocConfig())).instructions).toContain(`${ocRules()}/*.md`); // Then: remove the team rule and re-pull โ†’ glob gone. await fse.remove(path.join(localConfig.repo.localPath, 'rules', 'r.md')); await handler.pullAllRules(teamConfig, localConfig); const doc = await fse.readJson(ocConfig()); - expect(doc.instructions ?? []).not.toContain('rules/*.md'); + expect(doc.instructions ?? []).not.toContain(`${ocRules()}/*.md`); }); it('does not create opencode.json when OpenCode is not installed', async () => { @@ -1086,9 +1162,62 @@ describe('RulesHandler.pullAllRules โ€” OpenCode instructions activation', () => await handler.pullAllRules(teamConfig, localConfig); expect(await fse.pathExists(ocConfig())).toBe(false); }); + + describe('project scope (#946)', () => { + let projectRoot: string; + let projectConfig: LocalConfig; + const rootConfig = () => path.join(projectRoot, 'opencode.json'); + const dotConfig = () => path.join(projectRoot, '.opencode', 'opencode.json'); + + beforeEach(async () => { + projectRoot = path.join(tmpDir, 'project'); + await fse.ensureDir(path.join(projectRoot, '.opencode')); + projectConfig = { ...localConfig, scope: 'project', projectRoot } as LocalConfig; + const teamRules = path.join(localConfig.repo.localPath, 'rules'); + await fse.writeFile(path.join(teamRules, 'root-rule.md'), 'root'); + await fse.ensureDir(path.join(teamRules, 'fe')); + await fse.writeFile(path.join(teamRules, 'fe', 'style.md'), 'fe style'); + }); + + // A relative entry resolves from the session cwd up to the worktree, so + // one recursive glob from the project root loads the namespaced rules too. + it('registers .opencode/rules/**/*.md in .opencode/opencode.json, not in the root opencode.json', async () => { + await handler.pullAllRules(teamConfig, projectConfig); + + expect(await fse.pathExists(path.join(projectRoot, '.opencode', 'rules', 'fe', 'style.md'))).toBe(true); + expect(await fse.readJson(dotConfig())).toEqual({ instructions: ['.opencode/rules/**/*.md'] }); + expect(await fse.pathExists(rootConfig())).toBe(false); + }); + + it('reclaims the glob an earlier release wrote to the root opencode.json, leaving its other keys', async () => { + await fse.writeJson(rootConfig(), { mcp: { x: { type: 'local' } }, instructions: ['docs/style.md', '.opencode/rules/*.md'] }); + + await handler.pullAllRules(teamConfig, projectConfig); + + expect(await fse.readJson(rootConfig())).toEqual({ mcp: { x: { type: 'local' } }, instructions: ['docs/style.md'] }); + expect((await fse.readJson(dotConfig())).instructions).toEqual(['.opencode/rules/**/*.md']); + }); + + it('keeps the root glob while .opencode/opencode.json cannot be parsed, so the rules stay registered', async () => { + await fse.writeJson(rootConfig(), { instructions: ['.opencode/rules/*.md'] }); + await fse.writeFile(dotConfig(), '{ // a comment\n}\n'); + + await handler.pullAllRules(teamConfig, projectConfig); + + expect(await fse.readFile(dotConfig(), 'utf8')).toBe('{ // a comment\n}\n'); + expect(await fse.readJson(rootConfig())).toEqual({ instructions: ['.opencode/rules/*.md'] }); + }); + + it('removes the glob from .opencode/opencode.json when the team has no rules left', async () => { + await handler.pullAllRules(teamConfig, projectConfig); + await handler.pullAllRules(teamConfig, projectConfig, []); + + expect((await fse.readJson(dotConfig())).instructions).toBeUndefined(); + }); + }); }); -describe('RulesHandler โ€” Cursor-compatible .mdc handling', () => { +describe('RulesHandler โ€” .mdc handling (Cursor, JoyCode)', () => { let tmpDir: string; let homeDir: string; let repoPath: string; @@ -1119,7 +1248,8 @@ describe('RulesHandler โ€” Cursor-compatible .mdc handling', () => { toolPaths: { claude: { skills: '.claude/skills', rules: '.claude/rules', settings: '.claude/settings.json', claudemd: '.claude/CLAUDE.md' }, cursor: { skills: '.cursor/skills', rules: '.cursor/rules', settings: '.cursor/hooks.json' }, - joycode: { skills: '.joycode/skills', rules: '.joycode/rules' }, + // The default shape: in user scope JoyCode reads no rules directory (#946). + joycode: { skills: '.joycode/skills', rules: '.joycode/rules', userScope: { rules: null } }, }, }; @@ -1137,7 +1267,7 @@ describe('RulesHandler โ€” Cursor-compatible .mdc handling', () => { await fse.remove(tmpDir); }); - it('pull writes .mdc (not .md) with derived frontmatter for Cursor and JoyCode', async () => { + it('pull writes .mdc (not .md) with derived frontmatter for Cursor, and JoyCode\'s user rules to rules.txt', async () => { await fse.writeFile( path.join(repoPath, 'rules', 'ts-style.md'), '---\npaths:\n - "**/*.ts"\n---\n\nUse named exports.', @@ -1151,14 +1281,33 @@ describe('RulesHandler โ€” Cursor-compatible .mdc handling', () => { const content = await fse.readFile(mdcPath, 'utf-8'); expect(content).toContain('globs: "**/*.ts"'); expect(content).toContain('alwaysApply: false'); - const joycodeMdcPath = path.join(homeDir, '.joycode/rules/ts-style.mdc'); - expect(await fse.pathExists(joycodeMdcPath)).toBe(true); + // JoyCode reads its user rules from one text file, not ~/.joycode/rules (#946). + expect(await fse.pathExists(path.join(homeDir, '.joycode/rules/ts-style.mdc'))).toBe(false); expect(await fse.pathExists(path.join(homeDir, '.joycode/rules/ts-style.md'))).toBe(false); - expect(await fse.readFile(joycodeMdcPath, 'utf-8')).toBe(content); + expect(await fse.readFile(path.join(homeDir, '.joycode/rules.txt'), 'utf-8')) + .toContain('Applies to files matching: **/*.ts\nUse named exports.'); // claude still gets a plain .md copy expect(await fse.pathExists(path.join(homeDir, '.claude/rules/ts-style.md'))).toBe(true); }); + // JoyCode keeps the quotes Cursor's render puts around globs and splits on + // every comma, so it gets its own render (#946). + it('pull writes JoyCode\'s own .mdc render to a project\'s .joycode/rules', async () => { + localConfig.scope = 'project'; + localConfig.projectRoot = homeDir; + await fse.writeFile( + path.join(repoPath, 'rules', 'ts-style.md'), + '---\npaths:\n - "**/*.{ts,tsx}"\n---\n\nUse named exports.', + ); + + await handler.pullAllRules(teamConfig, localConfig); + + const joycodeMdcPath = path.join(homeDir, '.joycode/rules/ts-style.mdc'); + expect(await fse.pathExists(path.join(homeDir, '.joycode/rules/ts-style.md'))).toBe(false); + expect(await fse.readFile(joycodeMdcPath, 'utf-8')) + .toBe('---\nglobs: **/*.ts, **/*.tsx\nalwaysApply: false\n---\n\nUse named exports.\n'); + }); + it('a clean pull does not make cursor rules look modified on push', async () => { await fse.writeFile(path.join(repoPath, 'rules', 'enforced.md'), 'A mandatory rule.'); await handler.pullAllRules(teamConfig, localConfig); @@ -1183,8 +1332,10 @@ describe('RulesHandler โ€” Cursor-compatible .mdc handling', () => { expect(await fse.readFile(path.join(homeDir, '.joycode/rules', file), 'utf-8')) .toBe(`Personal content: ${file}`); } - expect(await fse.readFile(path.join(homeDir, '.joycode/rules/team.mdc'), 'utf-8')) - .toContain('Team rule.'); + // In user scope the team rule reaches JoyCode through rules.txt (#946). + const delivered = scope === 'user' ? '.joycode/rules.txt' : '.joycode/rules/team.mdc'; + expect(await fse.readFile(path.join(homeDir, delivered), 'utf-8')).toContain('Team rule.'); + if (scope === 'user') expect(await fse.pathExists(path.join(homeDir, '.joycode/rules/team.mdc'))).toBe(false); }); it.each(['user', 'project'] as const)('cleans only explicitly removed JoyCode rules in %s scope', async (scope) => { @@ -1192,19 +1343,28 @@ describe('RulesHandler โ€” Cursor-compatible .mdc handling', () => { if (scope === 'project') localConfig.projectRoot = homeDir; await fse.writeFile(path.join(repoPath, 'rules', 'keep.md'), 'Current team rule.'); await fse.writeFile(path.join(repoPath, 'rules', '.removed'), 'nested/removed\n'); - for (const ext of ['.mdc', '.md']) { - await fse.outputFile(path.join(homeDir, '.joycode/rules/nested', `removed${ext}`), 'Former team rule.'); + // Older layouts left `.md` copies in the project's `.mdc` directory; the + // user-scope ~/.joycode/rules only ever held teamai's `.mdc` copies. + const extensions = scope === 'user' ? ['.mdc'] : ['.mdc', '.md']; + const previous: DeliveredHashes = {}; + for (const ext of extensions) { + const removed = path.join(homeDir, '.joycode/rules/nested', `removed${ext}`); + await fse.outputFile(removed, 'Former team rule.'); + await recordDelivered(previous, removed); } const personalPath = path.join(homeDir, '.joycode/rules/nested/personal.mdc'); await fse.outputFile(personalPath, 'Personal rule.'); - await handler.pullAllRules(teamConfig, localConfig); + // In user scope ~/.joycode/rules is a directory JoyCode no longer gets + // rules in: a removed rule's copy goes on the record of its delivery (#946). + await handler.pullAllRules(teamConfig, localConfig, undefined, [], openLedger(previous)); - for (const ext of ['.mdc', '.md']) { + for (const ext of extensions) { expect(await fse.pathExists(path.join(homeDir, '.joycode/rules/nested', `removed${ext}`))).toBe(false); } expect(await fse.readFile(personalPath, 'utf-8')).toBe('Personal rule.'); - expect(await fse.pathExists(path.join(homeDir, '.joycode/rules/keep.mdc'))).toBe(true); + const kept = scope === 'user' ? '.joycode/rules.txt' : '.joycode/rules/keep.mdc'; + expect(await fse.pathExists(path.join(homeDir, kept))).toBe(true); }); it('detects a genuine edit to a cursor .mdc body as modified', async () => { @@ -1385,6 +1545,153 @@ describe('RulesHandler โ€” Cursor-compatible .mdc handling', () => { }); }); +describe('RulesHandler โ€” Kiro and Qoder rule formats (#946)', () => { + let tmpDir: string; + let homeDir: string; + let repoPath: string; + let handler: RulesHandler; + let teamConfig: TeamaiConfig; + let localConfig: LocalConfig; + + const SCOPED = '---\npaths:\n - "src/{a,b}/**"\n---\n\nUse named exports.\n'; + const KIRO = '---\ninclusion: fileMatch\nfileMatchPattern: ["src/{a,b}/**"]\n---\n\nUse named exports.\n'; + const QODER = '---\ntrigger: glob\nglob: src/a/**, src/b/**\n---\n\nUse named exports.\n'; + + beforeEach(async () => { + tmpDir = await fse.mkdtemp(path.join(os.tmpdir(), 'teamai-rules-formats-')); + homeDir = path.join(tmpDir, 'home'); + repoPath = path.join(tmpDir, 'team-repo'); + await fse.ensureDir(path.join(repoPath, 'rules')); + await fse.writeFile(path.join(repoPath, 'rules', 'scoped.md'), SCOPED); + for (const dir of ['.claude/rules', '.kiro/steering', '.qoder/rules', '.qoder-cn/rules']) { + await fse.ensureDir(path.join(homeDir, dir)); + } + vi.stubEnv('HOME', homeDir); + handler = new RulesHandler(); + teamConfig = { + team: 'test', + description: '', + repo: 'https://git.woa.com/test/repo.git', + provider: 'tgit' as const, + reviewers: [], + sharing: { skills: {}, rules: { enforced: [] }, docs: { localDir: '' }, env: { injectShellProfile: true } }, + toolPaths: { + claude: { rules: '.claude/rules' }, + kiro: { rules: '.kiro/steering' }, + qoder: { rules: '.qoder/rules' }, + 'qoder-cn': { rules: '.qoder/rules', userScope: { rules: '.qoder-cn/rules' } }, + }, + }; + localConfig = { + repo: { localPath: repoPath, remote: 'https://git.woa.com/test/repo.git' }, + username: 'testuser', + updatePolicy: 'auto', + additionalRoles: [], + scope: 'user', + }; + }); + + afterEach(async () => { + vi.unstubAllEnvs(); + await fse.remove(tmpDir); + }); + + it('writes each tool its own render in user scope', async () => { + await handler.pullAllRules(teamConfig, localConfig); + + expect(await fse.readFile(path.join(homeDir, '.kiro/steering/scoped.md'), 'utf-8')).toBe(KIRO); + expect(await fse.readFile(path.join(homeDir, '.qoder/rules/scoped.md'), 'utf-8')).toBe(QODER); + expect(await fse.readFile(path.join(homeDir, '.qoder-cn/rules/scoped.md'), 'utf-8')).toBe(QODER); + expect(await fse.readFile(path.join(homeDir, '.claude/rules/scoped.md'), 'utf-8')).toBe(SCOPED); + }); + + it('writes the same renders in project scope', async () => { + const projectRoot = path.join(tmpDir, 'project'); + for (const dir of ['.kiro/steering', '.qoder/rules']) await fse.ensureDir(path.join(projectRoot, dir)); + localConfig.scope = 'project'; + localConfig.projectRoot = projectRoot; + + await handler.pullAllRules(teamConfig, localConfig); + + expect(await fse.readFile(path.join(projectRoot, '.kiro/steering/scoped.md'), 'utf-8')).toBe(KIRO); + expect(await fse.readFile(path.join(projectRoot, '.qoder/rules/scoped.md'), 'utf-8')).toBe(QODER); + }); + + it('a clean pull leaves nothing to push', async () => { + await handler.pullAllRules(teamConfig, localConfig); + + expect(await handler.scanLocalForPush(teamConfig, localConfig)).toEqual([]); + }); + + it.each([ + ['kiro', '.kiro/steering', KIRO], + ['qoder', '.qoder/rules', QODER], + ])('pushes an edited %s body into the team rule without the tool frontmatter', async (_tool, dir, render) => { + await handler.pullAllRules(teamConfig, localConfig); + const copy = path.join(homeDir, dir, 'scoped.md'); + await fse.writeFile(copy, render.replace('Use named exports.', 'Use default exports.')); + + const items = await handler.scanLocalForPush(teamConfig, localConfig); + expect(items).toMatchObject([{ name: 'scoped', status: 'modified', sourcePath: copy }]); + await handler.pushItem(items[0], teamConfig, localConfig); + + expect(await fse.readFile(path.join(repoPath, 'rules', 'scoped.md'), 'utf-8')) + .toBe('---\npaths:\n - "src/{a,b}/**"\n---\n\nUse default exports.\n'); + }); + + it("does not offer a member's own steering file as a new team rule", async () => { + await fse.writeFile(path.join(homeDir, '.kiro/steering/product.md'), '---\ninclusion: always\n---\n\nOur product.\n'); + await fse.writeFile(path.join(homeDir, '.qoder/rules/mine.md'), '---\ntrigger: always_on\n---\n\nMine.\n'); + + const items = await handler.scanLocalForPush(teamConfig, localConfig); + + expect(items.map((item) => item.name)).toEqual([]); + }); + + it("keeps a member's own steering and Qoder rule files on pull, and still reclaims a team copy on record", async () => { + const product = path.join(homeDir, '.kiro/steering/product.md'); + const mine = path.join(homeDir, '.qoder/rules/mine.md'); + await fse.writeFile(product, '---\ninclusion: always\n---\n\nOur product.\n'); + await fse.writeFile(mine, '---\ntrigger: always_on\n---\n\nMine.\n'); + // A team rule this checkout received before, no longer delivered here. + const formerKiro = path.join(homeDir, '.kiro/steering/former.md'); + const formerQoder = path.join(homeDir, '.qoder/rules/former.md'); + await fse.writeFile(formerKiro, '---\ninclusion: always\n---\n\nFormer.\n'); + await fse.writeFile(formerQoder, '---\ntrigger: always_on\n---\n\nFormer.\n'); + const previous: DeliveredHashes = {}; + await recordDelivered(previous, formerKiro); + await recordDelivered(previous, formerQoder); + const ledger = openLedger(previous); + + await handler.pullAllRules(teamConfig, localConfig, undefined, [], ledger); + + expect(await fse.readFile(product, 'utf-8')).toBe('---\ninclusion: always\n---\n\nOur product.\n'); + expect(await fse.readFile(mine, 'utf-8')).toBe('---\ntrigger: always_on\n---\n\nMine.\n'); + expect(await fse.pathExists(formerKiro)).toBe(false); + expect(await fse.pathExists(formerQoder)).toBe(false); + expect(Object.keys(ledger.hashes).filter((file) => file.endsWith('former.md'))).toEqual([]); + }); + + it('re-renders a verbatim copy an older teamai delivered, and keeps one the member edited (#822)', async () => { + const kiroCopy = path.join(homeDir, '.kiro/steering/scoped.md'); + const qoderCopy = path.join(homeDir, '.qoder/rules/scoped.md'); + await fse.writeFile(kiroCopy, SCOPED); + await fse.writeFile(qoderCopy, SCOPED); + const previous: DeliveredHashes = {}; + await recordDelivered(previous, kiroCopy); + await recordDelivered(previous, qoderCopy); + const edited = SCOPED.replace('Use named exports.', 'My own wording.'); + await fse.writeFile(qoderCopy, edited); + const ledger = openLedger(previous); + + await handler.pullAllRules(teamConfig, localConfig, undefined, [], ledger); + + expect(await fse.readFile(kiroCopy, 'utf-8')).toBe(KIRO); + expect(await fse.readFile(qoderCopy, 'utf-8')).toBe(edited); + expect(ledger.kept.map((kept) => kept.dest)).toEqual([qoderCopy]); + }); +}); + describe('inlinedRulesText โ€” rules inlined into one instructions file (#938)', () => { let tmpDir: string; @@ -1418,6 +1725,21 @@ describe('inlinedRulesText โ€” rules inlined into one instructions file (#938)', ); }); + it('keeps the path hint of a rule scoped with an unquoted glob (#946)', async () => { + const rules = [await rule('ts', '---\npaths: **/*.ts\n---\n\nUse strict types.\n')]; + + expect(await inlinedRulesText(rules)).toBe('Applies to files matching: **/*.ts\nUse strict types.'); + }); + + it.each([ + 'paths: **/*.ts # TypeScript files', + 'paths:\n - **/*.ts # TypeScript files', + ])('keeps comments out of the inline path hint: %s', async (frontmatter) => { + const rules = [await rule('ts', `---\n${frontmatter}\n---\n\nUse strict types.\n`)]; + + expect(await inlinedRulesText(rules)).toBe('Applies to files matching: **/*.ts\nUse strict types.'); + }); + it('skips a rule whose body is empty once its frontmatter is gone', async () => { const rules = [ await rule('only-frontmatter', '---\npaths:\n - "src/**"\n---\n\n'), @@ -1470,4 +1792,31 @@ describe('RulesHandler.pullAllRules โ€” Hermes SOUL.md (#938)', () => { expect(soul).toContain('Applies to files matching: src/**/*.ts\nUse strict types.'); expect(soul).not.toContain('paths:'); }); + + // Hermes has no project rules channel: SOUL.md is global, so only a + // user-scope pull may write its block (#946). + it('leaves the SOUL.md block as the user-scope pull wrote it after a project pull, with or without project rules', async () => { + // Model the user installation that owns the global rules block. + await fse.outputFile(path.join(tmpDir, 'home', '.teamai', 'config.yaml'), JSON.stringify(localConfig)); + await fse.writeFile(path.join(localConfig.repo.localPath, 'rules', 'user-rule.md'), 'USER RULE\n'); + await new RulesHandler().pullAllRules(teamConfig, localConfig); + const soulPath = path.join(hermesHome, 'SOUL.md'); + const afterUserPull = await fse.readFile(soulPath, 'utf-8'); + expect(afterUserPull).toContain('USER RULE'); + + const projectRoot = path.join(tmpDir, 'project'); + const projectRepo = path.join(tmpDir, 'project-team-repo'); + await fse.ensureDir(path.join(projectRepo, 'rules')); + await fse.writeFile(path.join(projectRepo, 'rules', 'project-rule.md'), 'PROJECT RULE\n'); + const projectConfig = { + ...localConfig, scope: 'project', projectRoot, repo: { localPath: projectRepo, remote: 'r' }, + } as unknown as LocalConfig; + + await new RulesHandler().pullAllRules(teamConfig, projectConfig); + expect(await fse.readFile(soulPath, 'utf-8')).toBe(afterUserPull); + + await fse.remove(path.join(projectRepo, 'rules', 'project-rule.md')); + await new RulesHandler().pullAllRules(teamConfig, projectConfig); + expect(await fse.readFile(soulPath, 'utf-8')).toBe(afterUserPull); + }); }); diff --git a/src/__tests__/search-index.test.ts b/src/__tests__/search-index.test.ts index a5b503ce0..e9fffc79b 100644 --- a/src/__tests__/search-index.test.ts +++ b/src/__tests__/search-index.test.ts @@ -1,8 +1,9 @@ -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import fs from 'node:fs'; import path from 'node:path'; import os from 'node:os'; -import { tokenize, wordSegments, buildIndex, loadIndex, search } from '../utils/search-index.js'; +import { tokenize, wordSegments, buildIndex, loadIndex, search, parseLearningDoc } from '../utils/search-index.js'; +import { log } from '../utils/logger.js'; import { computeIdfBaseline, isRelevantScore } from '../recall.js'; import type { SearchIndex, SearchIndexEntry } from '../types.js'; @@ -46,6 +47,21 @@ const DOC_EMPTY = ''; // โ”€โ”€โ”€ tokenize โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +describe('parseLearningDoc', () => { + it('reads a rule scoped with an unquoted glob without an error, and leaves its frontmatter out of the body (#946)', () => { + const error = vi.spyOn(log, 'error'); + try { + const doc = parseLearningDoc('---\ntitle: TS rule\npaths: **/*.ts\n---\n\nUse strict types.\n', 'ts.md'); + + expect(doc?.meta.title).toBe('TS rule'); + expect(doc?.bodyExcerpt).toBe('Use strict types.'); + expect(error).not.toHaveBeenCalled(); + } finally { + error.mockRestore(); + } + }); +}); + describe('tokenize', () => { it('T1: tokenizes mixed Chinese and English text with CJK bigrams', () => { const tokens = tokenize('่งฃๅ†ณSGLang API่ถ…ๆ—ถ้—ฎ้ข˜'); diff --git a/src/__tests__/types.test.ts b/src/__tests__/types.test.ts index 52fb04230..5404d57c8 100644 --- a/src/__tests__/types.test.ts +++ b/src/__tests__/types.test.ts @@ -128,7 +128,6 @@ describe('TeamaiConfigSchema', () => { expect(result.toolPaths).toHaveProperty('openclaw'); expect(result.toolPaths.openclaw).toEqual({ skills: '.openclaw/skills', - rules: '.openclaw/rules', claudemd: '.openclaw/workspace/AGENTS.md', }); }); diff --git a/src/__tests__/uninstall.test.ts b/src/__tests__/uninstall.test.ts index 81d6af348..a3206fb5e 100644 --- a/src/__tests__/uninstall.test.ts +++ b/src/__tests__/uninstall.test.ts @@ -1198,6 +1198,63 @@ describe('uninstall', () => { expect(await fse.pathExists(path.join(cursorRules, 'my-own-rule.mdc'))).toBe(true); }); + it.each([ + ['omp', true], + ['claude', false], + ])('names an edited flat OMP copy as kept only when uninstalling %s (#946)', async (agent, named) => { + const { homeDir, repoPath } = await setupFixture(tmpDir); + vi.stubEnv('HOME', homeDir); + vi.stubEnv('SHELL', '/bin/zsh'); + await fse.outputFile(path.join(repoPath, 'rules', 'fe', 'style.md'), 'Frontend rule.\n'); + const flat = path.join(homeDir, '.omp', 'agent', 'rules', 'fe.style.md'); + await fse.outputFile(flat, '---\nalwaysApply: true\n---\n\nFrontend rule.\n'); + await fse.ensureDir(path.join(homeDir, '.claude', 'rules')); + const localConfig = makeLocalConfig(homeDir, repoPath, { enabledAgents: ['omp', 'claude'] }); + const { checkoutKey } = await import('../pull.js'); + const delivered: Record = {}; + const { recordDelivered } = await import('../resources/delivered-copies.js'); + await recordDelivered(delivered, flat); + await saveStateForScope({ + ...await loadStateForScope(localConfig), + lastPullByWorkspace: { [await checkoutKey(homeDir)]: { rev: 'r1', targets: [], delivered } }, + }, localConfig); + await fse.writeFile(flat, '---\nalwaysApply: true\n---\n\nMy own wording.\n'); + const defaults = TeamaiConfigSchema.parse({ team: 't', repo: 'owner/repo' }).toolPaths; + mockAutoDetectInit.mockResolvedValue({ localConfig, teamConfig: makeTeamConfig({ toolPaths: { omp: defaults.omp, claude: defaults.claude } }) }); + const warn = vi.mocked(log.warn); + warn.mockClear(); + + await uninstall({ force: true, agent }); + + expect(await fse.readFile(flat, 'utf8')).toContain('My own wording.'); + expect(warn.mock.calls.some(([message]) => String(message).startsWith(`Kept ${flat}`))).toBe(named); + }); + + it("removes the flat OMP copy of a namespaced team rule on uninstall, not a member's file of that name (#946)", async () => { + const { homeDir, repoPath } = await setupFixture(tmpDir); + vi.stubEnv('HOME', homeDir); + vi.stubEnv('SHELL', '/bin/zsh'); + await fse.outputFile(path.join(repoPath, 'rules', 'fe', 'style.md'), 'Frontend rule.\n'); + const ompRules = path.join(homeDir, '.omp', 'agent', 'rules'); + await fse.outputFile(path.join(ompRules, 'fe.style.md'), '---\nalwaysApply: true\n---\n\nFrontend rule.\n'); + await fse.outputFile(path.join(ompRules, 'my-own.rule.md'), 'Mine.\n'); + // The flat name of a team rule, but the member's own file: no record, not the render. + await fse.outputFile(path.join(repoPath, 'rules', 'be', 'api.md'), 'Backend rule.\n'); + await fse.outputFile(path.join(ompRules, 'be.api.md'), 'My own backend notes.\n'); + + const defaults = TeamaiConfigSchema.parse({ team: 't', repo: 'owner/repo' }).toolPaths; + mockAutoDetectInit.mockResolvedValue({ + localConfig: makeLocalConfig(homeDir, repoPath, { enabledAgents: ['omp'] }), + teamConfig: makeTeamConfig({ toolPaths: { omp: defaults.omp } }), + }); + + await uninstall({ force: true }); + + expect(await fse.pathExists(path.join(ompRules, 'fe.style.md'))).toBe(false); + expect(await fse.readFile(path.join(ompRules, 'my-own.rule.md'), 'utf8')).toBe('Mine.\n'); + expect(await fse.readFile(path.join(ompRules, 'be.api.md'), 'utf8')).toBe('My own backend notes.\n'); + }); + // Regression: MCP cleanup used to run after ~/.teamai/ was deleted, so the // ownership manifest was already gone and removeAll became a no-op. it('ๅธ่ฝฝๆ—ถ็งป้™ค teamai ็ฎก็†็š„ MCP server๏ผŒๅนถไฟ็•™็”จๆˆท่‡ชๅปบ็š„', async () => { @@ -1347,6 +1404,72 @@ describe('uninstall', () => { expect(log.warn).toHaveBeenCalledWith(expect.stringContaining(`Kept \`/.mcp.json\` in ${await fse.realpath(excludeFile)}`)); }); + describe('CodeBuddy and WorkBuddy share a project\'s .codebuddy/rules (#946)', () => { + async function sharedFixture(agent: 'codebuddy' | 'workbuddy' | undefined, others: { disabled?: boolean } = {}) { + const { homeDir, repoPath } = await setupFixture(tmpDir); + const projectRoot = path.join(tmpDir, 'business-repo'); + vi.stubEnv('HOME', homeDir); + vi.stubEnv('SHELL', '/bin/zsh'); + const defaults = TeamaiConfigSchema.parse({ team: 't', repo: 'https://example.invalid/t.git' }).toolPaths; + const teamConfig = makeTeamConfig({ toolPaths: { codebuddy: defaults.codebuddy, workbuddy: defaults.workbuddy } }); + const other = agent === 'codebuddy' ? 'workbuddy' : 'codebuddy'; + const localConfig = makeLocalConfig(homeDir, repoPath, { + scope: 'project', + projectRoot, + ...(others.disabled ? { disabledAgents: [other] } : {}), + repo: { localPath: repoPath, remote: '', kind: 'self', businessRepoRoot: projectRoot }, + }); + mockAutoDetectInit.mockResolvedValue({ localConfig, teamConfig }); + await fse.ensureDir(path.join(projectRoot, '.workbuddy')); + const copy = path.join(projectRoot, '.codebuddy', 'rules', 'team-rule.md'); + await fse.outputFile(copy, '---\nalwaysApply: true\n---\n\n# Team Rule\n'); + return copy; + } + + it.each(['codebuddy', 'workbuddy'] as const)('uninstall --agent %s keeps the copy the other still reads', async (agent) => { + const copy = await sharedFixture(agent); + + await uninstall({ force: true, agent }); + + expect(await fse.pathExists(copy)).toBe(true); + }); + + it.each(['codebuddy', 'workbuddy'] as const)('uninstall --agent %s removes the copy once the other is excluded', async (agent) => { + const copy = await sharedFixture(agent, { disabled: true }); + + await uninstall({ force: true, agent }); + + expect(await fse.pathExists(copy)).toBe(false); + }); + + it('removes an unedited copy an older pull left in .workbuddy/rules, and names an edited one with why', async () => { + const copy = await sharedFixture('workbuddy'); + const legacy = (file: string) => path.join(path.dirname(path.dirname(path.dirname(copy))), '.workbuddy', 'rules', file); + await fse.outputFile(legacy('team-rule.md'), '# Team Rule'); + await fse.outputFile(path.join(tmpDir, 'team-repo', 'rules', 'other.md'), 'Other rule.\n'); + await fse.outputFile(legacy('other.md'), 'Other rule.\nMy own note.\n'); + + await uninstall({ force: true, agent: 'workbuddy' }); + + expect(await fse.pathExists(legacy('team-rule.md'))).toBe(false); + expect(await fse.readFile(legacy('other.md'), 'utf8')).toBe('Other rule.\nMy own note.\n'); + const kept = vi.mocked(log.warn).mock.calls.map(([message]) => String(message)) + .filter((message) => message.includes(legacy('other.md'))); + expect(kept).toHaveLength(1); + expect(kept[0]).toContain('WorkBuddy reads a project\'s rules from .codebuddy/rules'); + expect(kept[0]).not.toContain('Codex'); + }); + + it('a full uninstall removes the copy, counted once', async () => { + const copy = await sharedFixture(undefined); + + await uninstall({ force: true }); + + expect(await fse.pathExists(copy)).toBe(false); + expect(log.success).toHaveBeenCalledWith('Removed 1 rule files'); + }); + }); + describe('the block protects a config holding a resolved value (#882)', () => { const block = [ '# [teamai:mcp-exclude:start] project MCP configs holding resolved ${VAR} values', @@ -1757,6 +1880,11 @@ describe('uninstall', () => { await fse.ensureDir(ocHookDir); await fse.writeFile(path.join(ocHookDir, 'HOOK.md'), '---\nname: [teamai] status-report\n---\n'); await fse.writeFile(path.join(ocHookDir, 'handler.ts'), '// teamai'); + // The entry teamai added to enable the hook, beside one of the user's. + const ocConfig = path.join(homeDir, '.openclaw', 'openclaw.json'); + await fse.writeJson(ocConfig, { + hooks: { internal: { entries: { 'teamai-status-report': { enabled: true }, mine: { enabled: true } } } }, + }); const teamConfig = makeTeamConfig({ toolPaths: { @@ -1777,6 +1905,7 @@ describe('uninstall', () => { // The OpenClaw HOOK.md dir must be removed (regression: previously leaked). expect(await fse.pathExists(ocHookDir)).toBe(false); + expect(await fse.readJson(ocConfig)).toEqual({ hooks: { internal: { entries: { mine: { enabled: true } } } } }); }); it('project scope ๅธ่ฝฝๅŒๆ—ถๆธ…ๆމ็”จๆˆท็บงๅ’Œ้กน็›ฎ็บง็š„ OpenCode plugin', async () => { @@ -1937,6 +2066,145 @@ describe('uninstall', () => { if (scenario === 'other tool') expect((await loadStateForScope(localConfig)).opencodeContextEntries).toEqual([]); }); + it('removes the OpenCode rules globs teamai owns from the user opencode.json, keeping the member\'s entries (#946)', async () => { + const homeDir = path.join(tmpDir, 'oc-home'); + const repoPath = path.join(tmpDir, 'oc-team-repo'); + await fse.ensureDir(path.join(repoPath, 'rules', 'fe')); + await fse.writeFile(path.join(repoPath, 'rules', 'team-rule.md'), '# Team Rule'); + await fse.writeFile(path.join(repoPath, 'rules', 'fe', 'style.md'), '# FE'); + const rulesDir = path.join(homeDir, '.config', 'opencode', 'rules'); + await fse.ensureDir(path.join(rulesDir, 'fe')); + await fse.writeFile(path.join(rulesDir, 'team-rule.md'), '# Team Rule'); + await fse.writeFile(path.join(rulesDir, 'fe', 'style.md'), '# FE'); + const configFile = path.join(homeDir, '.config', 'opencode', 'opencode.json'); + // `mine/` is no team namespace: its glob is the member's own entry. + await fse.writeJson(configFile, { + model: 'mine', + instructions: ['CONVENTIONS.md', `${rulesDir}/*.md`, `${rulesDir}/fe/*.md`, `${rulesDir}/mine/*.md`, 'rules/*.md'], + }); + + vi.stubEnv('HOME', homeDir); + vi.stubEnv('SHELL', '/bin/zsh'); + const teamConfig = makeTeamConfig({ + toolPaths: { + opencode: { + skills: '.opencode/skills', rules: '.opencode/rules', + mcp: '.config/opencode/opencode.json', mcpProject: 'opencode.json', + userScope: { skills: '.config/opencode/skills', rules: '.config/opencode/rules' }, + }, + }, + }); + mockAutoDetectInit.mockResolvedValue({ localConfig: makeLocalConfig(homeDir, repoPath), teamConfig }); + + await uninstall({ force: true }); + + expect(await fse.readJson(configFile)).toEqual({ model: 'mine', instructions: ['CONVENTIONS.md', `${rulesDir}/mine/*.md`] }); + }); + + it('removes the project rules glob from .opencode/opencode.json and the old one from the root opencode.json (#946)', async () => { + const homeDir = path.join(tmpDir, 'oc-home'); + const repoPath = path.join(tmpDir, 'oc-team-repo'); + const projectRoot = path.join(tmpDir, 'oc-project'); + await fse.ensureDir(path.join(repoPath, 'rules')); + await fse.writeFile(path.join(repoPath, 'rules', 'team-rule.md'), '# Team Rule'); + await fse.ensureDir(path.join(projectRoot, '.opencode', 'rules')); + await fse.writeFile(path.join(projectRoot, '.opencode', 'rules', 'team-rule.md'), '# Team Rule'); + const dotConfig = path.join(projectRoot, '.opencode', 'opencode.json'); + const rootConfig = path.join(projectRoot, 'opencode.json'); + await fse.writeJson(dotConfig, { instructions: ['docs/style.md', '.opencode/rules/**/*.md'] }); + await fse.writeJson(rootConfig, { theme: 'dark', instructions: ['.opencode/rules/*.md'] }); + + vi.stubEnv('HOME', homeDir); + vi.stubEnv('SHELL', '/bin/zsh'); + const teamConfig = makeTeamConfig({ + toolPaths: { + opencode: { + skills: '.opencode/skills', rules: '.opencode/rules', + mcp: '.config/opencode/opencode.json', mcpProject: 'opencode.json', + userScope: { skills: '.config/opencode/skills', rules: '.config/opencode/rules' }, + }, + }, + }); + const localConfig = makeLocalConfig(homeDir, repoPath, { + scope: 'project', + projectRoot, + repo: { localPath: repoPath, remote: '', kind: 'self', businessRepoRoot: projectRoot }, + }); + mockAutoDetectInit.mockResolvedValue({ localConfig, teamConfig }); + + await uninstall({ force: true, agent: 'opencode' }); + + expect(await fse.readJson(dotConfig)).toEqual({ instructions: ['docs/style.md'] }); + expect(await fse.readJson(rootConfig)).toEqual({ theme: 'dark' }); + }); + + it('deletes the .opencode/opencode.json the rules glob alone filled, and keeps the root opencode.json (#946)', async () => { + const homeDir = path.join(tmpDir, 'oc-home'); + const repoPath = path.join(tmpDir, 'oc-team-repo'); + const projectRoot = path.join(tmpDir, 'oc-project'); + await fse.ensureDir(path.join(repoPath, 'rules')); + await fse.writeFile(path.join(repoPath, 'rules', 'team-rule.md'), '# Team Rule'); + await fse.ensureDir(path.join(projectRoot, '.opencode', 'rules')); + await fse.writeFile(path.join(projectRoot, '.opencode', 'rules', 'team-rule.md'), '# Team Rule'); + const dotConfig = path.join(projectRoot, '.opencode', 'opencode.json'); + const rootConfig = path.join(projectRoot, 'opencode.json'); + // OpenCode adds `$schema` to a config it loads. + await fse.writeJson(dotConfig, { $schema: 'https://opencode.ai/config.json', instructions: ['.opencode/rules/**/*.md'] }); + await fse.writeJson(rootConfig, { instructions: ['.opencode/rules/*.md'] }); + + vi.stubEnv('HOME', homeDir); + vi.stubEnv('SHELL', '/bin/zsh'); + const teamConfig = makeTeamConfig({ + toolPaths: { + opencode: { + skills: '.opencode/skills', rules: '.opencode/rules', + mcp: '.config/opencode/opencode.json', mcpProject: 'opencode.json', + userScope: { skills: '.config/opencode/skills', rules: '.config/opencode/rules' }, + }, + }, + }); + const localConfig = makeLocalConfig(homeDir, repoPath, { + scope: 'project', + projectRoot, + repo: { localPath: repoPath, remote: '', kind: 'self', businessRepoRoot: projectRoot }, + }); + mockAutoDetectInit.mockResolvedValue({ localConfig, teamConfig }); + + await uninstall({ force: true, agent: 'opencode' }); + + expect(await fse.pathExists(dotConfig)).toBe(false); + expect(await fse.readJson(rootConfig)).toEqual({}); + }); + + it('keeps a project .opencode/opencode.json that held only $schema and the recorded team instructions entry (#945, #946)', async () => { + const homeDir = path.join(tmpDir, 'oc-home'); + const repoPath = path.join(tmpDir, 'oc-team-repo'); + const projectRoot = path.join(tmpDir, 'oc-project'); + await fse.ensureDir(repoPath); + const contextFile = path.join(projectRoot, '.opencode', 'teamai-context.md'); + await fse.outputFile(contextFile, '\nBe kind.\n\n'); + const dotConfig = path.join(projectRoot, '.opencode', 'opencode.json'); + // OpenCode adds `$schema` to a config it loads. + await fse.writeJson(dotConfig, { $schema: 'https://opencode.ai/config.json', instructions: ['.opencode/teamai-context.md'] }); + + vi.stubEnv('HOME', homeDir); + vi.stubEnv('SHELL', '/bin/zsh'); + const { toolPaths } = TeamaiConfigSchema.parse({ team: 'test', repo: 'https://example.invalid/x/team.git' }); + const localConfig = makeLocalConfig(homeDir, repoPath, { + scope: 'project', + projectRoot, + repo: { localPath: repoPath, remote: '', kind: 'self', businessRepoRoot: projectRoot }, + }); + mockAutoDetectInit.mockResolvedValue({ localConfig, teamConfig: makeTeamConfig({ toolPaths }) }); + await saveStateForScope({ ...await loadStateForScope(localConfig), opencodeContextEntries: [{ config: dotConfig, entry: '.opencode/teamai-context.md' }] }, localConfig); + + await uninstall({ force: true, agent: 'opencode' }); + + expect(await fse.pathExists(contextFile)).toBe(false); + // Only the entry pull recorded goes; the file is not proven teamai's (#945). + expect(await fse.readJson(dotConfig)).toEqual({ $schema: 'https://opencode.ai/config.json' }); + }); + // 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. @@ -2716,6 +2984,27 @@ describe('uninstall', () => { expect(await fse.pathExists(stub)).toBe(false); }); + it('a project-scope uninstall leaves the SOUL.md rules block, which only a user-scope pull writes (#946)', async () => { + const projectRoot = path.join(tmpDir, 'hermes-project'); + const homeDir = path.join(tmpDir, 'home'); + const repoPath = path.join(projectRoot, '.teamai', 'team-repo'); + await fse.ensureDir(path.join(repoPath, 'rules')); + vi.stubEnv('HOME', homeDir); + vi.stubEnv('SHELL', '/bin/zsh'); + const hermesHome = path.join(tmpDir, 'hermes'); + vi.stubEnv('HERMES_HOME', hermesHome); + const soul = path.join(hermesHome, 'SOUL.md'); + const userBlock = `my own soul\n\n${TEAMAI_RULES_START}\nUser rule\n${TEAMAI_RULES_END}\n`; + await fse.outputFile(soul, userBlock); + + const teamConfig = makeTeamConfig(); + teamConfig.toolPaths.hermes = { skills: '.hermes/skills' }; + mockAutoDetectInit.mockResolvedValue({ localConfig: makeLocalConfig(projectRoot, repoPath, { scope: 'project', projectRoot }), teamConfig }); + await uninstall({ force: true }); + + expect(await fse.readFile(soul, 'utf-8')).toBe(userBlock); + }); + it('removes the stub Codex kept in the shared .agents/skills root, and nothing else there', async () => { const { homeDir, repoPath } = await setupFixture(tmpDir); vi.stubEnv('HOME', homeDir); diff --git a/src/__tests__/user-rules-files.test.ts b/src/__tests__/user-rules-files.test.ts new file mode 100644 index 000000000..30b6e09c2 --- /dev/null +++ b/src/__tests__/user-rules-files.test.ts @@ -0,0 +1,667 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import path from 'node:path'; +import os from 'node:os'; +import fse from 'fs-extra'; + +vi.mock('../config.js', async (importOriginal) => ({ + ...(await importOriginal()), + requireInit: vi.fn(), + loadState: vi.fn().mockResolvedValue({ lastPull: null, lastPullRev: null }), + saveState: vi.fn(), + loadStateForScope: vi.fn(async () => ({})), + saveStateForScope: vi.fn(), + loadLocalConfigForScope: vi.fn(), + loadLocalConfig: vi.fn(), + loadTeamConfig: vi.fn(), + detectProjectConfig: vi.fn().mockResolvedValue(null), + autoDetectInit: vi.fn(), +})); + +vi.mock('../utils/git.js', async (importOriginal) => ({ + ...(await importOriginal()), + pullRepo: vi.fn().mockResolvedValue('already up to date'), + getHeadRev: vi.fn().mockResolvedValue('abc1234'), + createGit: vi.fn(), +})); + +// pull() takes a real ~/.teamai/.sync-lock; parallel workers would race on it. +vi.mock('../update.js', () => ({ + acquireLock: vi.fn().mockResolvedValue(true), + releaseLock: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock('../utils/logger.js', () => ({ + log: { + persist: vi.fn(), + info: vi.fn(), + success: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + dim: vi.fn(), + }, + setStderrOnly: vi.fn(), + spinner: vi.fn(() => ({ + start: vi.fn().mockReturnThis(), + succeed: vi.fn().mockReturnThis(), + fail: vi.fn().mockReturnThis(), + warn: vi.fn().mockReturnThis(), + info: vi.fn().mockReturnThis(), + stop: vi.fn().mockReturnThis(), + })), +})); + +import { RulesHandler, ruleChannelNotes } from '../resources/rules.js'; +import { teamRuleToCursorMdc } from '../resources/cursor-mdc.js'; +import { teamRuleToJoycodeRule } from '../resources/joycode-rule.js'; +import { pull } from '../pull.js'; +import { uninstall } from '../uninstall.js'; +import { buildChecks, resolveDoctorContext } from '../doctor.js'; +import { log } from '../utils/logger.js'; +import { + autoDetectInit, detectProjectConfig, loadLocalConfig, loadLocalConfigForScope, loadStateForScope, loadTeamConfig, saveStateForScope, +} from '../config.js'; +import { TeamaiConfigSchema } from '../types.js'; +import type { LocalConfig, TeamaiConfig } from '../types.js'; + +// What a user-scope pull writes for the two team rules below, markers included (#946). +const BLOCK = '\n' + + '\n\n' + + 'The team codeword is PELICAN-42.\n\n' + + 'Applies to files matching: src/**\nPrefer named exports.\n\n' + + ''; + +const CODEWORD = 'The team codeword is PELICAN-42.\n'; +const SCOPED = '---\npaths:\n - "src/**"\n---\nPrefer named exports.\n'; + +let tmpDir: string; +let homeDir: string; +let projectRoot: string; +let repoPath: string; + +/** Each tool with no rules format, the home dir that says it is installed, and the file only it reads. */ +const TOOLS: ReadonlyArray<{ tool: string; root: string; file: string }> = [ + { tool: 'zcode', root: '.zcode', file: '.zcode/AGENTS.md' }, + { tool: 'dsh', root: '.dsh', file: '.dsh/AGENTS.md' }, + { tool: 'openclaw', root: '.openclaw/workspace', file: '.openclaw/workspace/AGENTS.md' }, + { tool: 'pi', root: '.pi/agent', file: '.pi/agent/AGENTS.md' }, + { tool: 'joycode', root: '.joycode', file: '.joycode/rules.txt' }, +]; + +const teamConfig = (): TeamaiConfig => TeamaiConfigSchema.parse({ team: 'test', repo: 'https://example.invalid/x/team.git' }); + +function config(scope: 'user' | 'project', enabledAgents: string[]): LocalConfig { + return { + repo: { localPath: repoPath, remote: 'https://example.invalid/x/team.git' }, + username: 'u', + updatePolicy: 'auto', + additionalRoles: [], + scope, + ...(scope === 'project' ? { projectRoot } : {}), + enabledAgents, + } as LocalConfig; +} + +const home = (rel: string) => path.join(homeDir, rel); + +beforeEach(async () => { + tmpDir = await fse.mkdtemp(path.join(os.tmpdir(), 'teamai-user-rules-')); + homeDir = path.join(tmpDir, 'home'); + projectRoot = path.join(tmpDir, 'project'); + repoPath = path.join(tmpDir, 'team-repo'); + await fse.ensureDir(homeDir); + await fse.ensureDir(projectRoot); + await fse.ensureDir(path.join(repoPath, 'rules')); + await fse.writeFile(path.join(repoPath, 'rules', 'codeword.md'), CODEWORD); + await fse.writeFile(path.join(repoPath, 'rules', 'scoped.md'), SCOPED); + vi.stubEnv('HOME', homeDir); + for (const name of ['DSH_HOME', 'OPENCLAW_STATE_DIR', 'OPENCLAW_PROFILE', 'OPENCLAW_CONFIG_PATH', 'OPENCLAW_WORKSPACE_DIR']) { + vi.stubEnv(name, ''); + } + vi.mocked(log.warn).mockClear(); +}); + +afterEach(async () => { + vi.unstubAllEnvs(); + await fse.remove(tmpDir); +}); + +describe('a user-scope rules sync puts the team rules in a file only the tool reads (#946)', () => { + it.each(TOOLS)('writes the team-rules block for $tool to ~/$file, beside the member\'s text', async ({ tool, root, file }) => { + await fse.ensureDir(home(root)); + await fse.writeFile(home(file), '# My notes\n'); + + await new RulesHandler().pullAllRules(teamConfig(), config('user', [tool])); + + expect(await fse.readFile(home(file), 'utf8')).toBe(`# My notes\n\n${BLOCK}\n`); + }); + + it.each(TOOLS)('writes no rules directory for $tool in user scope', async ({ tool, root }) => { + await fse.ensureDir(home(root)); + + await new RulesHandler().pullAllRules(teamConfig(), config('user', [tool])); + + for (const dir of ['.openclaw/rules', '.pi/agent/rules', '.pi/rules', '.joycode/rules']) { + expect(await fse.pathExists(home(dir))).toBe(false); + } + }); + + it.each(TOOLS)('a project-scope sync leaves ~/$file unchanged for $tool', async ({ tool, root, file }) => { + await fse.ensureDir(home(root)); + await fse.writeFile(home(file), '# My notes\n'); + + await new RulesHandler().pullAllRules(teamConfig(), config('project', [tool])); + + expect(await fse.readFile(home(file), 'utf8')).toBe('# My notes\n'); + }); + + it.each(TOOLS)('creates nothing for $tool when it is not installed', async ({ tool, file }) => { + await new RulesHandler().pullAllRules(teamConfig(), config('user', [tool])); + + expect(await fse.pathExists(home(file))).toBe(false); + }); + + it.each(TOOLS)('removes the block, and a file that held only it, once $tool is excluded', async ({ tool, root, file }) => { + await fse.ensureDir(home(root)); + await new RulesHandler().pullAllRules(teamConfig(), config('user', [tool])); + expect(await fse.readFile(home(file), 'utf8')).toBe(`${BLOCK}\n`); + + await new RulesHandler().pullAllRules(teamConfig(), config('user', ['claude'])); + + expect(await fse.pathExists(home(file))).toBe(false); + }); + + it.each(TOOLS)('removes the block once the member disables $tool', async ({ tool, root, file }) => { + await fse.ensureDir(home(root)); + await new RulesHandler().pullAllRules(teamConfig(), config('user', [tool])); + + await new RulesHandler().pullAllRules(teamConfig(), { ...config('user', [tool]), disabledAgents: [tool] } as LocalConfig); + + expect(await fse.pathExists(home(file))).toBe(false); + }); + + it('writes DeepSeek Harness\'s block to $DSH_HOME/AGENTS.md', async () => { + const dshHome = path.join(tmpDir, 'dsh-home'); + await fse.ensureDir(dshHome); + vi.stubEnv('DSH_HOME', dshHome); + + await new RulesHandler().pullAllRules(teamConfig(), config('user', ['dsh'])); + + expect(await fse.readFile(path.join(dshHome, 'AGENTS.md'), 'utf8')).toBe(`${BLOCK}\n`); + expect(await fse.pathExists(home('.dsh/AGENTS.md'))).toBe(false); + }); + + it('writes the other tools\' files when one cannot be written, and names that one', async () => { + await fse.ensureDir(home('.zcode/AGENTS.md')); // a directory where the file should be + await fse.ensureDir(home('.pi/agent')); + + await new RulesHandler().pullAllRules(teamConfig(), config('user', ['zcode', 'pi'])); + + expect(await fse.readFile(home('.pi/agent/AGENTS.md'), 'utf8')).toBe(`${BLOCK}\n`); + const warnings = vi.mocked(log.warn).mock.calls.map(([message]) => String(message)); + expect(warnings.some((m) => m.startsWith(`Could not write the team-rules block to ${home('.zcode/AGENTS.md')}`))).toBe(true); + }); + + it('writes DeepSeek Harness\'s block where only $DSH_HOME exists, as its hooks find it', async () => { + const dshHome = path.join(tmpDir, 'dsh-home'); + await fse.ensureDir(dshHome); + vi.stubEnv('DSH_HOME', dshHome); + + await new RulesHandler().pullAllRules(teamConfig(), config('user', ['dsh'])); + + expect(await fse.readFile(path.join(dshHome, 'AGENTS.md'), 'utf8')).toBe(`${BLOCK}\n`); + }); + + it('writes nothing for DeepSeek Harness when $DSH_HOME does not exist, even with ~/.dsh', async () => { + await fse.ensureDir(home('.dsh')); + vi.stubEnv('DSH_HOME', path.join(tmpDir, 'missing-dsh-home')); + + await new RulesHandler().pullAllRules(teamConfig(), config('user', ['dsh'])); + + expect(await fse.pathExists(home('.dsh/AGENTS.md'))).toBe(false); + expect(await fse.pathExists(path.join(tmpDir, 'missing-dsh-home'))).toBe(false); + }); + + describe('OpenClaw reads the workspace AGENTS.md its hooks resolve', () => { + it('follows agents.defaults.workspace in openclaw.json ahead of OPENCLAW_WORKSPACE_DIR', async () => { + const configured = path.join(tmpDir, 'configured-ws'); + const fromEnv = path.join(tmpDir, 'env-ws'); + await fse.ensureDir(configured); + await fse.ensureDir(fromEnv); + await fse.outputJson(home('.openclaw/openclaw.json'), { agents: { defaults: { workspace: configured } } }); + vi.stubEnv('OPENCLAW_WORKSPACE_DIR', fromEnv); + + await new RulesHandler().pullAllRules(teamConfig(), config('user', ['openclaw'])); + + expect(await fse.readFile(path.join(configured, 'AGENTS.md'), 'utf8')).toBe(`${BLOCK}\n`); + expect(await fse.pathExists(path.join(fromEnv, 'AGENTS.md'))).toBe(false); + }); + + it('follows OPENCLAW_WORKSPACE_DIR', async () => { + const fromEnv = path.join(tmpDir, 'env-ws'); + await fse.ensureDir(fromEnv); + await fse.ensureDir(home('.openclaw/workspace')); + vi.stubEnv('OPENCLAW_WORKSPACE_DIR', fromEnv); + + await new RulesHandler().pullAllRules(teamConfig(), config('user', ['openclaw'])); + + expect(await fse.readFile(path.join(fromEnv, 'AGENTS.md'), 'utf8')).toBe(`${BLOCK}\n`); + expect(await fse.pathExists(home('.openclaw/workspace/AGENTS.md'))).toBe(false); + }); + + it('follows the profile\'s state dir', async () => { + vi.stubEnv('OPENCLAW_PROFILE', 'work'); + await fse.ensureDir(home('.openclaw-work/workspace')); + await fse.ensureDir(home('.openclaw/workspace')); + + await new RulesHandler().pullAllRules(teamConfig(), config('user', ['openclaw'])); + + expect(await fse.readFile(home('.openclaw-work/workspace/AGENTS.md'), 'utf8')).toBe(`${BLOCK}\n`); + expect(await fse.pathExists(home('.openclaw/workspace/AGENTS.md'))).toBe(false); + }); + + it('writes no block, and warns naming openclaw.json, its parse error and the next step, when teamai cannot read that file', async () => { + // OpenClaw reads JSON5; a workspace set there is invisible to teamai, so + // the default workspace may not be the one OpenClaw uses. + await fse.ensureDir(home('.openclaw/workspace')); + await fse.outputFile(home('.openclaw/openclaw.json'), '{ // JSON5\n agents: { defaults: { workspace: "~/elsewhere" } },\n}\n'); + vi.mocked(loadLocalConfigForScope).mockImplementation(async (scope) => (scope === 'user' ? config('user', ['openclaw']) : null) as never); + vi.mocked(loadTeamConfig).mockResolvedValue(teamConfig()); + + try { + await pull({ force: true }); + } finally { + vi.mocked(loadLocalConfigForScope).mockReset(); + } + + expect(await fse.pathExists(home('.openclaw/workspace/AGENTS.md'))).toBe(false); + const warnings = vi.mocked(log.warn).mock.calls.map(([message]) => String(message)); + const warning = warnings.find((message) => message.includes(home('.openclaw/openclaw.json'))); + expect(warning).toBeDefined(); + expect(warning).toMatch(/JSON/); + expect(warning).toContain('teamai pull'); + }); + + it('follows OPENCLAW_STATE_DIR', async () => { + const stateDir = path.join(tmpDir, 'oc-state'); + await fse.ensureDir(path.join(stateDir, 'workspace')); + vi.stubEnv('OPENCLAW_STATE_DIR', stateDir); + + await new RulesHandler().pullAllRules(teamConfig(), config('user', ['openclaw'])); + + expect(await fse.readFile(path.join(stateDir, 'workspace', 'AGENTS.md'), 'utf8')).toBe(`${BLOCK}\n`); + }); + }); +}); + +const LEGACY: ReadonlyArray<{ tool: string; scope: 'user' | 'project'; root: string; dir: string; copy: (raw: string) => string; ext: string }> = [ + { tool: 'openclaw', scope: 'user', root: '.openclaw/workspace', dir: '.openclaw/rules', copy: (raw) => raw, ext: '.md' }, + { tool: 'openclaw', scope: 'project', root: '.openclaw', dir: '.openclaw/rules', copy: (raw) => raw, ext: '.md' }, + { tool: 'pi', scope: 'user', root: '.pi/agent', dir: '.pi/agent/rules', copy: (raw) => raw, ext: '.md' }, + { tool: 'pi', scope: 'project', root: '.pi', dir: '.pi/rules', copy: (raw) => raw, ext: '.md' }, + { tool: 'joycode', scope: 'user', root: '.joycode', dir: '.joycode/rules', copy: teamRuleToCursorMdc, ext: '.mdc' }, + // A release between JoyCode's own render (#946) and this one rewrote user copies in it. + { tool: 'joycode', scope: 'user', root: '.joycode', dir: '.joycode/rules', copy: teamRuleToJoycodeRule, ext: '.mdc' }, +]; +const base = (scope: 'user' | 'project') => (scope === 'user' ? homeDir : projectRoot); + +describe('pull reclaims the rule copies left where these tools never read them (#946)', () => { + it.each(LEGACY)('$tool ($scope): removes an unedited copy in $dir and keeps an edited one, named', async ({ tool, scope, root, dir, copy, ext }) => { + await fse.ensureDir(path.join(base(scope), root)); + const unedited = path.join(base(scope), dir, `scoped${ext}`); + const edited = path.join(base(scope), dir, `codeword${ext}`); + await fse.outputFile(unedited, copy(SCOPED)); + await fse.outputFile(edited, 'The team codeword is PELICAN-42. My own addition.\n'); + + await new RulesHandler().pullAllRules(teamConfig(), config(scope, [tool])); + + expect(await fse.pathExists(unedited)).toBe(false); + expect(await fse.readFile(edited, 'utf8')).toBe('The team codeword is PELICAN-42. My own addition.\n'); + const warnings = vi.mocked(log.warn).mock.calls.map(([message]) => String(message)); + expect(warnings.some((message) => message.includes(edited))).toBe(true); + }); +}); + +describe('a pull at an unchanged team revision after a CLI upgrade (#946)', () => { + let saved: Record; + + beforeEach(() => { + saved = {}; + vi.mocked(saveStateForScope).mockImplementation(async (state) => { + saved = structuredClone(state) as Record; + }); + vi.mocked(loadStateForScope).mockImplementation(async () => structuredClone(saved) as never); + vi.mocked(loadTeamConfig).mockResolvedValue(teamConfig()); + }); + + afterEach(() => { + vi.mocked(saveStateForScope).mockReset(); + vi.mocked(loadStateForScope).mockImplementation(async () => ({}) as never); + vi.mocked(loadLocalConfigForScope).mockReset(); + }); + + it.each(TOOLS)('writes $tool\'s missing block and reclaims nothing it should keep', async ({ tool, root, file }) => { + await fse.ensureDir(home(root)); + vi.mocked(loadLocalConfigForScope).mockImplementation(async (scope) => (scope === 'user' ? config('user', [tool]) : null) as never); + await pull({}); + // What an older CLI left at this revision: no block. + await fse.writeFile(home(file), 'My own notes.\n'); + vi.mocked(log.success).mockClear(); + + await pull({}); + + expect(vi.mocked(log.success).mock.calls.some(([message]) => String(message).includes('Already synced at abc1234'))).toBe(true); + expect(await fse.readFile(home(file), 'utf8')).toBe(`My own notes.\n\n${BLOCK}\n`); + }); + + it.each(LEGACY.filter(({ scope }) => scope === 'user'))('reclaims $tool\'s unedited copy in ~/$dir', async ({ tool, root, dir, copy, ext }) => { + await fse.ensureDir(home(root)); + vi.mocked(loadLocalConfigForScope).mockImplementation(async (scope) => (scope === 'user' ? config('user', [tool]) : null) as never); + await pull({}); + // What an older CLI left at this revision. + const unedited = home(path.join(dir, `scoped${ext}`)); + await fse.outputFile(unedited, copy(SCOPED)); + vi.mocked(log.success).mockClear(); + + await pull({}); + + expect(vi.mocked(log.success).mock.calls.some(([message]) => String(message).includes('Already synced at abc1234'))).toBe(true); + expect(await fse.pathExists(unedited)).toBe(false); + }); + + it('reclaims Pi\'s unedited copy in a project\'s .pi/rules', async () => { + await fse.ensureDir(path.join(projectRoot, '.pi')); + vi.mocked(detectProjectConfig).mockResolvedValue(config('project', ['pi'])); + try { + await pull({}); + // What an older CLI left at this revision. + const unedited = path.join(projectRoot, '.pi', 'rules', 'scoped.md'); + await fse.outputFile(unedited, SCOPED); + vi.mocked(log.success).mockClear(); + + await pull({}); + + expect(vi.mocked(log.success).mock.calls.some(([message]) => String(message).includes('Already synced at abc1234'))).toBe(true); + expect(await fse.pathExists(unedited)).toBe(false); + } finally { + vi.mocked(detectProjectConfig).mockResolvedValue(null); + } + }); +}); + +describe('uninstall removes the team-rules block (#946)', () => { + afterEach(() => { + vi.mocked(autoDetectInit).mockReset(); + }); + + it.each(TOOLS)('uninstall --agent $tool removes the block, and the file teamai created for it alone', async ({ tool, root, file }) => { + await fse.ensureDir(home(root)); + const localConfig = config('user', [tool, 'claude']); + await new RulesHandler().pullAllRules(teamConfig(), localConfig); + expect(await fse.pathExists(home(file))).toBe(true); + vi.mocked(autoDetectInit).mockResolvedValue({ localConfig, teamConfig: teamConfig() } as never); + + await uninstall({ force: true, agent: tool }); + + expect(await fse.pathExists(home(file))).toBe(false); + }); + + it.each(TOOLS)('uninstall --agent $tool keeps the member\'s text in ~/$file', async ({ tool, root, file }) => { + await fse.ensureDir(home(root)); + await fse.writeFile(home(file), '# My notes\n'); + const localConfig = config('user', [tool, 'claude']); + await new RulesHandler().pullAllRules(teamConfig(), localConfig); + vi.mocked(autoDetectInit).mockResolvedValue({ localConfig, teamConfig: teamConfig() } as never); + + await uninstall({ force: true, agent: tool }); + + expect(await fse.readFile(home(file), 'utf8')).toBe('# My notes\n'); + }); +}); + +describe('uninstall reclaims the same legacy copies (#946)', () => { + afterEach(() => { + vi.mocked(autoDetectInit).mockReset(); + }); + + it.each(LEGACY)('$tool ($scope): uninstall --agent removes an unedited copy in $dir and keeps an edited one, named', async ({ tool, scope, root, dir, copy, ext }) => { + await fse.ensureDir(path.join(base(scope), root)); + const unedited = path.join(base(scope), dir, `scoped${ext}`); + const edited = path.join(base(scope), dir, `codeword${ext}`); + await fse.outputFile(unedited, copy(SCOPED)); + await fse.outputFile(edited, 'The team codeword is PELICAN-42. My own addition.\n'); + const localConfig = config(scope, [tool, 'claude']); + vi.mocked(autoDetectInit).mockResolvedValue({ localConfig, teamConfig: teamConfig() } as never); + + await uninstall({ force: true, agent: tool }); + + expect(await fse.pathExists(unedited)).toBe(false); + expect(await fse.pathExists(edited)).toBe(true); + const warnings = vi.mocked(log.warn).mock.calls.map(([message]) => String(message)); + expect(warnings.some((message) => message.includes(edited))).toBe(true); + }); +}); + +describe('doctor checks the block in the file each tool reads (#946)', () => { + const NAMES: Record = { + zcode: 'Team rules are inlined in ZCode AGENTS.md', + dsh: 'Team rules are inlined in DeepSeek Harness AGENTS.md', + openclaw: 'Team rules are inlined in OpenClaw workspace AGENTS.md', + pi: 'Team rules are inlined in Pi AGENTS.md', + joycode: 'Team rules are inlined in JoyCode rules.txt', + }; + + async function check(tool: string) { + vi.mocked(loadLocalConfig).mockResolvedValue(config('user', [tool])); + vi.mocked(loadTeamConfig).mockResolvedValue(teamConfig()); + const ctx = await resolveDoctorContext(); + if (!ctx) throw new Error('expected a resolved doctor context'); + return (await buildChecks(ctx)).find((c) => c.name === NAMES[tool]); + } + + it.each(TOOLS)('passes for $tool after a pull, and fails naming ~/$file after a hand edit', async ({ tool, root, file }) => { + await fse.ensureDir(home(root)); + await new RulesHandler().pullAllRules(teamConfig(), config('user', [tool])); + + const passing = await check(tool); + expect(passing).toBeDefined(); + expect(await passing!.check()).toBe(true); + + await fse.writeFile(home(file), (await fse.readFile(home(file), 'utf8')).replace('PELICAN-42', 'PELICAN-43')); + const failing = (await check(tool))!; + expect(await failing.check()).toBe(false); + expect(failing.fix).toContain(home(file)); + expect(failing.fix).toContain('Run `teamai pull` to rewrite it.'); + expect(failing.fix).not.toContain('--force'); + }); + + it.each(TOOLS)('fails for $tool when ~/$file carries no block', async ({ tool, root, file }) => { + await fse.ensureDir(home(root)); + await fse.writeFile(home(file), '# My notes\n'); + + const failing = (await check(tool))!; + expect(await failing.check()).toBe(false); + expect(failing.fix).toContain(home(file)); + expect(failing.fix).toContain('Run `teamai pull` to restore it.'); + }); + + it('fails for OpenClaw with the parse error when teamai cannot read openclaw.json', async () => { + await fse.ensureDir(home('.openclaw/workspace')); + await fse.outputFile(home('.openclaw/workspace/AGENTS.md'), `${BLOCK}\n`); + await fse.outputFile(home('.openclaw/openclaw.json'), '{ // JSON5\n agents: {},\n}\n'); + + const failing = (await check('openclaw'))!; + expect(failing).toBeDefined(); + expect(await failing.check()).toBe(false); + expect(failing.fix).toContain(home('.openclaw/openclaw.json')); + expect(failing.fix).toMatch(/JSON/); + }); + + it.each(TOOLS)('asks nothing of $tool when it is not installed', async ({ tool }) => { + expect(await check(tool)).toBeUndefined(); + }); + + it('asks nothing of these tools in project scope', async () => { + for (const { root } of TOOLS) await fse.ensureDir(home(root)); + vi.mocked(loadLocalConfig).mockResolvedValue(config('project', TOOLS.map(({ tool }) => tool))); + vi.mocked(detectProjectConfig).mockResolvedValue(config('project', TOOLS.map(({ tool }) => tool))); + vi.mocked(loadTeamConfig).mockResolvedValue(teamConfig()); + try { + const ctx = await resolveDoctorContext(); + const names = (await buildChecks(ctx!)).map((c) => c.name); + for (const name of Object.values(NAMES)) expect(names).not.toContain(name); + } finally { + vi.mocked(detectProjectConfig).mockResolvedValue(null); + } + }); +}); + +describe('OpenClaw\'s instruction blocks follow the same workspace, in user scope only (#946)', () => { + beforeEach(async () => { + await fse.writeFile(path.join(repoPath, 'culture.md'), '---\ncompany:\n name: Acme\n---\n\nBe kind to teammates.\n'); + vi.mocked(loadTeamConfig).mockResolvedValue(teamConfig()); + }); + + afterEach(() => { + vi.mocked(loadLocalConfigForScope).mockReset(); + vi.mocked(detectProjectConfig).mockResolvedValue(null); + }); + + it('a user-scope pull writes culture and the team rules to the profile\'s workspace AGENTS.md, and leaves the default one, which the default profile reads', async () => { + vi.stubEnv('OPENCLAW_PROFILE', 'work'); + await fse.ensureDir(home('.openclaw-work/workspace')); + await fse.outputFile(home('.openclaw/workspace/AGENTS.md'), '# Default workspace\n\n\nold\n\n'); + vi.mocked(loadLocalConfigForScope).mockImplementation(async (scope) => (scope === 'user' ? config('user', ['openclaw']) : null) as never); + + await pull({}); + + const content = await fse.readFile(home('.openclaw-work/workspace/AGENTS.md'), 'utf8'); + expect(content).toContain('Be kind to teammates.'); + expect(content).toContain(BLOCK); + expect(await fse.readFile(home('.openclaw/workspace/AGENTS.md'), 'utf8')) + .toBe('# Default workspace\n\n\nold\n\n'); + }); + + it('a user-scope pull keeps Pi\'s team-rules block beside the instruction blocks, each once, over two pulls', async () => { + await fse.ensureDir(home('.pi/agent')); + vi.mocked(loadLocalConfigForScope).mockImplementation(async (scope) => (scope === 'user' ? config('user', ['pi']) : null) as never); + + await pull({ force: true }); + await pull({ force: true }); + + const content = await fse.readFile(home('.pi/agent/AGENTS.md'), 'utf8'); + expect(content.split('').length - 1).toBe(1); + expect(content.split('').length - 1).toBe(1); + expect(content).toContain('Be kind to teammates.'); + expect(content).toContain(BLOCK); + }); + + it('a project-scope pull writes no blocks into the project for OpenClaw, and strips an older release\'s', async () => { + await fse.ensureDir(home('.openclaw/workspace')); + const projectFile = path.join(projectRoot, '.openclaw', 'workspace', 'AGENTS.md'); + await fse.outputFile(projectFile, '# Project notes\n\n\nold\n\n'); + vi.mocked(detectProjectConfig).mockResolvedValue(config('project', ['openclaw'])); + + await pull({}); + + expect(await fse.readFile(projectFile, 'utf8')).toBe('# Project notes\n'); + expect(await fse.pathExists(path.join(projectRoot, 'AGENTS.md'))).toBe(false); + expect(await fse.pathExists(home('.openclaw/workspace/AGENTS.md'))).toBe(false); + }); +}); + +describe('init and doctor say why OpenClaw gets no project rules (#946)', () => { + it('notes it in project scope while OpenClaw is installed', async () => { + await fse.ensureDir(home('.openclaw/workspace')); + + const notes = await ruleChannelNotes(config('project', ['openclaw'])); + + expect(notes.some((note) => note.startsWith('OpenClaw gets no project rules') && note.includes('workspace AGENTS.md'))).toBe(true); + }); + + it.each([ + ['in user scope', 'user' as const, ['openclaw'], true], + ['when OpenClaw is excluded', 'project' as const, ['claude'], true], + ['when OpenClaw is not installed', 'project' as const, ['openclaw'], false], + ])('says nothing %s', async (_label, scope, enabled, installed) => { + if (installed) await fse.ensureDir(home('.openclaw/workspace')); + + const notes = await ruleChannelNotes(config(scope, enabled)); + + expect(notes.some((note) => note.startsWith('OpenClaw'))).toBe(false); + }); +}); + +/** + * A team `toolPaths` entry replaces the default one whole, so one written + * before #946 still sends rules to a directory the tool never reads; doctor + * must not pass that delivery (#946). + */ +describe('doctor fails a delivery to a rules directory the tool does not read (#946)', () => { + const OLD_ENTRIES: ReadonlyArray<{ + tool: string; scope: 'user' | 'project'; root: string; dir: string; name: string; entry: Record; change: string; + }> = [ + { + tool: 'pi', scope: 'user', root: '.pi/agent', dir: '.pi/agent/rules', name: 'Rules delivered to pi', + entry: { skills: '.pi/skills', rules: '.pi/rules', claudemd: 'AGENTS.md', userScope: { skills: '.pi/agent/skills', rules: '.pi/agent/rules', claudemd: '.pi/agent/AGENTS.md' } }, + change: '`userScope.rules`', + }, + { + tool: 'pi', scope: 'project', root: '.pi', dir: '.pi/rules', name: 'Rules delivered to pi', + entry: { skills: '.pi/skills', rules: '.pi/rules', claudemd: 'AGENTS.md', userScope: { skills: '.pi/agent/skills', rules: '.pi/agent/rules', claudemd: '.pi/agent/AGENTS.md' } }, + change: '`rules`', + }, + { + tool: 'workbuddy', scope: 'project', root: '.workbuddy', dir: '.workbuddy/rules', name: 'Rules delivered to workbuddy', + entry: { skills: '.workbuddy/skills', rules: '.workbuddy/rules', settings: '.workbuddy/settings.json' }, + change: '`rules: .codebuddy/rules`', + }, + { + tool: 'joycode', scope: 'user', root: '.joycode', dir: '.joycode/rules', name: 'Rules delivered to joycode', + entry: { skills: '.joycode/skills', rules: '.joycode/rules' }, + change: '`userScope.rules: null`', + }, + ]; + + afterEach(() => { + vi.mocked(detectProjectConfig).mockResolvedValue(null); + }); + + it.each(OLD_ENTRIES)('$tool ($scope): fails for $dir, naming the directory, why and the toolPaths change', async ({ tool, scope, root, dir, name, entry, change }) => { + await fse.ensureDir(path.join(base(scope), root)); + const team = TeamaiConfigSchema.parse({ team: 'test', repo: 'https://example.invalid/x/team.git', toolPaths: { [tool]: entry } }); + const localConfig = config(scope, [tool]); + await new RulesHandler().pullAllRules(team, localConfig); + // The old entry still delivers there; this is the case doctor must catch. + expect(await fse.pathExists(path.join(base(scope), dir, `codeword${tool === 'joycode' ? '.mdc' : '.md'}`))).toBe(true); + + vi.mocked(loadLocalConfig).mockResolvedValue(localConfig); + if (scope === 'project') vi.mocked(detectProjectConfig).mockResolvedValue(localConfig); + vi.mocked(loadTeamConfig).mockResolvedValue(team); + const ctx = await resolveDoctorContext(); + const failing = (await buildChecks(ctx!)).find((c) => c.name === name); + + expect(failing).toBeDefined(); + expect(await failing!.check()).toBe(false); + expect(failing!.fix).toContain(`toolPaths.${tool} entry still sends the rules to ${path.join(base(scope), dir)}`); + expect(failing!.fix).toContain(`toolPaths.${tool}`); + expect(failing!.fix).toContain(change); + // A copy there is inert, so restoring it is no fix. + expect(failing!.fix).not.toContain('--force'); + }); + + it('still passes the default WorkBuddy delivery to .codebuddy/rules in a project', async () => { + await fse.ensureDir(path.join(projectRoot, '.workbuddy')); + const localConfig = config('project', ['workbuddy']); + await new RulesHandler().pullAllRules(teamConfig(), localConfig); + + vi.mocked(loadLocalConfig).mockResolvedValue(localConfig); + vi.mocked(detectProjectConfig).mockResolvedValue(localConfig); + vi.mocked(loadTeamConfig).mockResolvedValue(teamConfig()); + const ctx = await resolveDoctorContext(); + const check = (await buildChecks(ctx!)).find((c) => c.name.startsWith('Rules delivered to') && c.name.includes('workbuddy')); + + expect(check).toBeDefined(); + expect(await check!.check()).toBe(true); + }); +}); diff --git a/src/builtin-hooks.ts b/src/builtin-hooks.ts index e31e41936..897874618 100644 --- a/src/builtin-hooks.ts +++ b/src/builtin-hooks.ts @@ -3,7 +3,7 @@ import path from 'node:path'; import { execFileSync } from 'node:child_process'; import { fileURLToPath } from 'node:url'; import { TEAMAI_HOOK_DESCRIPTION_PREFIX } from './types.js'; -import { CODEX_TOOL_IDS } from './utils/tool-names.js'; +import { CODEX_TOOL_IDS, isCodexTool } from './utils/tool-names.js'; import type { HookDef } from './types.js'; import { getUserHome } from './utils/home.js'; import { log } from './utils/logger.js'; @@ -414,9 +414,6 @@ const SUBAGENT_STOP_TOOLS = new Set([ */ const WRAPPER_TOOLS = SHELL_DEPENDENT_TOOLS; -function isCodexTool(tool: string): boolean { - return (CODEX_TOOL_IDS as readonly string[]).includes(tool); -} export function builtinHookDefs(tool: string): HookDef[] { // ZCode renders per-event timeouts from the ZCODE_TIMEOUT_MS table in its own @@ -492,8 +489,9 @@ const ADAPTER_BUILTIN_HOOKS: Record 'Hook dispatch prompt-submit', ], }, - // openclaw-hooks.ts EVENT_MAP maps session:start and command:new only, and - // its generated handler spawns the dispatcher with argv. Only `openclaw`: + // openclaw-hooks.ts EVENT_MAP maps OpenClaw's events onto session-start and + // prompt-submit only, and its generated handler spawns the dispatcher with + // argv. Only `openclaw`: // the other claw variants share its workspace resolver, so reconciliation // does not route them (see reconcileHooksToAllTools). openclaw: { keys: ['Hook dispatch session-start', 'Hook dispatch prompt-submit'] }, diff --git a/src/builtin-rules.ts b/src/builtin-rules.ts index cbe19bbb6..456e6a018 100644 --- a/src/builtin-rules.ts +++ b/src/builtin-rules.ts @@ -2,8 +2,7 @@ import path from 'node:path'; import { ensureDir, writeFile, pathExists } from './utils/fs.js'; import { log } from './utils/logger.js'; import { isToolInstalledForConfig, ResourceHandler } from './resources/base.js'; -import { ruleFileExtensionForTool, usesCursorMdcRules } from './resources/rule-format.js'; -import { teamRuleToCursorMdc } from './resources/cursor-mdc.js'; +import { renderRuleForTool, ruleFileExtensionForTool } from './resources/rule-format.js'; import type { TeamaiConfig, LocalConfig } from './types.js'; import { resolveToolBaseDir, isAgentExcluded, scopedToolPaths } from './types.js'; import fs from 'node:fs/promises'; @@ -86,14 +85,13 @@ export async function deployBuiltinRules( try { await ensureDir(rulesDir); - // Deploy current built-in rules. Cursor-compatible tools use `.mdc` - // with derived frontmatter; every other tool gets canonical `.md`. + // Deploy current built-in rules in each tool's own rules format + // (`.mdc` for Cursor, Kiro's and Qoder's frontmatter, โ€ฆ); a tool + // without one gets the canonical `.md`. const ext = ruleFileExtensionForTool(tool); for (const rule of builtinRules) { const destFile = path.join(rulesDir, `${rule.name}${ext}`); - const content = usesCursorMdcRules(tool) - ? teamRuleToCursorMdc(rule.content) - : rule.content; + const content = renderRuleForTool(tool, rule.content); await writeFile(destFile, content); log.debug(`Deployed built-in rule ${rule.name} โ†’ ${tool}`); diff --git a/src/doctor-delivery.ts b/src/doctor-delivery.ts index 2adc5b5d0..77099112c 100644 --- a/src/doctor-delivery.ts +++ b/src/doctor-delivery.ts @@ -1,7 +1,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 { expandHome, listFilesRecursive, pathExists, readFileSafe, readJsonObject } from './utils/fs.js'; import { CODEX_TOOL_ID, DEFAULT_CODEX_ROOT, getDataHome, getMcpSharing, isAgentExcluded, managedMcpManifestKey, resolveToolBaseDir, resolveToolRootDir, scopedToolPaths, @@ -9,7 +9,7 @@ import { import type { DeliveryTarget, LocalConfig, ManagedMcpManifest, ResourceItem, TeamaiConfig } from './types.js'; import type { EntryLayout, EntryResolution } from './namespaced-entries.js'; import { splitFrontmatter } from './utils/frontmatter.js'; -import type { ResourceHandler } from './resources/base.js'; +import { isToolInstalledForConfig, type ResourceHandler } from './resources/base.js'; import type { Check, DoctorContext } from './doctor.js'; import type { DesiredMcpContext } from './mcp-reconcile.js'; import type { ResolvedMcpFile } from './mcp-resolved-files.js'; @@ -20,6 +20,7 @@ import { SHELL_PROFILE_CANDIDATE_NAMES, } from './utils/shell-profile.js'; import { getUserHome } from './utils/home.js'; +import { getsRulesFromExtension, ruleFormatForTool, ruleStemsForTool } from './resources/rule-format.js'; /** * The checks that verify the payload rather than the plumbing: what each tool @@ -74,6 +75,8 @@ function appendTo(buckets: Map, tool: string, name: string): v /** What one tool was owed, and which of it did not arrive intact. */ interface ToolDelivery { + /** The tool whose format its items land in; the map key also names the tools sharing the copy. */ + tool: string; /** Where its items land โ€” the fix names it when the filename is derived. */ dir: string; /** Item names grouped by the problem label `classify` gave them. */ @@ -93,6 +96,8 @@ async function walkDelivery( items: ResourceItem[], classify: (target: DeliveryTarget, item: ResourceItem) => Promise, ): Promise<{ byTool: Map; unreceived: string[] }> { + // `items` is what this member receives, so the handler need not resolve it again. + const received = items.map((item) => item.name); const { localConfig, teamConfig } = ctx; if (!teamConfig) return { byTool: new Map(), unreceived: [] }; @@ -100,14 +105,16 @@ async function walkDelivery( const unreceived: string[] = []; for (const item of items) { - const targets = await handler.deliveryTargets(teamConfig, localConfig, item); + const targets = await handler.deliveryTargets(teamConfig, localConfig, item, received); if (targets.length === 0) unreceived.push(item.name); for (const target of targets) { - let delivery = byTool.get(target.tool); + // One check for a copy several tools read, naming them all (#946). + const key = [target.tool, ...target.sharedWith ?? []].join(', '); + let delivery = byTool.get(key); if (!delivery) { - delivery = { dir: path.dirname(target.dest), problems: new Map() }; - byTool.set(target.tool, delivery); + delivery = { tool: target.tool, dir: path.dirname(target.dest), problems: new Map() }; + byTool.set(key, delivery); } const problem = await classify(target, item); if (problem !== null) appendTo(delivery.problems, problem, item.name); @@ -161,15 +168,19 @@ function hasDeliveryProblem(delivery: ToolDelivery): boolean { /** * What `pullItem` did not write at `target`: an older render, or a copy the - * member changed since teamai delivered it, which pull keeps. + * member changed since teamai delivered it, which pull keeps. With + * `recordedLabel`, an older render still holding what teamai recorded writing + * gets that label instead. */ async function differingCopyLabel( - item: ResourceItem, target: DeliveryTarget, olderLabel: string, localConfig: LocalConfig, + item: ResourceItem, target: DeliveryTarget, olderLabel: string, localConfig: LocalConfig, recordedLabel?: string, ): Promise { const { deliveredHashes } = await import('./pull.js'); - const { judgeCopy } = await import('./resources/delivered-copies.js'); - const verdict = await judgeCopy(await deliveredHashes(localConfig), item, target); - return verdict.kind === 'keep' ? CHANGED_BY_YOU : olderLabel; + const { judgeCopy, recordedUnchanged } = await import('./resources/delivered-copies.js'); + const previous = await deliveredHashes(localConfig); + const verdict = await judgeCopy(previous, item, target); + if (verdict.kind === 'keep') return CHANGED_BY_YOU; + return recordedLabel !== undefined && await recordedUnchanged(previous, target.dest) ? recordedLabel : olderLabel; } /** `a, b, c and 4 more` โ€” a fix a human reads, not a wall of paths. */ @@ -260,23 +271,21 @@ export async function buildRulesDeliveryChecks(ctx: DoctorContext): Promise ({ - name: `Rules delivered to ${tool}`, - source: 'local', - check: async () => !hasDeliveryProblem(delivery), - // The fix names the directory rather than the tool: a rule's delivered - // filename carries a per-tool extension the reader would have to derive. - fix: `In ${delivery.dir}, ${describeProblems(delivery.problems, ruleLabels)}. ` - + 'Run `teamai pull --force`: a plain pull skips a scope whose team repo has not changed, ' - + 'so it cannot restore this. An older copy is one whose bytes are no longer what teamai ' - + `renders for ${tool}, frontmatter included: a \`.mdc\` or \`.instructions.md\` whose ` - + '`globs`, `alwaysApply` or `applyTo` drifted from the team `.md` applies to the wrong ' - + `files while looking perfectly well-formed.${changedByYouFix(delivery)}`, - })); + ); + // A tool that reads only the top of its rules directory gets no file for a + // namespaced rule whose flat name another rule has (`deliveryTargets`). + for (const [tool, toolPath] of Object.entries(scopedToolPaths(teamConfig, localConfig))) { + if (!toolPath.rules || !ruleFormatForTool(tool)?.flat || isAgentExcluded(localConfig, tool)) continue; + if (!await isToolInstalledForConfig(tool, toolPath.rules, localConfig)) continue; + const stems = ruleStemsForTool(tool, items.map((item) => item.name)); + const collisions = items.filter((item) => !stems.has(item.name)); + if (collisions.length === 0) continue; + let delivery = byTool.get(tool); + if (!delivery) { + delivery = { tool, dir: path.join(resolveToolBaseDir(tool, localConfig), toolPath.rules), problems: new Map() }; + byTool.set(tool, delivery); + } + for (const item of collisions) appendTo(delivery.problems, FLAT_NAME_TAKEN, item.name); + } + const { unreadRulesDir } = await import('./resources/rules.js'); + const perTool: Check[] = []; + for (const [tool, delivery] of byTool) { + // A team `toolPaths` entry written before the tool's rules moved still + // sends them to a directory it never reads (#946). + const rulesPath = scopedToolPaths(teamConfig, localConfig)[delivery.tool]?.rules; + const rulesDir = rulesPath ? path.join(resolveToolBaseDir(delivery.tool, localConfig), rulesPath) : undefined; + const unread = rulesDir ? unreadRulesDir(delivery.tool, rulesDir, localConfig) : undefined; + const problems = describeProblems(delivery.problems, ruleLabels); + const restore = delivery.problems.has(ruleLabels[0]) || delivery.problems.has(ruleLabels[1]); + perTool.push({ + name: `Rules delivered to ${tool}`, + source: 'local', + check: async () => !hasDeliveryProblem(delivery) && unread === undefined, + // The fix names the directory rather than the tool: a rule's delivered + // filename carries a per-tool extension the reader would have to derive. + // A copy there is inert whatever its state, so only the entry is worth fixing. + fix: unread + ? `${unread.why}, but the team teamai.yaml's toolPaths.${delivery.tool} entry still sends the rules to ` + + `${rulesDir}: a team entry replaces teamai's default for ${delivery.tool} whole, so ${delivery.tool} gets ` + + `none of the rules copied there. In the team teamai.yaml, ${unread.toolPathsFix}, then run \`teamai pull\`.` + : `In ${delivery.dir}, ${problems}. ` + + (delivery.problems.has(FLAT_NAME_TAKEN) + ? `${tool} reads only the top level of that directory, so a rule not written there never reaches it: ` + + 'rename one of them in the team repo; `teamai pull` names the rules that share the file. ' + : '') + + (restore + ? 'Run `teamai pull --force`: a plain pull skips a scope whose team repo has not changed, ' + + 'so it cannot restore a missing copy or one teamai has no record of writing. ' + : '') + + (delivery.problems.has(RECORDED_OLDER_RULE) + ? 'Run `teamai pull` to rewrite a copy that still holds what teamai recorded writing: it re-renders ' + + 'one even when the team repo has not changed. ' + : '') + + `${olderRuleCopyMeaning(delivery.tool)}${changedByYouFix(delivery)}`, + }); + } return [...activation, ...perTool]; } +/** An older render of a rule that still holds what teamai recorded writing, which a plain pull re-renders. */ +const RECORDED_OLDER_RULE = 'delivered in an older render teamai recorded'; + +/** A namespaced rule a tool reading only the top of its rules directory gets no file for. */ +const FLAT_NAME_TAKEN = 'not written, as another team rule has its flat name'; + +/** What a rule copy "delivered from an older copy" means for `tool`, in its own format. */ +function olderRuleCopyMeaning(tool: string): string { + const fields = ruleFormatForTool(tool)?.scopeFields ?? []; + if (fields.length === 0) { + return `An older copy is one whose bytes are no longer the team \`.md\`, which ${tool} gets verbatim.`; + } + const named = fields.map((field) => `\`${field}\``); + return `An older copy is one whose bytes are no longer what teamai renders for ${tool}, frontmatter included: ` + + `one whose ${named.slice(0, -1).join(', ')}${named.length > 1 ? ' or ' : ''}${named[named.length - 1]} drifted ` + + 'from the team `.md` applies to the wrong files while looking perfectly well-formed.'; +} + /** * The two rule destinations that are not a file per tool. * @@ -326,78 +393,188 @@ async function buildRulesActivationChecks(ctx: DoctorContext, items: ResourceIte const handler = new RulesHandler(); const checks: Check[] = []; - const opencode = await handler.opencodeInstructionsTarget(teamConfig, localConfig); + const opencode = await handler.opencodeInstructionsTarget(teamConfig, localConfig, items); if (opencode !== null) { const { readOpencodeInstructionList } = await import('./resources/opencode-config.js'); - const instructions = await readOpencodeInstructionList(opencode.configFile); - const active = instructions !== null && instructions.includes(opencode.glob); + // A missing file just lists nothing yet; null is one the pull cannot parse. + const instructions = await pathExists(opencode.configFile) ? await readOpencodeInstructionList(opencode.configFile) : []; + const globs = items.length > 0 ? opencode.globs : []; + const missing = globs.filter((glob) => !instructions?.includes(glob)); + const stale = (instructions ?? []).filter((entry): entry is string => + typeof entry === 'string' && opencode.owns(entry) && !globs.includes(entry)); + const relativeStale = stale.filter((entry) => !path.isAbsolute(entry)); + const namespaceStale = stale.filter((entry) => path.isAbsolute(entry)); + const quoted = (entries: string[]): string => entries.map((entry) => `\`${entry}\``).join(', '); + // Pull sets these globs even when the team repo has not moved (#946). + const rerun = 'Run `teamai pull`.'; checks.push({ name: 'Team rules are active in opencode', source: 'local', - check: async () => active, + check: async () => instructions !== null && missing.length === 0 && stale.length === 0, fix: instructions === null ? `${opencode.configFile} could not be read as a JSON object, so the pull left it alone ` - + `and never added \`${opencode.glob}\` to \`instructions\`. Fix the file, then run ` - + '`teamai pull --force`.' - : `${opencode.configFile} does not list \`${opencode.glob}\` under \`instructions\`. ` - + 'OpenCode does not scan a rules directory, so every team rule delivered there is ' - + 'inert until this glob references it. Run `teamai pull --force`: a plain pull skips ' - + 'a scope whose team repo has not changed, so it cannot restore this.', + + 'without updating `instructions`. Fix the file, then run ' + + '`teamai pull`.' + : [ + ...(missing.length > 0 + ? [`${opencode.configFile} does not list ${quoted(missing)} under \`instructions\`. ` + + 'OpenCode does not scan a rules directory, so every team rule delivered there is ' + + 'inert until a glob references it.'] + : []), + ...(relativeStale.length > 0 + ? [`${opencode.configFile} still lists ${quoted(relativeStale)}, which teamai no longer writes. ` + + 'OpenCode resolves a relative entry from the session\'s working directory, so it loads ' + + 'that directory\'s rules instead of the team rules.'] + : []), + ...(namespaceStale.length > 0 + ? [`${opencode.configFile} still lists ${quoted(namespaceStale)}, for team rules that ` + + 'no longer reach this scope, so OpenCode loads whatever copy is left there.'] + : []), + rerun, + ].join(' '), }); } const { getHermesHome } = await import('./hermes-home.js'); const hermesHome = getHermesHome(); - if (!isAgentExcluded(localConfig, 'hermes') && await pathExists(hermesHome)) { + // SOUL.md is global and only a user-scope pull writes it; in a project, + // `ruleChannelNotes` says why Hermes gets no project rules (#946). + if (localConfig.scope === 'user' && !isAgentExcluded(localConfig, 'hermes') && await pathExists(hermesHome)) { const { getHermesSoulPath, readSoulRules } = await import('./hermes-config.js'); const expected = await inlinedRulesText(items); const delivered = await readSoulRules(); checks.push({ name: 'Team rules are inlined in Hermes SOUL.md', source: 'local', - check: async () => delivered !== null && delivered === expected.trim(), + check: async () => (items.length === 0 && delivered === null) || delivered === expected.trim(), fix: delivered === null ? `${getHermesSoulPath()} carries no teamai rules block, so Hermes reads none of the ` - + 'team rules. Run `teamai pull --force`: a plain pull skips a scope whose team repo ' - + 'has not changed, so it cannot restore this.' + + 'team rules. Run `teamai pull` to restore it.' : `The teamai block in ${getHermesSoulPath()} is not what the team rules inline to: ` + 'Hermes reads standing instructions from this file rather than a rules directory, ' - + 'so a stale block is a stale rule set. Run `teamai pull --force` to rewrite it.', + + 'so a stale block is a stale rule set. Run `teamai pull` to rewrite it.', }); } - checks.push(...await buildCodexUserRulesChecks(ctx, items)); + checks.push(...await buildUserRulesFileChecks(ctx, items)); + if (items.length > 0) checks.push(...await buildProjectRulesHookChecks(ctx)); return checks; } /** - * In user scope the Codex family reads the team rules from a managed block of - * its own AGENTS.md (#938); in a project its session-start hook adds them, and - * doctor checks that hook instead. One check per enabled, installed tool. + * The teamai `hook-dispatch session-start` commands under `SessionStart` in a + * Claude-shaped map of hook events (ZCode's `hooks.events`, the dsh bridge's + * `hooks`). + */ +function sessionStartDispatches(eventMap: unknown): string[] { + const groups = (eventMap as Record | null | undefined)?.SessionStart; + if (!Array.isArray(groups)) return []; + return groups + .flatMap((group) => (Array.isArray(group?.hooks) ? group.hooks : [])) + .map((entry: { command?: unknown; args?: unknown }) => [entry?.command, ...(Array.isArray(entry?.args) ? entry.args : [])].join(' ')) + .filter((command) => command.includes('hook-dispatch session-start')); +} + +/** + * In a project, ZCode and DeepSeek Harness get the team rules only from + * teamai's session-start hook (#946), so doctor checks the hook the tool + * runs: ZCode reads only the user-level ~/.zcode/cli/config.json, and only + * with `hooks.enabled`; DeepSeek Harness loads teamai's hook config only + * through the patch, which the member passes to dsh (`ruleChannelNotes`). + * Pi's extension is checked with the team instructions it also carries + * (`buildInstructionDeliveryChecks`). */ -async function buildCodexUserRulesChecks(ctx: DoctorContext, items: ResourceItem[]): Promise { +async function buildProjectRulesHookChecks(ctx: DoctorContext): Promise { + const { localConfig, teamConfig } = ctx; + if (!teamConfig || localConfig.scope !== 'project') return []; + const paths = scopedToolPaths(teamConfig, localConfig); + const home = getUserHome(); + const inject = 'Run `teamai hooks inject` to rewrite it.'; + const checks: Check[] = []; + + const zcodeSettings = paths.zcode?.settings; + if (zcodeSettings && !isAgentExcluded(localConfig, 'zcode') && await pathExists(path.join(home, '.zcode'))) { + const file = path.join(home, zcodeSettings); + const read = await readJsonObject(file); + const hooks = read.kind === 'ok' ? read.value.hooks as { enabled?: unknown; events?: unknown } | undefined : undefined; + const registered = sessionStartDispatches(hooks?.events).length > 0; + const enabled = hooks?.enabled === true; + checks.push({ + name: 'Project rules reach zcode through its SessionStart hook', + source: 'local', + check: async () => registered && enabled, + fix: read.kind === 'invalid' + ? `${file} is not valid JSON (${read.error}), so ZCode loads none of its hooks and sessions in this project ` + + 'get none of the team rules. Fix the file by hand (teamai does not rewrite a file it cannot parse), then run ' + + '`teamai hooks inject`.' + : !registered + ? `${file} has no teamai SessionStart hook, so ZCode sessions in this project get none of the team rules: ` + + `ZCode runs only the hooks in this file, none from a project. ${inject}` + : `${file} sets hooks.enabled to something other than true, so ZCode runs none of its hooks and sessions in ` + + 'this project get none of the team rules. Run `teamai hooks inject`, which turns them on.', + }); + } + + const { isDshInstalled } = await import('./dsh-hooks.js'); + if (!isAgentExcluded(localConfig, 'dsh') && await isDshInstalled()) { + const { buildDshPatch, resolveDshHookConfigPath, resolveDshPatchPath } = await import('./dsh-hooks.js'); + const configPath = resolveDshHookConfigPath(); + const patchPath = resolveDshPatchPath(); + const patched = await readFileSafe(patchPath) === buildDshPatch(configPath); + const read = await readJsonObject(configPath); + const registered = read.kind === 'ok' && sessionStartDispatches(read.value.hooks).length > 0; + const broken = [...(patched ? [] : [patchPath]), ...(registered ? [] : [configPath])]; + checks.push({ + name: 'Project rules reach dsh through its session-start hook', + source: 'local', + check: async () => broken.length === 0, + fix: `${broken.join(' and ')} ${broken.length === 1 ? 'is' : 'are'} missing or out of date, so DeepSeek Harness ` + + 'sessions in this project get none of the team rules. Run `teamai hooks inject` to rewrite ' + + `${broken.length === 1 ? 'it' : 'them'}. dsh loads that hook only when it runs with \`--patch "${patchPath}"\`.`, + }); + } + return checks; +} + +/** + * In user scope each tool with no rules format reads the team rules from a + * managed block of a file only it reads (`userRulesFile`: the Codex family's + * AGENTS.md, #938; ZCode, DeepSeek Harness, the OpenClaw workspace, Pi, + * JoyCode's rules.txt, #946); in a project the session-start hook (the Codex + * family, ZCode, DeepSeek Harness) or Pi's extension adds them, and doctor + * checks that channel instead. One check per enabled, + * installed tool, on the bytes of the block in the file the tool reads. + */ +async function buildUserRulesFileChecks(ctx: DoctorContext, items: ResourceItem[]): Promise { const { localConfig, teamConfig } = ctx; if (!teamConfig || localConfig.scope !== 'user') return []; const { teamRulesBlock } = await import('./resources/rules.js'); - const { getsRulesFromSessionHook, instructionFileInstallProbe, writesInstructionBlock } = await import('./resources/rule-format.js'); - const { isToolInstalledForConfig } = await import('./resources/base.js'); - const { TEAMAI_TEAM_RULES_START, TEAMAI_TEAM_RULES_END, resolveToolBaseDir, scopedToolPaths } = await import('./types.js'); + const { isCodexTool } = await import('./utils/tool-names.js'); + const { userRulesFile } = await import('./instruction-targets.js'); + const { TEAMAI_TEAM_RULES_START, TEAMAI_TEAM_RULES_END, scopedToolPaths } = await import('./types.js'); const expected = await teamRulesBlock(items); const checks: Check[] = []; for (const [tool, toolPath] of Object.entries(scopedToolPaths(teamConfig, localConfig))) { - if (!getsRulesFromSessionHook(tool) || isAgentExcluded(localConfig, tool)) continue; - const probe = instructionFileInstallProbe(tool, toolPath); - if (probe !== undefined && !await isToolInstalledForConfig(tool, probe, localConfig)) continue; - const name = tool === 'codex' - ? 'Team rules are inlined in Codex AGENTS.md' - : `Team rules are inlined in Codex AGENTS.md (${tool})`; - if (!writesInstructionBlock(tool, toolPath, 'team-rules')) { - // A team `toolPaths` entry replaces the default one whole, so an entry - // written before #938 leaves this tool nowhere to read user rules from. + if (isAgentExcluded(localConfig, tool)) continue; + const target = await userRulesFile(tool, toolPath, localConfig); + if (!target?.installed) continue; + const codexFamily = isCodexTool(tool); + const name = codexFamily && tool !== 'codex' + ? `Team rules are inlined in ${target.label} (${tool})` + : `Team rules are inlined in ${target.label}`; + if (target.unreadable) { + const { unreadableRulesFileMessage } = await import('./instruction-targets.js'); + checks.push({ name, source: 'local', check: async () => false, fix: unreadableRulesFileMessage(tool, target.unreadable) }); + continue; + } + const { file } = target; + if (file === undefined) { + // A team `toolPaths` entry replaces the default one whole, so a Codex + // entry written before #938 leaves it nowhere to read user rules from. // One with no `rules` path delivers no rules to it on purpose; with no // team rules it misses none. - if (items.length === 0 || !toolPath.rules) continue; + if (!codexFamily || items.length === 0 || !toolPath.rules) continue; checks.push({ name, source: 'local', @@ -409,28 +586,27 @@ async function buildCodexUserRulesChecks(ctx: DoctorContext, items: ResourceItem }); continue; } - const file = path.join(resolveToolBaseDir(tool, localConfig), toolPath.claudemd); const content = await readFileSafe(file); const start = content?.indexOf(TEAMAI_TEAM_RULES_START) ?? -1; const end = content?.indexOf(TEAMAI_TEAM_RULES_END) ?? -1; const delivered = content !== null && start !== -1 && end > start ? content.slice(start, end + TEAMAI_TEAM_RULES_END.length) : null; - // Codex reads AGENTS.override.md instead of AGENTS.md in the same - // directory, so a current block there is never seen. An empty or - // whitespace-only override shadows it too (checked with `codex exec`). - const override = path.join(path.dirname(file), 'AGENTS.override.md'); const problems: string[] = []; // With no rule body to inline (`expected === null`), pull writes no block. if (delivered === null && expected !== null) { problems.push(`${file} carries no team-rules block, so ${tool} reads none of the team ` + 'rules. Run `teamai pull` to restore it.'); } else if (delivered !== expected) { - problems.push(`The team-rules block in ${file} is not what the team rules inline to: Codex reads ` - + 'standing instructions from this file rather than a rules directory, so a stale block ' + problems.push(`The team-rules block in ${file} is not what the team rules inline to: ${tool} reads ` + + 'the team rules from this file rather than a rules directory, so a stale block ' + 'is a stale rule set. Run `teamai pull` to rewrite it.'); } - if (expected !== null && await isReadableFile(override)) { + // Codex reads AGENTS.override.md instead of AGENTS.md in the same + // directory, so a current block there is never seen. An empty or + // whitespace-only override shadows it too (checked with `codex exec`). + const override = path.join(path.dirname(file), 'AGENTS.override.md'); + if (codexFamily && expected !== null && await isReadableFile(override)) { problems.push(`${override} exists, so Codex reads it instead of ${file} and never sees the ` + 'team rules. Move its content into AGENTS.md, or delete it.'); } @@ -1279,7 +1455,7 @@ export async function buildInstructionDeliveryChecks(ctx: DoctorContext): Promis } const opencodePaths = scopedToolPaths(teamConfig, localConfig).opencode; - const opencodeFile = opencodePaths && instructionTargetPath('opencode', opencodePaths, localConfig); + const opencodeFile = opencodePaths && await 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)); @@ -1295,13 +1471,17 @@ export async function buildInstructionDeliveryChecks(ctx: DoctorContext): Promis }); } + // Pi's extension also carries the project's team rules (#946). + const { teamRulesContext } = await import('./resources/rules.js'); + let hasTeamRules: boolean | undefined; for (const hook of hooks) { const text = instructionHookText(blocks, hook.recall); - if (!text) continue; + if (!text && !getsRulesFromExtension(hook.tool)) continue; + if (!text && !(hasTeamRules ??= await teamRulesContext(teamConfig, localConfig) !== null)) 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`, + name: `${hook.tool} adds the team instructions${getsRulesFromExtension(hook.tool) ? ' and rules' : ''} to its prompt`, source: 'local', check: async () => channel.ready && overLimit === null, fix: channel.ready ? overLimit ?? '' : channel.fix, diff --git a/src/doctor.ts b/src/doctor.ts index a3fcf8ab0..a5a85abdb 100644 --- a/src/doctor.ts +++ b/src/doctor.ts @@ -20,7 +20,7 @@ import { } from './types.js'; import { isToolInstalledForConfig } from './resources/base.js'; import { skillsDirForTool } from './resources/skills.js'; -import { getsRulesFromSessionHook } from './resources/rule-format.js'; +import { isCodexTool } from './utils/tool-names.js'; import { TEAMAI_HOOK_SUBCOMMANDS, isCodexTrustGatedTool, codexTrustReminder, readCodexHookTrustForScope } from './hooks.js'; import { buildDeliveryChecks, @@ -262,6 +262,23 @@ async function buildHookChecks( }); continue; } + if (tool === 'openclaw') { + // OpenClaw has no settings file: its hook is a workspace hook directory, + // which OpenClaw loads only once openclaw.json enables its entry. + const { resolveOpenclawWorkspaceDir, isOpenclawHookEnabled, OPENCLAW_HOOK_DIR, OPENCLAW_HOOK_KEY } = await import('./openclaw-hooks.js'); + const workspace = await resolveOpenclawWorkspaceDir(); + if (!workspace || !await pathExists(path.join(workspace, 'hooks', OPENCLAW_HOOK_DIR))) continue; + checks.push({ + name: 'OpenClaw hook enabled', + source: 'local', + check: isOpenclawHookEnabled, + fix: `OpenClaw loads teamai's hook only when openclaw.json sets hooks.internal.entries.${OPENCLAW_HOOK_KEY}.enabled ` + + `(and internal hooks are not switched off), so OpenClaw sends no status report or sync. ` + + `Run \`openclaw hooks enable ${OPENCLAW_HOOK_KEY}\`. If it is enabled already, openclaw.json is not plain JSON ` + + `(comments, JSON5), which teamai cannot read: \`openclaw hooks info ${OPENCLAW_HOOK_KEY}\` shows what OpenClaw sees.`, + }); + continue; + } // A standalone hooks file (Copilot) is injected at the config's own scope // (`reconcileTeamHooksForConfig` joins resolveToolBaseDir with the // config-scoped `hooks`), so it is probed from `toolPaths`. Settings-based @@ -298,7 +315,10 @@ async function buildHookChecks( }, fix: 'Run `teamai hooks inject` to inject/update hooks', }); - if (getsRulesFromSessionHook(tool)) checks.push(sessionHookRulesCheck(tool, settingsPath)); + // ZCode and DeepSeek Harness get the rules from their session-start hook + // too, checked with the rules (`buildProjectRulesHookChecks`); only Codex + // has the SubagentStart entry and the context limit this check is about. + if (isCodexTool(tool)) checks.push(sessionHookRulesCheck(tool, settingsPath)); } return checks; } @@ -668,12 +688,14 @@ export async function doctor(options: DoctorOptions): Promise { // Info, not checks: which namespace item or entry replaces which root one // (#707), a model alias an agent uses from a namespace not active here, and // how each alias agent's model resolved in each tool (#830). + const { ruleChannelNotes } = await import('./resources/rules.js'); const notes = [ ...await buildNamespaceNotes(ctx), ...await entryNamespaceNotes(ctx), ...await aliasNamespaceNotes(ctx), ...await agentModelNotes(ctx), ...(await envAdvisories(localConfig, ctx.teamConfig, ctx.teamEnv)).map(describeEnvAdvisory), + ...await ruleChannelNotes(localConfig), ...codexTrust.notes, ]; diff --git a/src/dsh-hooks.ts b/src/dsh-hooks.ts index 92fefe5b6..4c093d72f 100644 --- a/src/dsh-hooks.ts +++ b/src/dsh-hooks.ts @@ -11,7 +11,7 @@ import path from 'node:path'; import { reconcileHooks } from './hooks.js'; import type { BuiltinHookOverride } from './builtin-hooks.js'; import type { HookDef } from './types.js'; -import { getUserHome } from './utils/home.js'; +import { expandHome, getUserHome } from './utils/home.js'; import { pathExists, remove, writeIfChanged } from './utils/fs.js'; import { log } from './utils/logger.js'; @@ -20,6 +20,23 @@ export const DSH_PATCH_FILE = 'cordis.patch.yml'; export const DSH_HOOK_PLUGIN_ID = 'teamai-hooks-claude-code'; export const DSH_HOOK_PLUGIN_PACKAGE = '@deepseek-ai/dsh-hooks-claude-code'; +/** + * DeepSeek Harness's own home, where it reads the user `AGENTS.md`: + * `DSH_HOME`, else `~/.dsh` (dsh `config.ts` `dshHome`). + */ +export function resolveDshHome(): string { + const configured = process.env.DSH_HOME?.trim(); + return configured ? path.resolve(expandHome(configured)) : path.join(getUserHome(), '.dsh'); +} + +/** + * Whether DeepSeek Harness is installed: its home (`resolveDshHome`) exists. + * The hook bridge, the user-scope rules file, doctor and init all ask here. + */ +export async function isDshInstalled(): Promise { + return pathExists(resolveDshHome()); +} + /** The TeamAI-managed DSH bridge directory under the resolved user home. */ export function resolveDshHooksDir(): string { return path.join(getUserHome(), '.teamai', 'dsh'); diff --git a/src/hook-handlers.ts b/src/hook-handlers.ts index 66498c12d..ca0f7d262 100644 --- a/src/hook-handlers.ts +++ b/src/hook-handlers.ts @@ -832,14 +832,15 @@ const secretsHintHandler: HookHandler = { }; /** - * SessionStart: a project's rules and instruction blocks (culture, shared - * instructions, recall) for a tool with no rules format and no project file of - * its own (the Codex family, #938, #945). The project AGENTS.md is the - * owners' file, and other tools read it too. User-scope content is in the - * tool's own AGENTS.md, so a session outside a project gets nothing here. - * Codex runs SessionStart again after a compaction or a clear; a resumed - * session already holds the content in its history. A subagent fires - * SubagentStart instead, which gets the same content. + * SessionStart: a project's rules for a tool with no rules format and no + * project file of its own (the Codex family, #938; ZCode and DeepSeek + * Harness, #946), and for the Codex family its instruction blocks (culture, + * shared instructions, recall, #945). The project AGENTS.md is the owners' + * file, and other tools read it too. User-scope content is in the tool's own + * AGENTS.md, so a session outside a project gets nothing here. Codex runs + * SessionStart again after a compaction or a clear; a resumed session already + * holds the content in its history. A Codex subagent fires SubagentStart + * instead, which gets the same content. */ const teamRulesHandler: HookHandler = { name: 'team-rules', @@ -851,9 +852,9 @@ const teamRulesHandler: HookHandler = { const { loadTeamConfig } = await import('./config.js'); const teamConfig = await loadTeamConfig(config.repo.localPath); if (!teamConfig) return null; - const { instructionHookTextFor } = await import('./instruction-targets.js'); + const { deliversInstructionsByHook, instructionHookTextFor } = await import('./instruction-targets.js'); const { teamRulesContext } = await import('./resources/rules.js'); - const text = await instructionHookTextFor(teamConfig, config, tool); + const text = deliversInstructionsByHook(tool, config.scope) ? await instructionHookTextFor(teamConfig, config, tool) : ''; const parts = text ? [text] : []; const rules = await teamRulesContext(teamConfig, config); if (rules !== null) parts.push(rules); @@ -866,9 +867,10 @@ 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. + * extension adds them to the prompt instead of reading a file (#945), and for + * Pi the project's team rules after them (#946). 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', @@ -881,8 +883,15 @@ const instructionsHandler: HookHandler = { 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 } }); + const parts = text ? [text] : []; + const { getsRulesFromExtension } = await import('./resources/rule-format.js'); + if (config.scope === 'project' && getsRulesFromExtension(tool)) { + const { teamRulesContext } = await import('./resources/rules.js'); + const rules = await teamRulesContext(teamConfig, config); + if (rules !== null) parts.push(rules); + } + if (parts.length === 0) return null; + return JSON.stringify({ hookSpecificOutput: { hookEventName: 'SessionStart', additionalContext: parts.join('\n\n') } }); }, }; diff --git a/src/hooks.ts b/src/hooks.ts index ac6caf17b..aa4dd69b0 100644 --- a/src/hooks.ts +++ b/src/hooks.ts @@ -227,7 +227,8 @@ export function isCodexTrustGatedTool(tool: string): boolean { * Shared with `doctor` (installed-hooks note) so the wording stays identical. */ export function codexTrustReminder(): string { - return 'Codex hooks written, but Codex may require you to review/trust them before they run โ€” open /hooks or Settings โ†’ Hooks in Codex to trust them.'; + return 'Codex hooks written, but Codex may require you to review/trust them before they run โ€” open /hooks or Settings โ†’ Hooks in Codex to trust them. ' + + 'Until you do, a project\'s team rules do not reach Codex: they come through those hooks.'; } /** @@ -1879,9 +1880,10 @@ export async function reconcileHooksToAllTools( if (opts.settingsOnly) continue; try { if (opts.removeAll) { - const { removeOpenClawHooks, resolveOpenclawWorkspaceDir } = await import('./openclaw-hooks.js'); + const { removeOpenClawHooks, removeOpenClawHookEntry, resolveOpenclawWorkspaceDir } = await import('./openclaw-hooks.js'); const wsDir = await resolveOpenclawWorkspaceDir(); if (wsDir) await removeOpenClawHooks(path.join(wsDir, 'hooks')); + await removeOpenClawHookEntry(); } else { const { injectOpenClawHooks } = await import('./openclaw-hooks.js'); await injectOpenClawHooks(undefined, tool); @@ -1932,9 +1934,8 @@ export async function reconcileHooksToAllTools( if (tool === 'dsh') { if (opts.settingsOnly) continue; try { - const dshHome = getUserHome(); - if (opts.removeAll || await pathExists(path.join(dshHome, '.dsh'))) { - const { reconcileDshHooks, resolveDshHookConfigPath } = await import('./dsh-hooks.js'); + const { isDshInstalled, reconcileDshHooks, resolveDshHookConfigPath } = await import('./dsh-hooks.js'); + if (opts.removeAll || await isDshInstalled()) { if (await skipInstalled(resolveDshHookConfigPath(), 'dsh')) continue; await reconcileDshHooks(defs, { manifestPath: teamManifestPath, diff --git a/src/init.ts b/src/init.ts index 045b95ff3..6975c9123 100644 --- a/src/init.ts +++ b/src/init.ts @@ -781,7 +781,8 @@ export async function initHttp( /** * Install the hooks for a fresh init. When the team hooks do not resolve, the * built-in hooks are still installed; say that the team hooks were not, so the - * success line that follows does not claim them. + * success line that follows does not claim them. Then name each installed tool + * that gets no rules in this scope, and why (#946). */ async function reconcileHooksForInit( teamConfig: TeamaiConfig, @@ -809,6 +810,10 @@ async function reconcileHooksForInit( } catch (e) { log.debug(`Team instruction check skipped: ${(e as Error).message}`); } + // A tool with no rules channel in this scope is told so here, not left to + // look delivered (#946). + const { ruleChannelNotes } = await import('./resources/rules.js'); + for (const note of await ruleChannelNotes(localConfig)) log.info(note); } /** diff --git a/src/instruction-targets.ts b/src/instruction-targets.ts index 83892970d..7731e5595 100644 --- a/src/instruction-targets.ts +++ b/src/instruction-targets.ts @@ -1,5 +1,6 @@ import path from 'node:path'; import { isToolInstalledForConfig } from './resources/base.js'; +import { getsRulesFromExtension } from './resources/rule-format.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'; @@ -39,13 +40,21 @@ import { type ToolPaths = TeamaiConfig['toolPaths'][string]; +/** + * A file of a tool, relative to the tool's base dir for the scope + * (`resolveToolBaseDir`) or absolute; undefined when it has none. + */ +type ToolFile = (paths: ToolPaths) => string | undefined | Promise; + interface TargetEntry { + /** The file this tool reads the blocks from in this scope. */ + readonly file: ToolFile; /** - * 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. + * For a tool with no rules format, in user scope: the file it reads the + * team rules from (#938, #946), the blocks' `file` unless `file` is given, + * and how doctor names it. */ - readonly file: (paths: ToolPaths) => string | undefined; + readonly teamRules?: { readonly label: string; readonly file?: ToolFile }; /** 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. */ @@ -66,6 +75,26 @@ interface TargetEntry { /** The tool's `claudemd` path from the team's `toolPaths` (honors `toolRoots`). */ const configured = (paths: ToolPaths): string | undefined => paths.claudemd; +/** + * OpenClaw's workspace AGENTS.md, in the workspace its hooks resolve + * (`resolveOpenclawWorkspaceDir`); none when no workspace resolves, which is + * also what says OpenClaw is not installed here (`isInstructionToolInstalled`). + */ +const openclawWorkspace = async (): Promise => { + const { resolveOpenclawWorkspace } = await import('./openclaw-hooks.js'); + const workspace = await resolveOpenclawWorkspace(); + // An openclaw.json teamai cannot read may name another workspace: guess none (#946). + return workspace.kind === 'found' ? path.join(workspace.dir, 'AGENTS.md') : undefined; +}; + +/** DeepSeek Harness reads `$DSH_HOME/AGENTS.md` (`~/.dsh` by default) in its first request. */ +const dshAgentsMd = async (): Promise => { + const { resolveDshHome } = await import('./dsh-hooks.js'); + return path.join(resolveDshHome(), 'AGENTS.md'); +}; + +const codexUser: TargetEntry = { file: configured, teamRules: { label: 'Codex AGENTS.md' }, retired: [] }; + /** * 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 @@ -105,11 +134,24 @@ const USER_TARGETS: Readonly> = { // 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: [] }, + // Pi reads no user rules directory: the team rules sit beside the blocks. + pi: { file: configured, teamRules: { label: 'Pi AGENTS.md' }, retired: [] }, + // $CODEX_HOME/AGENTS.md (a `toolRoots` entry moves the claudemd path). + codex: codexUser, + 'codex-internal': codexUser, + tcodex: codexUser, + // ZCode reads ~/.zcode/AGENTS.md as its user context; teamai writes it only the team rules. + zcode: { file: () => undefined, teamRules: { label: 'ZCode AGENTS.md', file: () => '.zcode/AGENTS.md' }, retired: [] }, + dsh: { file: () => undefined, teamRules: { label: 'DeepSeek Harness AGENTS.md', file: dshAgentsMd }, retired: [] }, + // JoyCode reads its user rules from one plain text file. + joycode: { file: () => undefined, teamRules: { label: 'JoyCode rules.txt', file: () => '.joycode/rules.txt' }, 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: [] }, + // A profile or OPENCLAW_WORKSPACE_DIR moves the workspace off the default + // path. The default workspace is not retired: OpenClaw's default profile + // still reads it. + openclaw: { file: openclawWorkspace, teamRules: { label: 'OpenClaw workspace AGENTS.md' }, 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: [] }, }; @@ -135,7 +177,9 @@ const PROJECT_TARGETS: Readonly> = { 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: [] }, + // OpenClaw's only project file is the shared AGENTS.md; it reads no + // project .openclaw/workspace (#946). + openclaw: { file: () => undefined, retired: ['.openclaw/workspace/AGENTS.md'] }, // Codex reads no project file only it reads; its session-start and // subagent-start hooks add the blocks (#938, #940). codex: codexHook, @@ -215,7 +259,7 @@ export interface InstructionBlocks { * 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 { +export async function instructionTargetFile(tool: string, paths: ToolPaths, scope: Scope): Promise { return (entryFor(tool, scope)?.file ?? configured)(paths); } @@ -224,10 +268,12 @@ export function instructionTargetFile(tool: string, paths: ToolPaths, scope: Sco * `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[] { +export async function retiredInstructionFiles(tool: string, paths: ToolPaths, scope: Scope): Promise { const entry = entryFor(tool, scope); if (!entry) return []; - const previous = paths.claudemd === instructionTargetFile(tool, paths, scope) ? undefined : paths.claudemd; + // OpenClaw's default workspace stays read by its default profile (#946). + if (tool === 'openclaw') return entry.retired; + const previous = paths.claudemd === await instructionTargetFile(tool, paths, scope) ? undefined : paths.claudemd; return previous === undefined || entry.retired.includes(previous) ? entry.retired : [...entry.retired, previous]; } @@ -298,7 +344,9 @@ export async function instructionHookChannel( 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}`, + // Pi's extension also carries a project's team rules (#946); OMP reads its own rules files. + fix: `${file} is missing or out of date, so ${tool} sessions in this project get no team instructions` + + `${getsRulesFromExtension(tool) ? ' or rules' : ''}. ${rerun}`, }; } if (tool === 'hermes') { @@ -386,26 +434,80 @@ function managedBlockBody(block: string): string { } /** Absolute instruction file of `tool` in the active scope, or undefined when it takes none. */ -export function instructionTargetPath( +export async function instructionTargetPath( tool: string, paths: ToolPaths, localConfig: LocalConfig, -): string | undefined { - const file = instructionTargetFile(tool, paths, localConfig.scope); +): Promise { + const file = await instructionTargetFile(tool, paths, localConfig.scope); return file === undefined ? undefined : path.resolve(resolveToolBaseDir(tool, localConfig), file); } +/** The file a tool with no rules format reads the team rules from in user scope (`userRulesFile`). */ +export interface UserRulesFile { + /** Absolute; undefined when the tool has none here (the team's `toolPaths` gives it no `claudemd`, or no OpenClaw workspace resolves). */ + readonly file: string | undefined; + /** Whether the tool is installed, probed the way its instruction blocks are, so pull writes the block. */ + readonly installed: boolean; + /** How doctor names the file: "Team rules are inlined in