Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/guide/sharing.md
Original file line number Diff line number Diff line change
Expand Up @@ -231,6 +231,8 @@ ZCode servers use `mcp.servers` in `~/.zcode/cli/config.json` or the project's `

WorkBuddy uses the first existing user file in `~/.workbuddy/.mcp.json`, `~/.workbuddy/mcp.json`, and `~/.codebuddy.json`. `CODEBUDDY_CONFIG_DIR` moves the last file. A project uses `.mcp.json`, or `mcp.json` when only that exists. With `sharing.gitExclude` on, its servers use `projects[<project path>].mcpServers` in `.codebuddy.json`. These paths follow the CodeBuddy 2.168.0 engine; WorkBuddy's launcher binding is not certified. Pull removes only unchanged teamai entries from the former `.workbuddy/mcp.json` project file.

When `.workbuddy-ai/` exists in the home or project (WorkBuddy 5.7+), the default skills path moves to `.workbuddy-ai/skills`. Every other surface still waits for `.workbuddy/`, so a member with only `.workbuddy-ai/` gets no rules, team instructions, agents, MCP servers or hooks, and `teamai doctor` fails `workbuddy receives ...` with the ones held.

When a tool's home or configured MCP path moves, pull leaves old copies in place and names them once. Remove them after no tool reads them. Doctor reports unread mappings, moved files, and project MCP that is read only from a session started at the project root. Internal builds with an unknown MCP channel get a note instead of a guessed config.

In a workspace with repos set up inside it, Claude Code, CodeBuddy, and WorkBuddy get the servers through their local scope, not the project's MCP file, so a session in one of those repos does not load them. In single-repo mode, or while tclaude or Muse Code reads that file, Claude Code's servers stay in it, and such a session does load them.
Expand Down
2 changes: 2 additions & 0 deletions docs/guide/zh-CN/sharing.md
Original file line number Diff line number Diff line change
Expand Up @@ -231,6 +231,8 @@ ZCode 的 server 写入 `~/.zcode/cli/config.json` 或项目的 `.zcode/config.j

WorkBuddy 按顺序使用第一个已存在的用户文件:`~/.workbuddy/.mcp.json`、`~/.workbuddy/mcp.json`、`~/.codebuddy.json`。`CODEBUDDY_CONFIG_DIR` 会移动最后一个文件。项目使用 `.mcp.json`,仅有 `mcp.json` 时使用后者。开启 `sharing.gitExclude` 后,server 写入 `.codebuddy.json` 的 `projects[<项目路径>].mcpServers`。这些路径基于 CodeBuddy 2.168.0 引擎,尚未认证 WorkBuddy 启动器的主目录绑定。Pull 只从旧项目文件 `.workbuddy/mcp.json` 移除未被修改的 teamai 条目。

主目录或项目中存在 `.workbuddy-ai/` 时(WorkBuddy 5.7+),默认技能路径改为 `.workbuddy-ai/skills`。其他内容仍要等 `.workbuddy/` 存在才写入,所以只有 `.workbuddy-ai/` 的成员收不到规则、团队指令、agent、MCP server 和钩子,`teamai doctor` 会以 `workbuddy receives ...` 失败并列出被搁置的项。

工具主目录或配置的 MCP 路径改变时,pull 保留旧副本并提示一次。确认没有工具读取后再删除。Doctor 会报告工具不读取的映射、已移动的文件,以及仅在项目根目录启动会话时才读取的项目 MCP。MCP 通道未知的内部版本只显示提示,不猜测配置路径。

在包含已初始化仓库的 workspace 中,Claude Code、CodeBuddy 和 WorkBuddy 通过各自的 local scope 而不是项目 MCP 文件获得这些 server,所以在其中某个仓库里启动的会话不会加载它们。在单仓库模式下,或 tclaude、Muse Code 读取该文件时,Claude Code 的 server 仍留在其中,这样的会话会加载它们。
Expand Down
6 changes: 5 additions & 1 deletion skill-data/core/references/troubleshooting.md
Original file line number Diff line number Diff line change
Expand Up @@ -269,7 +269,7 @@ broken machine):
| Codex | Installed and trusted | Codex runs only trusted hooks; teamai trusts the ones it writes through `codex app-server`, and `teamai doctor` names any Codex will not run |
| Cursor | Installed | Also runs `~/.claude/settings.json`. That copy exits only when `~/.cursor/hooks.json` or the project `.cursor/hooks.json` contains `--tool cursor` |
| Copilot CLI | Installed | Built-ins use `$COPILOT_HOME/hooks/teamai.json` (default `~/.copilot/hooks/teamai.json`); self mode keeps them in the project. Claude built-ins exit for a valid, enabled version-1 file, global or project, with a `--tool copilot` command; self mode checks only the project directory. A Claude team hook exits only when a valid, enabled project version-1 file delivers the same command to Copilot for the same event and matcher. `COPILOT_CLI` alone does not skip |
| CodeBuddy / WorkBuddy | Installed | Claude-format hooks in their own `settings.json` |
| CodeBuddy / WorkBuddy | Installed | Claude-format hooks in their own `settings.json`; none on a WorkBuddy with only `.workbuddy-ai/` (see WorkBuddy below) |

Practical rule: if you set up with `--agent claude`, expect **only** Claude to show
hooks installed. A tool you are not using, or one that is not a supported hook
Expand Down Expand Up @@ -335,6 +335,10 @@ hook entry; if unsure of the exact config, run `teamai doctor` and `teamai hooks
to see what should be present, then have them replicate it. Until then, they can
sync with a manual `teamai pull`.

