From 8e27596cf39d73461822bc49bf2563f5f8df3762 Mon Sep 17 00:00:00 2001 From: kittimzhe Date: Thu, 22 Oct 2026 15:44:45 +0800 Subject: [PATCH 01/11] feat(install): recognize WorkBuddy 5.7+'s .workbuddy-ai root (#1273) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WorkBuddy 5.7+ moved its runtime to .workbuddy-ai/ and stops reading .workbuddy/, so a member on the new generation has only the new root and the install probe reported the tool as not installed — teamai integrated nothing, while every self-check stayed green. The probe now accepts either root as install proof, per generation list; which paths teamai writes for the new generation follows in the write-side change, blocked on the three questions in the issue (hooks file location, event vocabulary, old-format compatibility). --- src/__tests__/workbuddy-generation.test.ts | 58 ++++++++++++++++++++++ src/resources/base.ts | 16 +++++- 2 files changed, 73 insertions(+), 1 deletion(-) create mode 100644 src/__tests__/workbuddy-generation.test.ts diff --git a/src/__tests__/workbuddy-generation.test.ts b/src/__tests__/workbuddy-generation.test.ts new file mode 100644 index 000000000..ccb5eee24 --- /dev/null +++ b/src/__tests__/workbuddy-generation.test.ts @@ -0,0 +1,58 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import fse from 'fs-extra'; +import os from 'node:os'; +import path from 'node:path'; +import { isToolInstalledForConfig } from '../resources/base.js'; +import type { LocalConfig } 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('finds new-generation WorkBuddy 5.7+ by .workbuddy-ai alone', async () => { + await fse.ensureDir(path.join(projectRoot, '.workbuddy-ai')); + expect(await isToolInstalledForConfig('workbuddy', '.workbuddy/skills', projectConfig())).toBe(true); + }); + + 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); + }); +}); diff --git a/src/resources/base.ts b/src/resources/base.ts index 31ee560bd..fb20a2d22 100644 --- a/src/resources/base.ts +++ b/src/resources/base.ts @@ -12,6 +12,16 @@ import type { DeliveryLedger } from './delivered-copies.js'; const TOMBSTONE_FILE = '.removed'; +/** + * Roots of newer app generations, which count as install proof beside the + * primary root: WorkBuddy 5.7+ moved its runtime to `.workbuddy-ai/` and stops + * reading `.workbuddy/`, so a member on 5.7+ has only the new root (#1273). + * The probe accepts either; which paths teamai writes stays the toolPaths + * entry's choice, per generation. + */ +const NEW_GENERATION_INSTALL_ROOTS: Readonly> = { + workbuddy: ['.workbuddy-ai'], +}; /** Detect an installed tool while respecting tool-specific user roots. */ export async function isToolInstalledForConfig( tool: string, @@ -36,7 +46,11 @@ export async function isToolInstalledForConfig( return pathExists(defaultToolHome(tool, { home: getUserHome(), path })); case 'own-root': { const root = homeRoot(tool); - return ResourceHandler.isToolInstalled(root, baseDir, toolGateRoot(tool, root, localConfig.toolRoots)); + if (await ResourceHandler.isToolInstalled(root, baseDir, toolGateRoot(tool, root, localConfig.toolRoots))) return true; + for (const nextRoot of NEW_GENERATION_INSTALL_ROOTS[tool] ?? []) { + if (await ResourceHandler.isToolInstalled(nextRoot, baseDir, toolGateRoot(tool, nextRoot, localConfig.toolRoots))) return true; + } + return false; } case undefined: break; From c2504f704868110df5a65ce71caf5c31c6ad8bea Mon Sep 17 00:00:00 2001 From: kittimzhe Date: Thu, 22 Oct 2026 17:00:20 +0800 Subject: [PATCH 02/11] feat(skills): deliver WorkBuddy skills to the 5.7+ root (#1273) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WorkBuddy 5.7+ scans skills from .workbuddy-ai/skills and stops reading .workbuddy/, so on a member whose install left only the new root, skills landed in a directory the app never looks at. scopedToolPaths now swaps the default skills path to .workbuddy-ai/skills when only the new root exists at the delivery base (user HOME or project root, by scope) — live-verified in the issue: 13 team skills delivered this way showed in the app's / menu on 5.7.6. Only the default path swaps, so a team that redirected skills on purpose keeps its own; other surfaces keep their configured paths (writes into the dead old layout are no-ops). Real-CLI record (workspace scope, .workbuddy-ai alone at the project root): pull --force delivers 'fixture-skill, teamai' into .workbuddy-ai/skills/, and no .workbuddy/ directory is created. --- src/__tests__/workbuddy-generation.test.ts | 56 ++++++++++++++++++++++ src/types.ts | 37 +++++++++++++- 2 files changed, 91 insertions(+), 2 deletions(-) diff --git a/src/__tests__/workbuddy-generation.test.ts b/src/__tests__/workbuddy-generation.test.ts index ccb5eee24..c6f6a2069 100644 --- a/src/__tests__/workbuddy-generation.test.ts +++ b/src/__tests__/workbuddy-generation.test.ts @@ -3,6 +3,7 @@ import fse from 'fs-extra'; import os from 'node:os'; import path from 'node:path'; import { isToolInstalledForConfig } from '../resources/base.js'; +import { scopedToolPaths } from '../types.js'; import type { LocalConfig } from '../types.js'; /** @@ -56,3 +57,58 @@ describe('WorkBuddy install probe across generations (#1273)', () => { 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('keeps the old path when the old root is still there', async () => { + await fse.ensureDir(path.join(home, '.workbuddy')); + expect(paths(defaultTeam).workbuddy.skills).toBe('.workbuddy/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('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'); + }); +}); diff --git a/src/types.ts b/src/types.ts index f5ed0c865..3716d1900 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'; @@ -2487,6 +2488,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'; @@ -2897,13 +2905,38 @@ export function applyToolRoots( * scope must not: there the paths hang off the project root, which a * HOME-relative member root has nothing to say about. */ +/** + * WorkBuddy 5.7+ moved its runtime to `.workbuddy-ai/` and stops reading + * `.workbuddy/` (#1273): on an install that left only the new root, deliver + * skills to the directory the app actually scans — live-verified in the issue + * (13 team skills showed in the live `/` menu). Only the default skills path + * swaps, so a team that redirected on purpose keeps its own; every other + * surface keeps its configured path (writes into the dead old layout are + * no-ops the app ignores). 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(); + if (fs.existsSync(path.join(baseDir, WORKBUDDY_NEW_GENERATION_ROOT)) && !fs.existsSync(path.join(baseDir, WORKBUDDY_TOOL_ROOT))) { + return { ...toolPaths, [WORKBUDDY_TOOL_ID]: { ...workbuddy, skills: `${WORKBUDDY_NEW_GENERATION_ROOT}/skills` } }; + } + return toolPaths; +} + export function scopedToolPaths( teamConfig: Pick, 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; From ee86989c78cbe89bbcf44979c1cbf388b8ee8f93 Mon Sep 17 00:00:00 2001 From: kittimzhe Date: Fri, 23 Oct 2026 10:14:27 +0800 Subject: [PATCH 03/11] fix(workbuddy): prefer the 5.7+ root on upgraded installs, after user-scope merge (#1273) Two findings from the PR review, both taken: - An upgraded 5.7+ install typically still has the leftover .workbuddy/ beside .workbuddy-ai/ (the reporter's machine is exactly that shape), so gating the swap on the old root's absence kept delivering skills into the directory the new runtime never reads. The new root now wins whenever it exists. - scopedToolPaths applied the swap before the user-scope merge, so a userScope.skills carrying the default .workbuddy/skills restored the unread old path over the adjusted one. The swap is re-applied after the merge (idempotent); a userScope redirect of its own is still never stomped. Real-CLI record, dual-root project root (.workbuddy + .workbuddy-ai): pull --force still delivers fixture-skill and teamai into .workbuddy-ai/skills/ and writes nothing into .workbuddy/skills. --- src/__tests__/workbuddy-generation.test.ts | 25 ++++++++++++++++++++-- src/types.ts | 14 +++++++++--- 2 files changed, 34 insertions(+), 5 deletions(-) diff --git a/src/__tests__/workbuddy-generation.test.ts b/src/__tests__/workbuddy-generation.test.ts index c6f6a2069..574b3996e 100644 --- a/src/__tests__/workbuddy-generation.test.ts +++ b/src/__tests__/workbuddy-generation.test.ts @@ -89,9 +89,10 @@ describe('scopedToolPaths swaps WorkBuddy skills to the 5.7+ root (#1273)', () = expect(paths(defaultTeam).workbuddy.skills).toBe('.workbuddy-ai/skills'); }); - it('keeps the old path when the old root is still there', async () => { + it('prefers the new root when an upgrade left both roots behind (#1290 P1)', async () => { await fse.ensureDir(path.join(home, '.workbuddy')); - expect(paths(defaultTeam).workbuddy.skills).toBe('.workbuddy/skills'); + 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)', () => { @@ -106,6 +107,26 @@ describe('scopedToolPaths swaps WorkBuddy skills to the 5.7+ root (#1273)', () = expect(paths(elsewhere).workbuddy.skills).toBe('custom/skills'); }); + it('keeps the swap after the user-scope override merges the default path (#1290 P1)', 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 (#1290 P1)', 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')); diff --git a/src/types.ts b/src/types.ts index 3716d1900..6890a3796 100644 --- a/src/types.ts +++ b/src/types.ts @@ -2909,7 +2909,9 @@ export function applyToolRoots( * WorkBuddy 5.7+ moved its runtime to `.workbuddy-ai/` and stops reading * `.workbuddy/` (#1273): on an install that left only the new root, deliver * skills to the directory the app actually scans — live-verified in the issue - * (13 team skills showed in the live `/` menu). Only the default skills path + * (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 configured path (writes into the dead old layout are * no-ops the app ignores). Returns the same object when no swap fires. @@ -2923,7 +2925,10 @@ function applyWorkBuddyGeneration( const baseDir = localConfig.scope === 'project' && localConfig.projectRoot !== undefined ? localConfig.projectRoot : getUserHome(); - if (fs.existsSync(path.join(baseDir, WORKBUDDY_NEW_GENERATION_ROOT)) && !fs.existsSync(path.join(baseDir, WORKBUDDY_TOOL_ROOT))) { + // 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; @@ -2960,7 +2965,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). */ From f6939bcd12333aa223b795cf374759693b526cd6 Mon Sep 17 00:00:00 2001 From: kittimzhe Date: Sun, 25 Oct 2026 17:21:26 +0800 Subject: [PATCH 04/11] feat(locate): carry the WorkBuddy generation swap into the locate pipeline (#1273) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Main's #1221 moved skills delivery behind locate(), beside the probe re-homed into the own-root gate and the scopedToolPaths swap. locate now writes and probes .workbuddy-ai/ when the new root is the live generation: user scope may already carry the swapped default through scopedToolPaths, so both spellings count as the default. The constant moves to tools.ts, the tool registry, where base.ts's probe shares it. Real-CLI record rerun on the rebased head (workspace scope, only .workbuddy-ai/ at the project root): pull --force reports Synced 2 skills (2 new) — fixture-skill, teamai — both in .workbuddy-ai/skills/, no .workbuddy/ created. --- src/__tests__/locate-workbuddy.test.ts | 85 ++++++++++++++++++++++++++ src/locate.ts | 21 +++++++ src/resources/base.ts | 12 +--- src/tools.ts | 11 ++++ 4 files changed, 118 insertions(+), 11 deletions(-) create mode 100644 src/__tests__/locate-workbuddy.test.ts diff --git a/src/__tests__/locate-workbuddy.test.ts b/src/__tests__/locate-workbuddy.test.ts new file mode 100644 index 000000000..2986074ac --- /dev/null +++ b/src/__tests__/locate-workbuddy.test.ts @@ -0,0 +1,85 @@ +/** + * 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 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`); + }); +}); diff --git a/src/locate.ts b/src/locate.ts index 08d858e0f..b94e180ac 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'; @@ -433,6 +434,13 @@ 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. + if (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 +553,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); diff --git a/src/resources/base.ts b/src/resources/base.ts index fb20a2d22..0846f8da9 100644 --- a/src/resources/base.ts +++ b/src/resources/base.ts @@ -6,22 +6,12 @@ import type { ResourceType, ResourceItem, ResourceDiff, DeliveryTarget, TeamaiCo export type PlacementRecords = Pick; import { readFileSafe, writeFile, ensureDir, pathExists } from '../utils/fs.js'; import { getUserHome } from '../utils/home.js'; -import { installGateOf, isToolId } from '../tools.js'; +import { installGateOf, isToolId, NEW_GENERATION_INSTALL_ROOTS } from '../tools.js'; import { defaultToolHome, homeRoot } from '../tool-home.js'; import type { DeliveryLedger } from './delivered-copies.js'; const TOMBSTONE_FILE = '.removed'; -/** - * Roots of newer app generations, which count as install proof beside the - * primary root: WorkBuddy 5.7+ moved its runtime to `.workbuddy-ai/` and stops - * reading `.workbuddy/`, so a member on 5.7+ has only the new root (#1273). - * The probe accepts either; which paths teamai writes stays the toolPaths - * entry's choice, per generation. - */ -const NEW_GENERATION_INSTALL_ROOTS: Readonly> = { - workbuddy: ['.workbuddy-ai'], -}; /** Detect an installed tool while respecting tool-specific user roots. */ export async function isToolInstalledForConfig( tool: string, diff --git a/src/tools.ts b/src/tools.ts index 16b962b7b..6a1aab106 100644 --- a/src/tools.ts +++ b/src/tools.ts @@ -777,6 +777,17 @@ export const TOOLS = { }, } as const satisfies Record; +/** + * Roots of newer app generations, which count as install proof beside the + * tool's primary root: WorkBuddy 5.7+ moved its runtime to `.workbuddy-ai/` + * and stops reading `.workbuddy/`, so a member on 5.7+ has only the new + * root (#1273). A probe accepts either; which paths teamai writes stays the + * toolPaths entry's choice, per generation. + */ +export const NEW_GENERATION_INSTALL_ROOTS: Readonly> = { + workbuddy: ['.workbuddy-ai'], +}; + export function isToolId(value: string): value is ToolId { return Object.hasOwn(TOOLS, value); } From 651693b2235d94e319a611965eea182986329966 Mon Sep 17 00:00:00 2001 From: kittimzhe Date: Mon, 26 Oct 2026 11:27:55 +0800 Subject: [PATCH 05/11] fix(workbuddy): fail non-skills surfaces closed on 5.7-only members (#1290) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The relaxed install probe activated every surface on a WorkBuddy 5.7-only member, but only skills have verified 5.7+ delivery evidence (the 13-skill `/` menu check in #1273) — rules, MCP, agents, blocks and hooks were being written into the dead `.workbuddy/` layout with doctor reporting them delivered. Scope the new root to the surface that earned it: `installedGateOf` now takes the resource, and only `locateSkills` passes `'skills'`, so every other locate surface keeps requiring `.workbuddy/` and skips a 5.7-only member until its real 5.7+ path is confirmed. `isToolInstalledForConfig` goes back to the strict root (its consumers are the non-skills surfaces: doctor's enabled-tool probe, uninstall's shared-path protection, known-agents), and `NEW_GENERATION_INSTALL_ROOTS` retires with it. A pull whose rules found no writable target now says so instead of counting rules no tool took (`Held N rule(s): no installed tool reads rules in this scope`) — the same reported-success-received-nothing the codebase's own doctor comment warns about. Verification: full suite 16388 passed, 6 failures baseline-verified on current main (git-discovery, submodule-checkout x2, workflow-workspace, gh-deadline, and the new workflow-place one from #1381; the lease-timeout flake passes isolated). Includes the reviewer-requested test: a team rule and an MCP server on a `.workbuddy-ai`-only home (user scope, the full default entry) find no writable target, so nothing creates `.workbuddy/`, while skills still take the new root. Real CLI matches: 5.7-only project with a team rule and an MCP server — skills synced to `.workbuddy-ai/skills`, rules held with nothing written, no `.workbuddy/` or MCP file created; old root present: rules delivered to `.codebuddy/rules` with frontmatter. --- src/__tests__/locate-workbuddy.test.ts | 58 ++++++++++++++++++++++ src/__tests__/workbuddy-generation.test.ts | 4 +- src/locate.ts | 17 ++++--- src/pull.ts | 11 +++- src/resources/base.ts | 8 +-- src/resources/rules.ts | 11 ++++ src/tools.ts | 11 ---- 7 files changed, 93 insertions(+), 27 deletions(-) diff --git a/src/__tests__/locate-workbuddy.test.ts b/src/__tests__/locate-workbuddy.test.ts index 2986074ac..527a57c3b 100644 --- a/src/__tests__/locate-workbuddy.test.ts +++ b/src/__tests__/locate-workbuddy.test.ts @@ -8,6 +8,7 @@ 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'; @@ -83,3 +84,60 @@ describe('locate carries the WorkBuddy 5.7+ generation (#1273)', () => { 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: {} }); + for (const resource of ['rules', 'mcp'] as const) { + const where = await locate('workbuddy', resource, install, host); + expect(where.gates).toContainEqual({ kind: 'installed', probe: `${HOME}/.workbuddy`, met: false }); + expect(writable(where)).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 index 574b3996e..8f7921573 100644 --- a/src/__tests__/workbuddy-generation.test.ts +++ b/src/__tests__/workbuddy-generation.test.ts @@ -41,9 +41,9 @@ describe('WorkBuddy install probe across generations (#1273)', () => { expect(await isToolInstalledForConfig('workbuddy', '.workbuddy/skills', projectConfig())).toBe(true); }); - it('finds new-generation WorkBuddy 5.7+ by .workbuddy-ai alone', async () => { + 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(true); + expect(await isToolInstalledForConfig('workbuddy', '.workbuddy/skills', projectConfig())).toBe(false); }); it('takes the old root as proof when both generations left directories behind', async () => { diff --git a/src/locate.ts b/src/locate.ts index b94e180ac..ea1dc83ac 100644 --- a/src/locate.ts +++ b/src/locate.ts @@ -398,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) }); @@ -437,8 +437,13 @@ async function installedGateOf(tool: string, install: Install, host: Host, roots // 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. - if (tool === WORKBUDDY_TOOL_ID && await host.exists(join(roots.base, WORKBUDDY_NEW_GENERATION_ROOT))) { + // 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)); @@ -584,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..bcb97ed8a 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, writableRulesTools } 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 writableRulesTools(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/base.ts b/src/resources/base.ts index 0846f8da9..31ee560bd 100644 --- a/src/resources/base.ts +++ b/src/resources/base.ts @@ -6,7 +6,7 @@ import type { ResourceType, ResourceItem, ResourceDiff, DeliveryTarget, TeamaiCo export type PlacementRecords = Pick; import { readFileSafe, writeFile, ensureDir, pathExists } from '../utils/fs.js'; import { getUserHome } from '../utils/home.js'; -import { installGateOf, isToolId, NEW_GENERATION_INSTALL_ROOTS } from '../tools.js'; +import { installGateOf, isToolId } from '../tools.js'; import { defaultToolHome, homeRoot } from '../tool-home.js'; import type { DeliveryLedger } from './delivered-copies.js'; @@ -36,11 +36,7 @@ export async function isToolInstalledForConfig( return pathExists(defaultToolHome(tool, { home: getUserHome(), path })); case 'own-root': { const root = homeRoot(tool); - if (await ResourceHandler.isToolInstalled(root, baseDir, toolGateRoot(tool, root, localConfig.toolRoots))) return true; - for (const nextRoot of NEW_GENERATION_INSTALL_ROOTS[tool] ?? []) { - if (await ResourceHandler.isToolInstalled(nextRoot, baseDir, toolGateRoot(tool, nextRoot, localConfig.toolRoots))) return true; - } - return false; + return ResourceHandler.isToolInstalled(root, baseDir, toolGateRoot(tool, root, localConfig.toolRoots)); } case undefined: break; diff --git a/src/resources/rules.ts b/src/resources/rules.ts index 82db0794b..0cb60eaf7 100644 --- a/src/resources/rules.ts +++ b/src/resources/rules.ts @@ -1382,6 +1382,17 @@ 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` whose rules directory this pull can write in + * this scope. 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 nothing was written, whatever the desired count said. + */ +export async function writableRulesTools(teamConfig: TeamaiConfig, localConfig: LocalConfig): Promise { + const locations = await rulesLocations(teamConfig, localConfig); + return locations.filter(({ location }) => rulesDirOf(location) !== null && 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/tools.ts b/src/tools.ts index 6a1aab106..16b962b7b 100644 --- a/src/tools.ts +++ b/src/tools.ts @@ -777,17 +777,6 @@ export const TOOLS = { }, } as const satisfies Record; -/** - * Roots of newer app generations, which count as install proof beside the - * tool's primary root: WorkBuddy 5.7+ moved its runtime to `.workbuddy-ai/` - * and stops reading `.workbuddy/`, so a member on 5.7+ has only the new - * root (#1273). A probe accepts either; which paths teamai writes stays the - * toolPaths entry's choice, per generation. - */ -export const NEW_GENERATION_INSTALL_ROOTS: Readonly> = { - workbuddy: ['.workbuddy-ai'], -}; - export function isToolId(value: string): value is ToolId { return Object.hasOwn(TOOLS, value); } From 268c7820a61a016fef4edb791c21499f6c10c823 Mon Sep 17 00:00:00 2001 From: kittimzhe Date: Mon, 26 Oct 2026 17:37:50 +0800 Subject: [PATCH 06/11] fix(rules): a single-file rules target is writable too (#1290) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review follow-up: `writableRulesTools` also required a rules directory, so a pull whose only rules target was a single file — Codex's user-scope `AGENTS.md` — printed 'Held N rule(s): no installed tool reads rules in this scope' while writing the rule, a regression against main in this PR's own output. Only `writable` decides now; directory and single-file targets report alike. Also splits the reviewer-requested test's loop into two explicit `locate` calls: `writable()` narrows by resource, so a union-typed `Location<'mcp' | 'rules'>` did not pass tsc (CI's Lint & Test caught what `npm run lint`, which skips test files, could not). Verified end to end on both gates: user scope with codex installed and one team rule prints 'Synced 1 rule(s)' and lands the rule in `~/.codex/AGENTS.md`; the 5.7-only workbuddy member still prints 'Held 1 rule(s)' with nothing written and no `.workbuddy/` created. --- src/__tests__/locate-workbuddy.test.ts | 13 ++++++++----- src/resources/rules.ts | 12 +++++++----- 2 files changed, 15 insertions(+), 10 deletions(-) diff --git a/src/__tests__/locate-workbuddy.test.ts b/src/__tests__/locate-workbuddy.test.ts index 527a57c3b..fe75622b8 100644 --- a/src/__tests__/locate-workbuddy.test.ts +++ b/src/__tests__/locate-workbuddy.test.ts @@ -131,11 +131,14 @@ describe('only skills accept the new root as install proof (#1290)', () => { // member. const host = hostWith([`${HOME}/.workbuddy-ai`]); const install = installOf({ toolPaths: DEFAULT_TOOL_PATHS }, { scope: 'user', repo: {} }); - for (const resource of ['rules', 'mcp'] as const) { - const where = await locate('workbuddy', resource, install, host); - expect(where.gates).toContainEqual({ kind: 'installed', probe: `${HOME}/.workbuddy`, met: false }); - expect(writable(where)).toBeNull(); - } + // 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/resources/rules.ts b/src/resources/rules.ts index 0cb60eaf7..779b7ef4d 100644 --- a/src/resources/rules.ts +++ b/src/resources/rules.ts @@ -1383,14 +1383,16 @@ async function rulesLocations( } /** - * Tools of the team's `toolPaths` whose rules directory this pull can write in - * this scope. 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 nothing was written, whatever the desired count said. + * Tools of the team's `toolPaths` whose rules target this pull can write in + * this scope — a directory surface or a single file like Codex's `AGENTS.md` + * alike; only `writable` decides. 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 nothing was written, whatever the desired + * count said. */ export async function writableRulesTools(teamConfig: TeamaiConfig, localConfig: LocalConfig): Promise { const locations = await rulesLocations(teamConfig, localConfig); - return locations.filter(({ location }) => rulesDirOf(location) !== null && writable(location) !== null).map(({ tool }) => tool); + return locations.filter(({ location }) => writable(location) !== null).map(({ tool }) => tool); } /** From 093f5e3b6684bf6455047ef19c93013951bbecee Mon Sep 17 00:00:00 2001 From: Saul Moro Date: Mon, 26 Oct 2026 12:34:47 +0100 Subject: [PATCH 07/11] fix(doctor): name the surfaces a .workbuddy-ai-only member does not get (#1273) On a member whose only WorkBuddy root is .workbuddy-ai/, skills arrive there, while rules, team instructions, agents, MCP servers and hooks still need .workbuddy/, so pull writes none of them. `workbuddy is installed` passes on the skills root and the delivery checks skip the gated surfaces, so doctor reported "All checks passed!". Doctor now fails one check per tool and missing root, naming the held surfaces and the root they wait for. The post-pull pass prints the same row after every pull. --- src/__tests__/workbuddy-generation.test.ts | 84 +++++++++++++++++++++- src/doctor.ts | 44 ++++++++++++ 2 files changed, 126 insertions(+), 2 deletions(-) diff --git a/src/__tests__/workbuddy-generation.test.ts b/src/__tests__/workbuddy-generation.test.ts index 8f7921573..d8d99f327 100644 --- a/src/__tests__/workbuddy-generation.test.ts +++ b/src/__tests__/workbuddy-generation.test.ts @@ -2,9 +2,29 @@ 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 { scopedToolPaths } from '../types.js'; -import type { LocalConfig } from '../types.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, @@ -133,3 +153,63 @@ describe('scopedToolPaths swaps WorkBuddy skills to the 5.7+ root (#1273)', () = 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; + + 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'], + }; + const teamConfig: TeamaiConfig = { + 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('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([]); + }); +}); + diff --git a/src/doctor.ts b/src/doctor.ts index e5fd5f0ce..6240412f7 100644 --- a/src/doctor.ts +++ b/src/doctor.ts @@ -18,6 +18,7 @@ import { resolveToolBaseDir, isAgentExcluded, scopedToolPaths, + toolGateRoot, getDataHome, getTeamaiHomeDir, isUsageReportEnabled, @@ -35,6 +36,7 @@ import { TEAMAI_HOOK_SUBCOMMANDS, isCodexTrustGatedTool, codexTrustReminder, kep import { codexBuildSkewMessage } from './codex-trust.js'; import { buildDeliveryChecks, + nameList, toolPathsDroppedKeyNotes, buildSkillDirChecks, skillReadNotes, @@ -218,6 +220,47 @@ 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 teamai injects them under the same missing root. + */ +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; + 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 (location.channel !== 'files' || location.own === null || unmet?.kind !== 'installed') continue; + held.set(unmet.probe, [...held.get(unmet.probe) ?? [], surface]); + } + const settings = hookToolPaths[tool]?.settings; + if (settings) held.get(hookSettingsPath(tool, baseDir, toolGateRoot(tool, settings, localConfig.toolRoots)))?.push('hooks'); + for (const [root, surfaces] of held) { + 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. * @@ -679,6 +722,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), From 375ebe793fd6f33c668501e1b77aa5ce03326a87 Mon Sep 17 00:00:00 2001 From: Saul Moro Date: Mon, 26 Oct 2026 12:34:47 +0100 Subject: [PATCH 08/11] test(rules): cover writableRulesTools and tidy the WorkBuddy notes (#1290) Pin that a single-file rules target such as Codex's AGENTS.md counts as writable, so pull does not report "Held" for rules it wrote, and that a .workbuddy-ai-only member has no rules target. Correct the applyWorkBuddyGeneration comment, which still called writes under .workbuddy/ no-ops, move the helper above scopedToolPaths' own docblock, and drop review labels from test names. --- src/__tests__/workbuddy-generation.test.ts | 51 ++++++++++++++++++-- src/types.ts | 55 +++++++++++----------- 2 files changed, 75 insertions(+), 31 deletions(-) diff --git a/src/__tests__/workbuddy-generation.test.ts b/src/__tests__/workbuddy-generation.test.ts index d8d99f327..93d75b752 100644 --- a/src/__tests__/workbuddy-generation.test.ts +++ b/src/__tests__/workbuddy-generation.test.ts @@ -23,6 +23,7 @@ vi.mock('../utils/logger.js', () => ({ import { loadLocalConfig, loadTeamConfig } from '../config.js'; import { buildChecks, resolveDoctorContext, type Check } from '../doctor.js'; import { isToolInstalledForConfig } from '../resources/base.js'; +import { writableRulesTools } from '../resources/rules.js'; import { DEFAULT_TOOL_PATHS, scopedToolPaths } from '../types.js'; import type { LocalConfig, TeamaiConfig } from '../types.js'; @@ -109,7 +110,7 @@ describe('scopedToolPaths swaps WorkBuddy skills to the 5.7+ root (#1273)', () = expect(paths(defaultTeam).workbuddy.skills).toBe('.workbuddy-ai/skills'); }); - it('prefers the new root when an upgrade left both roots behind (#1290 P1)', async () => { + 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'); @@ -127,7 +128,7 @@ describe('scopedToolPaths swaps WorkBuddy skills to the 5.7+ root (#1273)', () = expect(paths(elsewhere).workbuddy.skills).toBe('custom/skills'); }); - it('keeps the swap after the user-scope override merges the default path (#1290 P1)', async () => { + 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: { @@ -137,7 +138,7 @@ describe('scopedToolPaths swaps WorkBuddy skills to the 5.7+ root (#1273)', () = expect(paths(scoped).workbuddy.skills).toBe('.workbuddy-ai/skills'); }); - it('never stomps a userScope skills redirect of its own (#1290 P1)', async () => { + it('never stomps a userScope skills redirect of its own', async () => { await fse.ensureDir(path.join(home, '.workbuddy-ai')); const scoped = { toolPaths: { @@ -213,3 +214,47 @@ describe('doctor on a WorkBuddy member with only the 5.7+ root', () => { }); }); +/** + * Pull reports rules as synced only when some tool's rules target is + * writable (#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. + */ +describe('writableRulesTools', () => { + 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 writableRulesTools(teamConfig({ workbuddy: DEFAULT_TOOL_PATHS.workbuddy }), localConfig())).toEqual([]); + }); + + 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 writableRulesTools(teamConfig(toolPaths), localConfig())).toEqual(['codex']); + }); +}); diff --git a/src/types.ts b/src/types.ts index 6890a3796..537054e69 100644 --- a/src/types.ts +++ b/src/types.ts @@ -2885,36 +2885,15 @@ export function applyToolRoots( return out ?? toolPaths; } -/** - * Return `teamConfig.toolPaths` with per-scope path overrides applied. - * - * Almost every tool keeps its user-scope and project-scope resources at the same - * `./` relative path, so this is the identity map for them. The - * exception is a tool whose user-scope config lives under a different prefix from - * its project-scope config; its `userScope` block carries those paths and is - * spliced in only when the active scope is `user`. OpenCode is such a tool - * (`~/.config/opencode/` vs `/.opencode/`), and so is Qoder CN - * (`~/.qoder-cn/` vs `/.qoder/`). Callers that iterate `toolPaths` for - * scoped resources should iterate the result of this function instead, so the - * correct path is used. - * - * MCP is untouched here: its two scopes are already distinct fields - * (`mcp` / `mcpProject`), resolved separately in the reconcile engine. - * - * User scope also applies the member's `toolRoots` (applyToolRoots). Project - * scope must not: there the paths hang off the project root, which a - * HOME-relative member root has nothing to say about. - */ /** * WorkBuddy 5.7+ moved its runtime to `.workbuddy-ai/` and stops reading - * `.workbuddy/` (#1273): on an install that left only the new root, deliver - * skills 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 configured path (writes into the dead old layout are - * no-ops the app ignores). Returns the same object when no swap fires. + * `.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>, @@ -2934,6 +2913,26 @@ function applyWorkBuddyGeneration( return toolPaths; } +/** + * Return `teamConfig.toolPaths` with per-scope path overrides applied. + * + * Almost every tool keeps its user-scope and project-scope resources at the same + * `./` relative path, so this is the identity map for them. The + * exception is a tool whose user-scope config lives under a different prefix from + * its project-scope config; its `userScope` block carries those paths and is + * spliced in only when the active scope is `user`. OpenCode is such a tool + * (`~/.config/opencode/` vs `/.opencode/`), and so is Qoder CN + * (`~/.qoder-cn/` vs `/.qoder/`). Callers that iterate `toolPaths` for + * scoped resources should iterate the result of this function instead, so the + * correct path is used. + * + * MCP is untouched here: its two scopes are already distinct fields + * (`mcp` / `mcpProject`), resolved separately in the reconcile engine. + * + * User scope also applies the member's `toolRoots` (applyToolRoots). Project + * scope must not: there the paths hang off the project root, which a + * HOME-relative member root has nothing to say about. + */ export function scopedToolPaths( teamConfig: Pick, localConfig: { scope?: Scope; toolRoots?: Readonly> }, From df52af5a1048792e7be6330a7348ae82e1b80d9a Mon Sep 17 00:00:00 2001 From: Saul Moro Date: Mon, 26 Oct 2026 13:45:46 +0100 Subject: [PATCH 09/11] fix(doctor): report held WorkBuddy hooks when no other surface is mapped (#1273) The hooks row was added only to a root another held surface had already created. A team that maps WorkBuddy to skills and settings alone left the map empty, so on a member with only `.workbuddy-ai/` doctor passed while no hook was written. Record every missing root the other surfaces wait for, configured or not, and hold hooks when neither of the hook writer's install roots exists. --- src/__tests__/workbuddy-generation.test.ts | 15 ++++++++++++++- src/doctor.ts | 20 ++++++++++++++++---- 2 files changed, 30 insertions(+), 5 deletions(-) diff --git a/src/__tests__/workbuddy-generation.test.ts b/src/__tests__/workbuddy-generation.test.ts index 93d75b752..3b11861d0 100644 --- a/src/__tests__/workbuddy-generation.test.ts +++ b/src/__tests__/workbuddy-generation.test.ts @@ -164,6 +164,7 @@ describe('scopedToolPaths swaps WorkBuddy skills to the 5.7+ root (#1273)', () = 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-')); @@ -174,7 +175,7 @@ describe('doctor on a WorkBuddy member with only the 5.7+ root', () => { repo: { localPath: path.join(root, 'repo'), remote: 'owner/repo' }, username: 'tester', scope: 'user', additionalRoles: [], enabledAgents: ['workbuddy'], }; - const teamConfig: TeamaiConfig = { + 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 }, @@ -208,6 +209,18 @@ describe('doctor on a WorkBuddy member with only the 5.7+ root', () => { 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([]); diff --git a/src/doctor.ts b/src/doctor.ts index 6240412f7..2f30bb58e 100644 --- a/src/doctor.ts +++ b/src/doctor.ts @@ -229,7 +229,8 @@ const GATED_SURFACES = [['rules', 'rules'], ['blocks', 'team instructions'], ['a * 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 teamai injects them under the same missing root. + * 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; @@ -238,16 +239,27 @@ async function buildHeldSurfaceChecks(ctx: DoctorContext): Promise { 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 (location.channel !== 'files' || location.own === null || unmet?.kind !== 'installed') continue; - held.set(unmet.probe, [...held.get(unmet.probe) ?? [], surface]); + 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) held.get(hookSettingsPath(tool, baseDir, toolGateRoot(tool, settings, localConfig.toolRoots)))?.push('hooks'); + 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', From 645b89cc31fbbba4dc79e0ff64d55dcd14b5b897 Mon Sep 17 00:00:00 2001 From: Saul Moro Date: Mon, 26 Oct 2026 13:45:46 +0100 Subject: [PATCH 10/11] fix(pull): count session-hook and extension rule delivery as synced (#1290) In a workspace, Codex, Pi, Muse, DSH and ZCode take rules through teamai's session hook or extension, which writes no rules file, so pull printed "Held ... no installed tool reads rules" while the rules were delivered. A run-time rules channel whose gates are met now counts as a receiver. The helper is renamed to rulesReceivingTools to match. --- src/__tests__/workbuddy-generation.test.ts | 24 ++++++++++++++-------- src/pull.ts | 4 ++-- src/resources/rules.ts | 18 ++++++++-------- 3 files changed, 28 insertions(+), 18 deletions(-) diff --git a/src/__tests__/workbuddy-generation.test.ts b/src/__tests__/workbuddy-generation.test.ts index 3b11861d0..d8cb1e788 100644 --- a/src/__tests__/workbuddy-generation.test.ts +++ b/src/__tests__/workbuddy-generation.test.ts @@ -23,7 +23,7 @@ vi.mock('../utils/logger.js', () => ({ import { loadLocalConfig, loadTeamConfig } from '../config.js'; import { buildChecks, resolveDoctorContext, type Check } from '../doctor.js'; import { isToolInstalledForConfig } from '../resources/base.js'; -import { writableRulesTools } from '../resources/rules.js'; +import { rulesReceivingTools } from '../resources/rules.js'; import { DEFAULT_TOOL_PATHS, scopedToolPaths } from '../types.js'; import type { LocalConfig, TeamaiConfig } from '../types.js'; @@ -228,12 +228,12 @@ describe('doctor on a WorkBuddy member with only the 5.7+ root', () => { }); /** - * Pull reports rules as synced only when some tool's rules target is - * writable (#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. + * 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('writableRulesTools', () => { +describe('rulesReceivingTools', () => { let root: string; let home: string; @@ -261,13 +261,21 @@ describe('writableRulesTools', () => { 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 writableRulesTools(teamConfig({ workbuddy: DEFAULT_TOOL_PATHS.workbuddy }), localConfig())).toEqual([]); + 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 writableRulesTools(teamConfig(toolPaths), localConfig())).toEqual(['codex']); + expect(await rulesReceivingTools(teamConfig(toolPaths), localConfig())).toEqual(['codex']); }); }); diff --git a/src/pull.ts b/src/pull.ts index bcb97ed8a..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, writableRulesTools } from './resources/rules.js'; +import { ruleOrigin, rulesReceivingTools } from './resources/rules.js'; import { deliveredDocFiles, describeLinkedDocsRoot, isLinkedDocsRoot, isTeamDocsDirectory, keepDocsSearchWhitelist, listStaleDocDirectories, membersDocs, resolveDesiredDocs, resolveDocsDestination, @@ -1974,7 +1974,7 @@ async function pullForScope( // 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 writableRulesTools(freshConfig, localConfig)).length > 0) { + 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`); diff --git a/src/resources/rules.ts b/src/resources/rules.ts index 779b7ef4d..c7d86e5ef 100644 --- a/src/resources/rules.ts +++ b/src/resources/rules.ts @@ -1383,16 +1383,18 @@ async function rulesLocations( } /** - * Tools of the team's `toolPaths` whose rules target this pull can write in - * this scope — a directory surface or a single file like Codex's `AGENTS.md` - * alike; only `writable` decides. 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 nothing was written, whatever the desired - * count said. + * 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 writableRulesTools(teamConfig: TeamaiConfig, localConfig: LocalConfig): Promise { +export async function rulesReceivingTools(teamConfig: TeamaiConfig, localConfig: LocalConfig): Promise { const locations = await rulesLocations(teamConfig, localConfig); - return locations.filter(({ location }) => writable(location) !== null).map(({ tool }) => tool); + return locations + .filter(({ location }) => location.channel === 'run-time' ? location.gates.every((gate) => gate.met) : writable(location) !== null) + .map(({ tool }) => tool); } /** From bc3939c105a26c6eb23f44be2ae4cf5088d27aa0 Mon Sep 17 00:00:00 2001 From: Saul Moro Date: Mon, 26 Oct 2026 13:45:46 +0100 Subject: [PATCH 11/11] docs: note what a WorkBuddy member with only .workbuddy-ai gets (#1273) Skills move to `.workbuddy-ai/skills`; every other surface waits for `.workbuddy/`, and doctor names the held ones. The troubleshooting note no longer sends that member to register hooks by hand. --- docs/guide/sharing.md | 2 ++ docs/guide/zh-CN/sharing.md | 2 ++ skill-data/core/references/troubleshooting.md | 6 +++++- skill-data/setup/references/manage-admin.md | 2 ++ 4 files changed, 11 insertions(+), 1 deletion(-) 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 ba1544aef..5e2969292 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