diff --git a/docs/guide/sharing.md b/docs/guide/sharing.md index 68e97a9e2..6af80bddb 100644 --- a/docs/guide/sharing.md +++ b/docs/guide/sharing.md @@ -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[].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. diff --git a/docs/guide/zh-CN/sharing.md b/docs/guide/zh-CN/sharing.md index af8bb832f..4efd3b622 100644 --- a/docs/guide/zh-CN/sharing.md +++ b/docs/guide/zh-CN/sharing.md @@ -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 仍留在其中,这样的会话会加载它们。 diff --git a/skill-data/core/references/troubleshooting.md b/skill-data/core/references/troubleshooting.md index 4334b8b51..2e03dff49 100644 --- a/skill-data/core/references/troubleshooting.md +++ b/skill-data/core/references/troubleshooting.md @@ -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 @@ -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. diff --git a/skill-data/setup/references/manage-admin.md b/skill-data/setup/references/manage-admin.md index e5109ad2a..bf4e0955e 100644 --- a/skill-data/setup/references/manage-admin.md +++ b/skill-data/setup/references/manage-admin.md @@ -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[].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 diff --git a/src/__tests__/locate-workbuddy.test.ts b/src/__tests__/locate-workbuddy.test.ts new file mode 100644 index 000000000..fe75622b8 --- /dev/null +++ b/src/__tests__/locate-workbuddy.test.ts @@ -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(); + }); +}); diff --git a/src/__tests__/workbuddy-generation.test.ts b/src/__tests__/workbuddy-generation.test.ts new file mode 100644 index 000000000..d8cb1e788 --- /dev/null +++ b/src/__tests__/workbuddy-generation.test.ts @@ -0,0 +1,281 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import fse from 'fs-extra'; +import os from 'node:os'; +import path from 'node:path'; + +vi.mock('../config.js', async (importOriginal) => ({ + ...(await importOriginal()), + detectProjectConfig: vi.fn().mockResolvedValue(null), + loadLocalConfig: vi.fn(), + loadTeamConfig: vi.fn(), + loadStateForScope: vi.fn().mockResolvedValue({}), +})); + +vi.mock('../utils/logger.js', () => ({ + log: { + debug: vi.fn(), error: vi.fn(), info: vi.fn(), success: vi.fn(), warn: vi.fn(), dim: vi.fn(), + }, + setStderrOnly: vi.fn(), + isSilent: () => false, + setSilent: vi.fn(), +})); + +import { loadLocalConfig, loadTeamConfig } from '../config.js'; +import { buildChecks, resolveDoctorContext, type Check } from '../doctor.js'; +import { isToolInstalledForConfig } from '../resources/base.js'; +import { rulesReceivingTools } from '../resources/rules.js'; +import { DEFAULT_TOOL_PATHS, scopedToolPaths } from '../types.js'; +import type { LocalConfig, TeamaiConfig } from '../types.js'; + +/** + * WorkBuddy 5.7+ moved its runtime to `~/.workbuddy-ai/` (settings.json, + * plugins/, skills/ …) and stopped reading `~/.workbuddy/` — the old root is a + * leftover (#1273). The install probe must recognize both generations, or a + * 5.7+ member looks uninstalled and teamai integrates nothing. + */ +describe('WorkBuddy install probe across generations (#1273)', () => { + let root: string; + let home: string; + let projectRoot: string; + + beforeEach(async () => { + root = await fse.mkdtemp(path.join(os.tmpdir(), 'teamai-workbuddy-detect-')); + home = path.join(root, 'home'); + projectRoot = path.join(root, 'project'); + vi.stubEnv('HOME', home); + vi.stubEnv('USERPROFILE', home); + await fse.ensureDir(projectRoot); + }); + + afterEach(async () => { + vi.unstubAllEnvs(); + await fse.remove(root); + }); + + const projectConfig = (): LocalConfig => ({ + repo: { localPath: path.join(root, 'repo'), remote: 'fixture' }, + username: 'tester', scope: 'project', projectRoot, additionalRoles: [], + }); + + it('finds old-generation WorkBuddy by .workbuddy at the base dir', async () => { + await fse.ensureDir(path.join(projectRoot, '.workbuddy')); + expect(await isToolInstalledForConfig('workbuddy', '.workbuddy/skills', projectConfig())).toBe(true); + }); + + it('does not treat .workbuddy-ai alone as installed outside the skills surface (#1290)', async () => { + await fse.ensureDir(path.join(projectRoot, '.workbuddy-ai')); + expect(await isToolInstalledForConfig('workbuddy', '.workbuddy/skills', projectConfig())).toBe(false); + }); + + it('takes the old root as proof when both generations left directories behind', async () => { + await fse.ensureDir(path.join(projectRoot, '.workbuddy')); + await fse.ensureDir(path.join(projectRoot, '.workbuddy-ai')); + expect(await isToolInstalledForConfig('workbuddy', '.workbuddy/skills', projectConfig())).toBe(true); + }); + + it('does not take a bare .codebuddy directory for WorkBuddy', async () => { + await fse.ensureDir(path.join(projectRoot, '.codebuddy')); + expect(await isToolInstalledForConfig('workbuddy', '.codebuddy/rules', projectConfig())).toBe(false); + }); +}); + +/** + * The skills write side (#1273): on an install that left only the 5.7+ + * `.workbuddy-ai/` root, skills must land where the app actually scans — + * live-verified in the issue (13 team skills showed in the `/` menu). Only + * the default `.workbuddy/skills` swaps: a team that redirected skills on + * purpose keeps its own path. + */ +describe('scopedToolPaths swaps WorkBuddy skills to the 5.7+ root (#1273)', () => { + let root: string; + let home: string; + + beforeEach(async () => { + root = await fse.mkdtemp(path.join(os.tmpdir(), 'teamai-wb-paths-')); + home = path.join(root, 'home'); + vi.stubEnv('HOME', home); + vi.stubEnv('USERPROFILE', home); + }); + afterEach(async () => { + vi.unstubAllEnvs(); + await fse.remove(root); + }); + + const defaultTeam = { toolPaths: { workbuddy: { skills: '.workbuddy/skills' } } }; + const paths = (team: unknown, scope: 'user' | 'project' = 'user', projectRoot?: string) => + scopedToolPaths(team as never, { scope, ...(projectRoot ? { projectRoot } : {}) }); + + it('delivers to .workbuddy-ai/skills when only the 5.7+ root exists', async () => { + await fse.ensureDir(path.join(home, '.workbuddy-ai')); + expect(paths(defaultTeam).workbuddy.skills).toBe('.workbuddy-ai/skills'); + }); + + it('prefers the new root when an upgrade left both roots behind', async () => { + await fse.ensureDir(path.join(home, '.workbuddy')); + await fse.ensureDir(path.join(home, '.workbuddy-ai')); + expect(paths(defaultTeam).workbuddy.skills).toBe('.workbuddy-ai/skills'); + }); + + it('keeps the old path when neither root exists (nothing detected)', () => { + expect(paths(defaultTeam).workbuddy.skills).toBe('.workbuddy/skills'); + }); + + it('never stomps a team that redirected skills on purpose', async () => { + await fse.ensureDir(path.join(home, '.workbuddy-ai')); + const redirected = { toolPaths: { workbuddy: { skills: '.workbuddy-ai/skills' } } }; + expect(paths(redirected).workbuddy.skills).toBe('.workbuddy-ai/skills'); + const elsewhere = { toolPaths: { workbuddy: { skills: 'custom/skills' } } }; + expect(paths(elsewhere).workbuddy.skills).toBe('custom/skills'); + }); + + it('keeps the swap after the user-scope override merges the default path', async () => { + await fse.ensureDir(path.join(home, '.workbuddy-ai')); + const scoped = { + toolPaths: { + workbuddy: { skills: '.workbuddy/skills', userScope: { skills: '.workbuddy/skills' } }, + }, + }; + expect(paths(scoped).workbuddy.skills).toBe('.workbuddy-ai/skills'); + }); + + it('never stomps a userScope skills redirect of its own', async () => { + await fse.ensureDir(path.join(home, '.workbuddy-ai')); + const scoped = { + toolPaths: { + workbuddy: { skills: '.workbuddy/skills', userScope: { skills: 'custom/skills' } }, + }, + }; + expect(paths(scoped).workbuddy.skills).toBe('custom/skills'); + }); + + it('swaps against the project root in project scope', async () => { + const projectRoot = path.join(root, 'project'); + await fse.ensureDir(path.join(projectRoot, '.workbuddy-ai')); + expect(paths(defaultTeam, 'project', projectRoot).workbuddy.skills).toBe('.workbuddy-ai/skills'); + }); +}); + +/** + * Doctor on a member whose only WorkBuddy root is `.workbuddy-ai/` (#1273): + * skills arrive there, while rules, instructions, agents, MCP servers and + * hooks wait for `.workbuddy/`, so pull writes none of them. Doctor must say + * so instead of passing. + */ +describe('doctor on a WorkBuddy member with only the 5.7+ root', () => { + let root: string; + let home: string; + let teamConfig: TeamaiConfig; + + beforeEach(async () => { + root = await fse.mkdtemp(path.join(os.tmpdir(), 'teamai-wb-doctor-')); + home = path.join(root, 'home'); + vi.stubEnv('HOME', home); + vi.stubEnv('USERPROFILE', home); + const localConfig: LocalConfig = { + repo: { localPath: path.join(root, 'repo'), remote: 'owner/repo' }, + username: 'tester', scope: 'user', additionalRoles: [], enabledAgents: ['workbuddy'], + }; + teamConfig = { + team: 'test', description: '', repo: 'owner/repo', provider: 'git', reviewers: [], + sharing: { skills: {}, rules: { enforced: [] }, docs: { localDir: '' }, env: { injectShellProfile: false } }, + toolPaths: { workbuddy: DEFAULT_TOOL_PATHS.workbuddy }, + }; + vi.mocked(loadLocalConfig).mockResolvedValue(localConfig); + vi.mocked(loadTeamConfig).mockResolvedValue(teamConfig); + }); + + afterEach(async () => { + vi.unstubAllEnvs(); + await fse.remove(root); + }); + + async function checks(): Promise { + const ctx = await resolveDoctorContext(); + if (!ctx) throw new Error('expected a resolved doctor context'); + return buildChecks(ctx); + } + + it('fails a check naming the surfaces pull holds and the root they wait for', async () => { + await fse.ensureDir(path.join(home, '.workbuddy-ai')); + const all = await checks(); + + const installed = all.find((c) => c.name === 'workbuddy is installed'); + expect(await installed?.check()).toBe(true); + const held = all.filter((c) => c.name.startsWith('workbuddy receives')); + expect(held).toHaveLength(1); + expect(await held[0].check()).toBe(false); + for (const surface of ['rules', 'agents', 'MCP servers', 'hooks']) expect(held[0].name).toContain(surface); + expect(held[0].fix).toContain(path.join(home, '.workbuddy-ai', 'skills')); + expect(held[0].fix).toContain(`${path.join(home, '.workbuddy')} exists`); + }); + + it('names hooks when the team maps WorkBuddy to skills and settings only', async () => { + await fse.ensureDir(path.join(home, '.workbuddy-ai')); + vi.mocked(loadTeamConfig).mockResolvedValue({ + ...teamConfig, toolPaths: { workbuddy: { skills: '.workbuddy/skills', settings: '.workbuddy/settings.json' } }, + }); + const held = (await checks()).filter((c) => c.name.startsWith('workbuddy receives')); + + expect(held.map((c) => c.name)).toEqual(['workbuddy receives hooks']); + expect(await held[0].check()).toBe(false); + expect(held[0].fix).toContain(`${path.join(home, '.workbuddy')} exists`); + }); + + it('has nothing to report while the old root is there', async () => { + await fse.ensureDir(path.join(home, '.workbuddy')); + expect((await checks()).map((c) => c.name).filter((name) => name.startsWith('workbuddy receives'))).toEqual([]); + }); +}); + +/** + * Pull reports rules as synced only when some tool takes them (#1273): none + * on a member whose only WorkBuddy root is `.workbuddy-ai/`, while a + * single-file target such as Codex's `AGENTS.md` counts like a rules + * directory, and a session hook or extension counts like a file. + */ +describe('rulesReceivingTools', () => { + let root: string; + let home: string; + + beforeEach(async () => { + root = await fse.mkdtemp(path.join(os.tmpdir(), 'teamai-wb-rules-')); + home = path.join(root, 'home'); + vi.stubEnv('HOME', home); + vi.stubEnv('USERPROFILE', home); + }); + + afterEach(async () => { + vi.unstubAllEnvs(); + await fse.remove(root); + }); + + const localConfig = (): LocalConfig => ({ + repo: { localPath: path.join(root, 'repo'), remote: 'owner/repo' }, + username: 'tester', scope: 'user', additionalRoles: [], + }); + const teamConfig = (toolPaths: TeamaiConfig['toolPaths']): TeamaiConfig => ({ + team: 'test', description: '', repo: 'owner/repo', provider: 'git', reviewers: [], + sharing: { skills: {}, rules: { enforced: [] }, docs: { localDir: '' }, env: { injectShellProfile: false } }, + toolPaths, + }); + + it('finds no rules target on a member whose only WorkBuddy root is .workbuddy-ai', async () => { + await fse.ensureDir(path.join(home, '.workbuddy-ai')); + expect(await rulesReceivingTools(teamConfig({ workbuddy: DEFAULT_TOOL_PATHS.workbuddy }), localConfig())).toEqual([]); + }); + + it.each(['codex', 'pi'] as const)('counts %s, whose session hook or extension adds the rules in a workspace', async (tool) => { + const projectRoot = path.join(root, 'project'); + await fse.ensureDir(path.join(home, `.${tool}`)); + await fse.ensureDir(path.join(projectRoot, `.${tool}`)); + const workspace: LocalConfig = { ...localConfig(), scope: 'project', projectRoot, enabledAgents: [tool] }; + expect(await rulesReceivingTools(teamConfig({ [tool]: DEFAULT_TOOL_PATHS[tool] }), workspace)).toEqual([tool]); + }); + + it('counts a tool whose rules land in a single file', async () => { + await fse.ensureDir(path.join(home, '.workbuddy-ai')); + await fse.ensureDir(path.join(home, '.codex')); + const toolPaths = { workbuddy: DEFAULT_TOOL_PATHS.workbuddy, codex: DEFAULT_TOOL_PATHS.codex }; + expect(await rulesReceivingTools(teamConfig(toolPaths), localConfig())).toEqual(['codex']); + }); +}); diff --git a/src/doctor.ts b/src/doctor.ts index b88a17b20..143058338 100644 --- a/src/doctor.ts +++ b/src/doctor.ts @@ -19,6 +19,7 @@ import { resolveToolBaseDir, isAgentExcluded, scopedToolPaths, + toolGateRoot, getDataHome, getTeamaiHomeDir, isUsageReportEnabled, @@ -36,6 +37,7 @@ import { TEAMAI_HOOK_SUBCOMMANDS, isCodexTrustGatedTool, codexTrustReminder, kep import { codexBuildSkewMessage } from './codex-trust.js'; import { buildDeliveryChecks, + nameList, toolPathsDroppedKeyNotes, buildSkillDirChecks, skillReadNotes, @@ -219,6 +221,59 @@ async function buildEnabledToolChecks(ctx: DoctorContext): Promise { return checks; } +/** The surfaces `locate` gates per tool, as a fix names them. */ +const GATED_SURFACES = [['rules', 'rules'], ['blocks', 'team instructions'], ['agents', 'agents'], ['mcp', 'MCP servers']] as const; + +/** + * A tool can take its skills while its other surfaces wait for a root that is + * not there: on WorkBuddy 5.7+, skills land under `.workbuddy-ai/`, while + * everything else still needs `.workbuddy/` (#1273). Pull writes none of those, + * and the delivery checks skip a tool whose gate is unmet, so with + * ` is installed` passing on the skills root doctor would stay green. + * Hooks count when the hook writer finds none of its install roots and one of + * them is a root the other surfaces wait for. + */ +async function buildHeldSurfaceChecks(ctx: DoctorContext): Promise { + const { localConfig, toolPaths, hookToolPaths, baseDir } = ctx; + const install = installOf(ctx.teamConfig ?? { toolPaths }, localConfig); + const checks: Check[] = []; + for (const tool of Object.keys(toolPaths)) { + const skills = writable(await locate(tool, 'skills', install)); + if (skills === null) continue; + // Keyed by every missing root, whether or not the team maps a surface to it. + const held = new Map(); + for (const [resource, surface] of GATED_SURFACES) { + const location = await locate(tool, resource, install); + const unmet = location.gates.find((gate) => gate.kind === 'installed' && !gate.met); + if (unmet?.kind !== 'installed') continue; + const surfaces = held.get(unmet.probe) ?? []; + if (location.channel === 'files' && location.own !== null) surfaces.push(surface); + held.set(unmet.probe, surfaces); + } + const settings = hookToolPaths[tool]?.settings; + if (settings) { + // reconcileHooksToAllTools injects while either root exists. + const gateRoot = toolGateRoot(tool, settings, localConfig.toolRoots); + const hookRoots = [hookSettingsPath(tool, baseDir, gateRoot)]; + if (localConfig.scope === 'project') hookRoots.push(path.join(localConfig.projectRoot ?? baseDir, gateRoot)); + const root = hookRoots.find((candidate) => held.has(candidate)); + if (root !== undefined && !(await Promise.all(hookRoots.map((dir) => pathExists(dir)))).includes(true)) held.get(root)?.push('hooks'); + } + for (const [root, surfaces] of held) { + if (surfaces.length === 0) continue; + checks.push({ + name: `${tool} receives ${nameList(surfaces)}`, + source: 'local', + check: async () => false, + fix: `teamai delivers ${tool}'s skills to ${skills.path}, but writes its ${nameList(surfaces)} only once ` + + `${root} exists, and it does not, so a pull holds them back from ${tool}. teamai does not write them ` + + 'where this install reads its skills yet, so there is nothing to fix on this machine.', + }); + } + } + return checks; +} + /** * Check that each relocated tool root is the one teamai writes to. * @@ -686,6 +741,7 @@ export async function buildChecks(ctx: DoctorContext, stage: CheckStage = 'docto ...buildToolRootChecks(localConfig, teamConfig), ...buildToolHomeChecks(localConfig), ...await buildEnabledToolChecks(ctx), + ...await buildHeldSurfaceChecks(ctx), ...await buildHookChecks(toolPaths, hookToolPaths, baseDir, localConfig), ...await buildDeliveryChecks(ctx), ...await buildSkillDirChecks(ctx), diff --git a/src/locate.ts b/src/locate.ts index 072651069..901f7dfb9 100644 --- a/src/locate.ts +++ b/src/locate.ts @@ -16,6 +16,7 @@ import { } from './tools.js'; import { DEFAULT_TOOL_PATHS, isAgentExcluded, isGitExcludeEnabled, isSelfMode, resolveBaseDir, scopedToolPaths, toolGateRoot, + WORKBUDDY_NEW_GENERATION_ROOT, WORKBUDDY_TOOL_ID, WORKBUDDY_TOOL_ROOT, type HomePaths, type LocalConfig, type Scope, type TeamaiConfig, type ToolId, } from './types.js'; import { homeRoot, movedToolHome, realHost, toolHome, type Host } from './tool-home.js'; @@ -397,13 +398,13 @@ async function userViewOf(install: Install, host: Host): Promise { return { toolPaths: install.toolPaths, agents: install.agents, toolRoots, gitExclude: install.gitExclude, scope: 'user' }; } -async function gatesOf(tool: string, install: Install, host: Host, roots: Roots = rootsOf(tool, install, host)): Promise { +async function gatesOf(tool: string, install: Install, host: Host, roots: Roots = rootsOf(tool, install, host), resource?: Resource): Promise { const selected: Gate = { kind: 'selected', met: !isAgentExcluded(install.agents, tool) }; - const installed = await installedGateOf(tool, install, host, roots); + const installed = await installedGateOf(tool, install, host, roots, resource); return installed === null ? [selected] : [selected, installed]; } -async function installedGateOf(tool: string, install: Install, host: Host, roots: Roots): Promise { +async function installedGateOf(tool: string, install: Install, host: Host, roots: Roots, resource?: Resource): Promise { const { home, join } = roots; const enabled = install.agents.enabledAgents?.includes(tool) === true; const probe = async (dir: string, met?: boolean): Promise => ({ kind: 'installed', probe: dir, met: met ?? await host.exists(dir) }); @@ -433,6 +434,18 @@ async function installedGateOf(tool: string, install: Install, host: Host, roots case 'home': return probe(home.dir); case 'own-root': + // WorkBuddy 5.7+ moved its runtime to `.workbuddy-ai/` and stops + // reading `.workbuddy/` (#1273): the new root present means 5.7+ is + // the live generation (an upgrade leaves the old root behind as an + // unread leftover), so it is the install proof even when both exist — + // but only for skills, the one surface with verified 5.7+ delivery + // evidence (the 13-skill `/` menu check in #1273). Every other + // surface keeps requiring the old root and fails closed on a + // 5.7-only member until its real 5.7+ path is confirmed (#1290). + if (resource === 'skills' && tool === WORKBUDDY_TOOL_ID + && await host.exists(join(roots.base, WORKBUDDY_NEW_GENERATION_ROOT))) { + return probe(join(roots.base, WORKBUDDY_NEW_GENERATION_ROOT)); + } return probe(join(roots.base, home.root)); default: { const unhandled: never = installedBy; @@ -545,6 +558,19 @@ async function locateSkills(tool: string, install: Install, host: Host): Promise if (configured === null) return { channel: 'files', own: null, shared: null, reads: 'unknown', readGates: [], retired: [], gates: [] }; let own: string | null = configured; + // WorkBuddy 5.7+ scans skills from `.workbuddy-ai/skills` and stops reading + // `.workbuddy/` (#1273), so on an install whose live generation is the new + // root, skills land where the app scans. Only the default path swaps — + // a team that redirected skills on purpose keeps its own. User scope may + // already carry the swapped default (`scopedToolPaths`), so both spellings + // count (#1290: the new root wins even when an upgrade left both behind). + if (tool === WORKBUDDY_TOOL_ID) { + const relative = roots.paths?.skills; + if ((relative === `${WORKBUDDY_TOOL_ROOT}/skills` || relative === `${WORKBUDDY_NEW_GENERATION_ROOT}/skills`) + && await host.exists(roots.join(roots.base, WORKBUDDY_NEW_GENERATION_ROOT))) { + own = roots.join(roots.base, `${WORKBUDDY_NEW_GENERATION_ROOT}/skills`); + } + } let shared: string | null = null; if (tool === 'openclaw') { const workspace = await openclawWorkspaceOf(roots, host); @@ -563,7 +589,7 @@ async function locateSkills(tool: string, install: Install, host: Host): Promise reads: await readsOf(isToolId(tool) ? TOOLS[tool].skills[install.scope] : 'unknown', roots, host), readGates: [], retired: [], - gates: await gatesOf(tool, install, host, roots), + gates: await gatesOf(tool, install, host, roots, 'skills'), }; } diff --git a/src/pull.ts b/src/pull.ts index 68e00795f..afdaccebb 100644 --- a/src/pull.ts +++ b/src/pull.ts @@ -23,7 +23,7 @@ import { } from './instruction-targets.js'; import { getHandler, RulesHandler, DocsHandler, EnvHandler, AgentsHandler } from './resources/index.js'; import { removedAgentOrigin, reportHeldAgents, type RedeployedCopy } from './resources/agents.js'; -import { ruleOrigin } from './resources/rules.js'; +import { ruleOrigin, rulesReceivingTools } from './resources/rules.js'; import { deliveredDocFiles, describeLinkedDocsRoot, isLinkedDocsRoot, isTeamDocsDirectory, keepDocsSearchWhitelist, listStaleDocDirectories, membersDocs, resolveDesiredDocs, resolveDocsDestination, @@ -1971,7 +1971,14 @@ async function pullForScope( if (result) { result.resourceSyncFailed = true; result.delivery = 'failed'; } } if (items.length > 0) { - log.success(`[${scopeLabel}] Synced ${items.length} rule(s)${skippedByTags > 0 ? ` (skipped ${skippedByTags} by tags)` : ''}`); + // A member whose rules tools are all fail-closed (WorkBuddy 5.7-only, + // #1290) or not installed gets the truth, not a count of rules no + // tool took. + if ((await rulesReceivingTools(freshConfig, localConfig)).length > 0) { + log.success(`[${scopeLabel}] Synced ${items.length} rule(s)${skippedByTags > 0 ? ` (skipped ${skippedByTags} by tags)` : ''}`); + } else { + log.info(`[${scopeLabel}] Held ${items.length} rule(s): no installed tool reads rules in this scope`); + } } if (reportKeptCopies() > 0) membersFilesKept = true; } diff --git a/src/resources/rules.ts b/src/resources/rules.ts index 82db0794b..c7d86e5ef 100644 --- a/src/resources/rules.ts +++ b/src/resources/rules.ts @@ -1382,6 +1382,21 @@ async function rulesLocations( return Promise.all(Object.keys(install.toolPaths).map(async (tool) => ({ tool, location: await locate(tool, 'rules', install) }))); } +/** + * Tools of the team's `toolPaths` that take rules in this scope: a rules + * target this pull can write (a directory or a single file like Codex's + * `AGENTS.md`), or a session hook, extension or plugin whose gates are met. + * An empty list means every rules surface is fail-closed (not installed, + * excluded, or a WorkBuddy 5.7-only member whose old root is gone, #1290), so + * no tool got the rules, whatever the desired count said. + */ +export async function rulesReceivingTools(teamConfig: TeamaiConfig, localConfig: LocalConfig): Promise { + const locations = await rulesLocations(teamConfig, localConfig); + return locations + .filter(({ location }) => location.channel === 'run-time' ? location.gates.every((gate) => gate.met) : writable(location) !== null) + .map(({ tool }) => tool); +} + /** * Files whose legacy `TEAMAI_RULES` section this pull strips. * The configured claudemd, including when `locate` retired it, and the blocks diff --git a/src/types.ts b/src/types.ts index c785f4731..75a3f8d39 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,4 +1,5 @@ import { z } from 'zod'; +import fs from 'node:fs'; import path from 'node:path'; import { createHash } from 'node:crypto'; import { getUserHome, expandHome } from './utils/home.js'; @@ -2489,6 +2490,13 @@ export const DEFAULT_CLAUDE_ROOT = '.claude'; export const CODEX_TOOL_ID = 'codex'; +/** WorkBuddy's toolPaths id and pre-5.7 root segment (#946). */ +export const WORKBUDDY_TOOL_ID = 'workbuddy'; +export const WORKBUDDY_TOOL_ROOT = '.workbuddy'; + +/** WorkBuddy 5.7+'s runtime root (#1273). */ +export const WORKBUDDY_NEW_GENERATION_ROOT = '.workbuddy-ai'; + /** Muse Code (Meta), whose CLI binary is `muse`. */ export const MUSE_TOOL_ID = 'muse'; @@ -2879,6 +2887,34 @@ export function applyToolRoots( return out ?? toolPaths; } +/** + * WorkBuddy 5.7+ moved its runtime to `.workbuddy-ai/` and stops reading + * `.workbuddy/` (#1273), so skills go to the directory the app actually scans + * — live-verified in the issue (13 team skills showed in the live `/` menu). + * The new root wins whenever it exists, because an upgrade leaves the old one + * behind as an unread leftover. Only the default skills path swaps, so a team + * that redirected on purpose keeps its own. Every other surface keeps its + * `.workbuddy/` path: written there while that root exists, held by `locate` + * once it is gone. Returns the same object when no swap fires. + */ +function applyWorkBuddyGeneration( + toolPaths: Record>, + localConfig: { scope?: Scope; projectRoot?: string }, +): Record> { + const workbuddy = toolPaths[WORKBUDDY_TOOL_ID]; + if (!workbuddy || workbuddy.skills !== '.workbuddy/skills') return toolPaths; + const baseDir = localConfig.scope === 'project' && localConfig.projectRoot !== undefined + ? localConfig.projectRoot + : getUserHome(); + // The new root present means 5.7+ is the live generation (an upgrade leaves + // `.workbuddy/` behind as a leftover the app no longer reads), so it wins + // even when both roots exist (#1290). + if (fs.existsSync(path.join(baseDir, WORKBUDDY_NEW_GENERATION_ROOT))) { + return { ...toolPaths, [WORKBUDDY_TOOL_ID]: { ...workbuddy, skills: `${WORKBUDDY_NEW_GENERATION_ROOT}/skills` } }; + } + return toolPaths; +} + /** * Return `teamConfig.toolPaths` with per-scope path overrides applied. * @@ -2904,8 +2940,9 @@ export function scopedToolPaths( localConfig: { scope?: Scope; toolRoots?: Readonly> }, at?: HomePaths, ): Record> { - if (localConfig.scope !== 'user') return teamConfig.toolPaths; - const rooted = applyToolRoots(teamConfig.toolPaths, localConfig.toolRoots, at); + const generationAdjusted = applyWorkBuddyGeneration(teamConfig.toolPaths, localConfig); + if (localConfig.scope !== 'user') return generationAdjusted; + const rooted = applyToolRoots(generationAdjusted, localConfig.toolRoots, at); const out: Record> = {}; for (const [tool, paths] of Object.entries(rooted)) { const us = paths.userScope; @@ -2929,7 +2966,10 @@ export function scopedToolPaths( // Copilot loads user hooks only from files directly in `$COPILOT_HOME/hooks/`. const copilot = out[COPILOT_TOOL_ID]; if (copilot?.hooks) out[COPILOT_TOOL_ID] = { ...copilot, hooks: `hooks/${path.basename(copilot.hooks)}` }; - return out; + // Re-apply the WorkBuddy generation swap after the user-scope merge: a + // `userScope.skills` carrying the default `.workbuddy/skills` restores the + // unread old path over the adjusted one (#1290). + return applyWorkBuddyGeneration(out, localConfig); } /** True when the local config is single-repo mode (the business repo is the team repo). */