Skip this on WorkBuddy 5.7+ with only `~/.workbuddy-ai/`. teamai does not know yet
where 5.7 reads hooks, so it writes none, and `teamai doctor` names them under
`workbuddy receives ...`. Do not hand-register a hook there; sync with `teamai pull`.

### Muse Code

Hooks go in the `hooks` block of `~/.config/muse/settings.json`, which Muse runs in every workspace; a project's `.muse/hooks.json` runs only once the workspace is trusted. Muse starts each hook with an emptied environment, so a variable set only on the command that launched `muse` (such as `TEAMAI_HOOKS_DISABLED=1 muse`) does not reach the hook. The hook command runs `bash -lc`, so export it in `~/.bash_profile` or `~/.profile` instead.
Expand Down
2 changes: 2 additions & 0 deletions skill-data/setup/references/manage-admin.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,8 @@ ZCode servers use `mcp.servers` in `~/.zcode/cli/config.json` or the project's `

WorkBuddy uses the first existing user file in `~/.workbuddy/.mcp.json`, `~/.workbuddy/mcp.json`, and `~/.codebuddy.json`. `CODEBUDDY_CONFIG_DIR` moves the last file. A project uses `.mcp.json`, or `mcp.json` when only that exists. With `sharing.gitExclude` on, its servers use `projects[<project path>].mcpServers` in `.codebuddy.json`. These paths follow the CodeBuddy 2.168.0 engine; WorkBuddy's launcher binding is not certified. Pull removes only unchanged teamai entries from the former `.workbuddy/mcp.json` project file.

When `.workbuddy-ai/` exists in the home or project (WorkBuddy 5.7+), the default skills path moves to `.workbuddy-ai/skills`. Every other surface still waits for `.workbuddy/`, so a member with only `.workbuddy-ai/` gets no rules, team instructions, agents, MCP servers or hooks, and `teamai doctor` fails `workbuddy receives ...` with the ones held.

When a tool's home or configured MCP path moves, pull leaves old copies in place and names them once. Remove them after no tool reads them. Doctor reports unread mappings, moved files, and project MCP that is read only from a session started at the project root. Internal builds with an unknown MCP channel get a note instead of a guessed config.

### Pi MCP delivery
Expand Down
146 changes: 146 additions & 0 deletions src/__tests__/locate-workbuddy.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
/**
* The WorkBuddy generation swap inside `locate` (#1273): WorkBuddy 5.7+ moved
* its runtime to `.workbuddy-ai/` and stops reading `.workbuddy/`, so when the
* new root is the live generation, skills must land where the app scans and
* the new root must count as install proof. These run against the in-memory
* `Host` (no filesystem), mirroring locate-matrix's style.
*/
import path from 'node:path';
import { describe, expect, it } from 'vitest';
import { installOf, locate, ownOf, writable } from '../locate.js';
import { DEFAULT_TOOL_PATHS } from '../types.js';
import type { Host } from '../tool-home.js';

const HOME = '/home/member';
const ROOT = '/repo/project';

function hostWith(existing: readonly string[]): Host {
const set = new Set(existing);
return {
platform: 'linux', path: path.posix, home: HOME,
env: {}, toolHomes: { kind: 'missing' },
cwd: () => ROOT,
exists: async (dir: string) => set.has(dir),
realpath: async (file: string) => file,
readText: async () => null,
readJson: async () => ({ kind: 'missing' }),
userToolRoots: async () => undefined,
};
}

const DEFAULT_PATHS = { workbuddy: { skills: '.workbuddy/skills' } };

describe('locate carries the WorkBuddy 5.7+ generation (#1273)', () => {
it('project scope, only .workbuddy-ai: writes and probes the new root', async () => {
const host = hostWith([`${ROOT}/.workbuddy-ai`]);
const install = installOf({ toolPaths: DEFAULT_PATHS }, { scope: 'project', projectRoot: ROOT, repo: {} });
const where = await locate('workbuddy', 'skills', install, host);
expect(ownOf(where)?.path).toBe(`${ROOT}/.workbuddy-ai/skills`);
expect(where.gates).toContainEqual({ kind: 'installed', probe: `${ROOT}/.workbuddy-ai`, met: true });
});

it('project scope, both roots: the new generation wins (#1290)', async () => {
const host = hostWith([`${ROOT}/.workbuddy`, `${ROOT}/.workbuddy-ai`]);
const install = installOf({ toolPaths: DEFAULT_PATHS }, { scope: 'project', projectRoot: ROOT, repo: {} });
const where = await locate('workbuddy', 'skills', install, host);
expect(ownOf(where)?.path).toBe(`${ROOT}/.workbuddy-ai/skills`);
});

it('project scope, only the old root: keeps the old default and probes it', async () => {
const host = hostWith([`${ROOT}/.workbuddy`]);
const install = installOf({ toolPaths: DEFAULT_PATHS }, { scope: 'project', projectRoot: ROOT, repo: {} });
const where = await locate('workbuddy', 'skills', install, host);
expect(ownOf(where)?.path).toBe(`${ROOT}/.workbuddy/skills`);
expect(where.gates).toContainEqual({ kind: 'installed', probe: `${ROOT}/.workbuddy`, met: true });
});

it('project scope, neither root: the install gate stays closed', async () => {
const host = hostWith([]);
const install = installOf({ toolPaths: DEFAULT_PATHS }, { scope: 'project', projectRoot: ROOT, repo: {} });
const where = await locate('workbuddy', 'skills', install, host);
expect(where.gates).toContainEqual({ kind: 'installed', probe: `${ROOT}/.workbuddy`, met: false });
expect(writable(where)).toBeNull();
});

it('a team that redirected skills keeps its own path beside the new root', async () => {
const host = hostWith([`${ROOT}/.workbuddy-ai`]);
const install = installOf({ toolPaths: { workbuddy: { skills: 'custom/skills' } } }, { scope: 'project', projectRoot: ROOT, repo: {} });
const where = await locate('workbuddy', 'skills', install, host);
expect(ownOf(where)?.path).toBe(`${ROOT}/custom/skills`);
});

it('user scope, only .workbuddy-ai under $HOME: writes and probes the new root', async () => {
const host = hostWith([`${HOME}/.workbuddy-ai`]);
const install = installOf({ toolPaths: DEFAULT_PATHS }, { scope: 'user', repo: {} });
const where = await locate('workbuddy', 'skills', install, host);
expect(ownOf(where)?.path).toBe(`${HOME}/.workbuddy-ai/skills`);
expect(where.gates).toContainEqual({ kind: 'installed', probe: `${HOME}/.workbuddy-ai`, met: true });
});

it('user scope, only the old root under $HOME: keeps the old default', async () => {
const host = hostWith([`${HOME}/.workbuddy`]);
const install = installOf({ toolPaths: DEFAULT_PATHS }, { scope: 'user', repo: {} });
const where = await locate('workbuddy', 'skills', install, host);
expect(ownOf(where)?.path).toBe(`${HOME}/.workbuddy/skills`);
});
});

describe('only skills accept the new root as install proof (#1290)', () => {
// Only skills have verified 5.7+ delivery evidence (the 13-skill `/` menu
// check in #1273). Every other surface keeps requiring `.workbuddy/`, so a
// 5.7-only member fails closed instead of writing into the dead layout.
const PATHS = {
workbuddy: {
skills: '.workbuddy/skills',
rules: '.codebuddy/rules',
mcp: '.workbuddy/mcp.json',
mcpProject: '.mcp.json',
},
};

it('rules stay fail-closed when only .workbuddy-ai exists', async () => {
const host = hostWith([`${ROOT}/.workbuddy-ai`]);
const install = installOf({ toolPaths: PATHS }, { scope: 'project', projectRoot: ROOT, repo: {} });
const where = await locate('workbuddy', 'rules', install, host);
expect(where.gates).toContainEqual({ kind: 'installed', probe: `${ROOT}/.workbuddy`, met: false });
expect(writable(where)).toBeNull();
});

it('mcp stays fail-closed when only .workbuddy-ai exists', async () => {
const host = hostWith([`${ROOT}/.workbuddy-ai`]);
const install = installOf({ toolPaths: PATHS }, { scope: 'project', projectRoot: ROOT, repo: {} });
const where = await locate('workbuddy', 'mcp', install, host);
expect(where.gates).toContainEqual({ kind: 'installed', probe: `${ROOT}/.workbuddy`, met: false });
expect(writable(where)).toBeNull();
});

it('rules keep working from the old root alone (no regression)', async () => {
const host = hostWith([`${ROOT}/.workbuddy`]);
const install = installOf({ toolPaths: PATHS }, { scope: 'project', projectRoot: ROOT, repo: {} });
const where = await locate('workbuddy', 'rules', install, host);
expect(where.gates).toContainEqual({ kind: 'installed', probe: `${ROOT}/.workbuddy`, met: true });
expect(writable(where)).not.toBeNull();
});

it('user scope, .workbuddy-ai-only home: a team rule and an MCP server find no target, so no .workbuddy/ is created (#1290)', async () => {
// The reviewer's exact scenario: every surface configured (rules and an
// MCP server, via the default entry that carries them all), the home
// holding only the 5.7+ root. Rules and MCP stay fail-closed — pull
// finds no writable target for either, so nothing ever creates
// `.workbuddy/` — while skills still take the new root on the same
// member.
const host = hostWith([`${HOME}/.workbuddy-ai`]);
const install = installOf({ toolPaths: DEFAULT_TOOL_PATHS }, { scope: 'user', repo: {} });
// Two explicit calls, not a loop: `writable()` narrows by resource, so a
// union-typed `Location<'mcp' | 'rules'>` does not type-check.
const rules = await locate('workbuddy', 'rules', install, host);
expect(rules.gates).toContainEqual({ kind: 'installed', probe: `${HOME}/.workbuddy`, met: false });
expect(writable(rules)).toBeNull();
const mcp = await locate('workbuddy', 'mcp', install, host);
expect(mcp.gates).toContainEqual({ kind: 'installed', probe: `${HOME}/.workbuddy`, met: false });
expect(writable(mcp)).toBeNull();
const skills = await locate('workbuddy', 'skills', install, host);
expect(ownOf(skills)?.path).toBe(`${HOME}/.workbuddy-ai/skills`);
expect(writable(skills)).not.toBeNull();
});
});
Loading
Loading