From b014c473dbc9b1a3f39f4ad965add34bd1067b3e Mon Sep 17 00:00:00 2001 From: Saul Moro Date: Fri, 2 Oct 2026 01:34:48 +0200 Subject: [PATCH 01/20] fix(openclaw): dispatch hooks on emitted events and enable their entries (#946) --- CHANGELOG.md | 1 + docs/usage-guide.md | 2 + docs/usage-guide.zh-CN.md | 2 + package-lock.json | 4 +- package.json | 1 + src/__tests__/doctor-delivery.test.ts | 39 +++ src/__tests__/fixtures/openclaw/events.ts | 77 +++++ src/__tests__/hooks-cmd.test.ts | 2 +- src/__tests__/openclaw-hooks.test.ts | 367 ++++++++++++++++++++- src/__tests__/uninstall.test.ts | 6 + src/builtin-hooks.ts | 5 +- src/doctor.ts | 17 + src/hooks.ts | 3 +- src/openclaw-hooks.ts | 370 ++++++++++++++++------ src/uninstall.ts | 16 +- 15 files changed, 795 insertions(+), 117 deletions(-) create mode 100644 src/__tests__/fixtures/openclaw/events.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index fd5fc26f3..b36c068aa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -45,6 +45,7 @@ All notable changes to this project will be documented in this file. See [standa ### 🐛 Bug Fixes +- teamai's OpenClaw hook runs. Its handler read an `event` field OpenClaw never sends and subscribed to `session:start`, which OpenClaw does not emit; its `HOOK.md` name was not valid YAML; and OpenClaw loads a workspace hook only once `openclaw.json` enables its entry, which teamai never wrote. The handler now keys on the event's `type` and `action`, runs `session-start` on `command:new`, `command:reset`, `session:auto-reset` and `gateway:startup` and `prompt-submit` on `message:received`, and passes the event's workspace as the hook's `cwd`. init, pull and `hooks inject` add `hooks.internal.entries.teamai-status-report.enabled`, unless that would narrow OpenClaw's open-ended hook discovery or you switched it off, in which case they warn and name `openclaw hooks enable teamai-status-report` (earlier versions set `hooks.internal.enabled` when `OPENCLAW_STATE_DIR` was set, so those machines see this warning until the command is run); `hooks remove` and uninstall take the entry out. The workspace and config follow `OPENCLAW_PROFILE`, `OPENCLAW_CONFIG_PATH` and `OPENCLAW_WORKSPACE_DIR` as OpenClaw does, a server-pushed `SessionStart` or `UserPromptSubmit` agent hook subscribes to the events OpenClaw emits and gets its own entry (`teamai-agent-`) so the allowlist keeps it, and `teamai doctor` fails `OpenClaw hook enabled` while the entry is missing or off (for [#946](https://github.com/Tencent/teamai-cli/issues/946)). - Team rules reach Codex, `codex-internal` and `tcodex`. Pull used to copy them to `.codex/rules/.md`, which Codex does not read, and `teamai doctor` reported them delivered. In user scope they now go into a team-rules block of the tool's own `AGENTS.md` (`~/.codex/AGENTS.md`), beside the culture, shared-instructions and recall blocks. In a project the session-start hook adds the project's rules and those blocks to each session, and to a spawned subagent through a new `SubagentStart` entry, with `additionalContextLimit: 0` so Codex keeps them whole; pull leaves the project `AGENTS.md`, which other tools read too, unchanged. Pull removes the `.md` copies earlier pulls left in the Codex rules directory at its recorded `toolRoots` location, including a publisher's bare local copy, keeping an edited one with a warning. Pull and uninstall keep a removed rule's legacy copy unless it matches its recorded delivery hash; a missing record proves nothing about local edits. A path-scoped rule is rendered without frontmatter after an `Applies to files matching: ` line, in Hermes' `SOUL.md` too. `teamai doctor` checks the user-scope block and, in a project, both hook entries' limit. The public Codex asks once to approve the changed teamai hooks (for [#938](https://github.com/Tencent/teamai-cli/issues/938)). - `teamai pull` names the skills it removes because they are no longer delivered here, in one line, instead of a `debug` line calling them excluded and a summary that says `No resources to sync`. Picking a role or project, or an admin adding `manifest/projects.yaml`, takes root skills away, since the root `skills/` is then the tag catalog; the line then says that `teamai tags subscribe ` brings one back. A namespace skill removed because its namespace is no longer active is named too. The usage guide and the multi-project design no longer say a member with no project still gets `common` or every root item (for [#911](https://github.com/Tencent/teamai-cli/issues/911)). - `teamai pull` keeps the `teamai tags subscribe ` recovery line when the skill directory it removes is byte-identical to an inactive namespace copy: that copy made the namespace cleanup phase remove the directory first, and the hint was lost, because pull inferred whether a root skill had left from which phase did the removing. The hint now follows the repo — it appears exactly when the team repo holds the removed skill at the root, the copy a tag delivers — so a namespace-only skill removed on deactivation is still named without the hint. The usage guide no longer says a member with no role gets no skills at all, in English or Chinese: root skills still arrive through a tag (review of [#917](https://github.com/Tencent/teamai-cli/pull/917)). diff --git a/docs/usage-guide.md b/docs/usage-guide.md index 029152495..c09da66a5 100644 --- a/docs/usage-guide.md +++ b/docs/usage-guide.md @@ -2049,6 +2049,8 @@ The inject and remove commands only touch tools you actually have installed (i.e In non-self project scope, `hooks remove` removes this checkout's gated team hooks from HOME and Claude/Codex team hooks from the main checkout. Other projects' gated team hooks stay in HOME; shared built-in hooks are removed. +> **OpenClaw** — teamai's hook is a workspace hook, `/hooks/teamai-status-report`. It runs `session-start` on `command:new`, `command:reset`, `session:auto-reset` and `gateway:startup`, and `prompt-submit` on `message:received`, with the event's workspace as the hook's `cwd`. OpenClaw loads a workspace hook only when `openclaw.json` enables its entry, so init, pull and `hooks inject` add `hooks.internal.entries.teamai-status-report.enabled: true`, and `hooks remove` and uninstall take it out. When OpenClaw loads every hook it discovers (`hooks.internal.enabled: true` with no named entries), that first entry would turn discovery into an allowlist and stop your other hooks, so teamai leaves the config alone and warns; it does the same when you switched the hook or internal hooks off, or when `openclaw.json` is not plain JSON. Run `openclaw hooks enable teamai-status-report` to enable it yourself. Earlier teamai versions set `hooks.internal.enabled: true` themselves when `OPENCLAW_STATE_DIR` was set, so such a machine sees this warning until you do. A server-pushed agent hook lands in `/hooks/` and gets its own entry, `teamai-agent-`. The workspace and config are found the way OpenClaw finds them: `OPENCLAW_CONFIG_PATH`, `OPENCLAW_STATE_DIR` or `OPENCLAW_PROFILE` (`~/.openclaw-`), then `agents.defaults.workspace`, `OPENCLAW_WORKSPACE_DIR`, or `/workspace`. `doctor` fails `OpenClaw hook enabled` while the entry is missing or off. + On Windows, the built-in hook dispatch commands that shell out through bash (e.g. Claude, Codex, Cursor, Copilot CLI) reference Git Bash by absolute path — standard install locations first, then the `HKLM\SOFTWARE\GitForWindows` registry as fallback — so they never resolve to the WSL `bash.exe` launcher; if Git Bash cannot be found they degrade to bare `bash`. Cursor also loads `~/.claude/settings.json`, and Copilot CLI loads a trusted project's `.claude/settings.json` (self mode writes hooks there; Copilot does not load `~/.claude/settings.json`). `hook-dispatch --tool claude` exits only when that other host's own teamai hooks are on disk: `~/.cursor/hooks.json` or `$CURSOR_PROJECT_DIR/.cursor/hooks.json` contains `--tool cursor`, or `$COPILOT_PROJECT_DIR/.github/hooks/teamai.json` contains `--tool copilot`. Team hook commands written for `claude` use the same check. A setup with only Claude keeps running inside Cursor, because there is no second copy. `COPILOT_CLI` is not a signal: Copilot sets it on every subprocess, including a Claude session started from its shell. Claude Code sets neither `CURSOR_VERSION` nor `COPILOT_PROJECT_DIR`. Run `teamai pull` or `teamai hooks inject` again so an already installed team hook picks up the guard. diff --git a/docs/usage-guide.zh-CN.md b/docs/usage-guide.zh-CN.md index 85f0f9cfb..c2da664fa 100644 --- a/docs/usage-guide.zh-CN.md +++ b/docs/usage-guide.zh-CN.md @@ -1894,6 +1894,8 @@ Git hook 安装失败时,`hooks inject`、`init` 和单仓库自动初始化 非-self 的 project scope 中,`hooks remove` 会移除 HOME 中当前 checkout 的门控团队 hooks,以及主 checkout 中 Claude/Codex 的团队 hooks。其他项目的门控团队 hooks 保留在 HOME;共享的内置 hooks 会被移除。 +> **OpenClaw** — teamai 的 hook 是一个 workspace hook,位于 `/hooks/teamai-status-report`。它在 `command:new`、`command:reset`、`session:auto-reset` 和 `gateway:startup` 时运行 `session-start`,在 `message:received` 时运行 `prompt-submit`,并以事件中的 workspace 作为 hook 的 `cwd`。OpenClaw 只有在 `openclaw.json` 启用了某个 workspace hook 的条目时才会加载它,因此 init、pull 和 `hooks inject` 会写入 `hooks.internal.entries.teamai-status-report.enabled: true`,`hooks remove` 和 uninstall 会将其移除。若 OpenClaw 正在加载它发现的所有 hook(`hooks.internal.enabled: true` 且没有具名条目),新增第一个条目会把发现模式变成白名单,从而停掉你的其他 hook,所以 teamai 不改动配置,只给出警告;当你关闭了该 hook 或整个 internal hooks,或 `openclaw.json` 不是纯 JSON 时也同样处理。此时请自行运行 `openclaw hooks enable teamai-status-report`。旧版 teamai 在设置了 `OPENCLAW_STATE_DIR` 时会自行写入 `hooks.internal.enabled: true`,因此这类机器在你运行该命令前都会看到这条警告。服务端下发的 agent hook 位于 `/hooks/`,并有自己的条目 `teamai-agent-`。workspace 与配置的查找方式与 OpenClaw 一致:`OPENCLAW_CONFIG_PATH`、`OPENCLAW_STATE_DIR` 或 `OPENCLAW_PROFILE`(`~/.openclaw-`),然后是 `agents.defaults.workspace`、`OPENCLAW_WORKSPACE_DIR` 或 `/workspace`。条目缺失或被关闭时,`doctor` 的 `OpenClaw hook enabled` 检查会失败。 + 在 Windows 上,经由 bash 执行的内置 hook 派发命令(如 Claude、Codex、Cursor、Copilot CLI)会以绝对路径引用 Git Bash——先查标准安装位置,再回退到 `HKLM\SOFTWARE\GitForWindows` 注册表——从而避免解析到 WSL 的 `bash.exe`;若找不到 Git Bash,则退回裸 `bash`。 Cursor 也会加载 `~/.claude/settings.json`。Copilot CLI 会加载受信任项目里的 `.claude/settings.json`(self mode 把 hook 写在项目里;Copilot 不加载 `~/.claude/settings.json`)。只有另一边的 teamai hook 已经在磁盘上时,`hook-dispatch --tool claude` 才会退出:`~/.cursor/hooks.json` 或 `$CURSOR_PROJECT_DIR/.cursor/hooks.json` 含有 `--tool cursor`,或 `$COPILOT_PROJECT_DIR/.github/hooks/teamai.json` 含有 `--tool copilot`。写给 `claude` 的团队 hook 命令用同一判断。只启用了 Claude 时,Cursor 里这份 hook 照常运行,因为没有第二份可以接替。`COPILOT_CLI` 不能当信号:Copilot 会给每个子进程设置它,包括从它的 shell 里启动的 Claude。Claude Code 不会设置 `CURSOR_VERSION` 或 `COPILOT_PROJECT_DIR`。已经装好的团队 hook 需要再跑一次 `teamai pull` 或 `teamai hooks inject`,才会带上这个判断。 diff --git a/package-lock.json b/package-lock.json index 1d10995a2..9cb4c9d2e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -34,6 +34,7 @@ "@types/node": "^20.17.0", "@types/semver": "^7.8.0", "@vitest/coverage-v8": "^3.2.7", + "esbuild": "^0.27.3", "fast-check": "^4.10.2", "opencode-ai": "1.18.23", "oxlint": "1.85.0", @@ -2638,10 +2639,11 @@ }, "node_modules/esbuild": { "version": "0.27.3", - "resolved": "https://mirrors.tencent.com/npm/esbuild/-/esbuild-0.27.3.tgz", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz", "integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==", "dev": true, "hasInstallScript": true, + "license": "MIT", "bin": { "esbuild": "bin/esbuild" }, diff --git a/package.json b/package.json index ca543f24d..52a6cf4c4 100644 --- a/package.json +++ b/package.json @@ -81,6 +81,7 @@ "@types/node": "^20.17.0", "@types/semver": "^7.8.0", "@vitest/coverage-v8": "^3.2.7", + "esbuild": "^0.27.3", "fast-check": "^4.10.2", "opencode-ai": "1.18.23", "oxlint": "1.85.0", diff --git a/src/__tests__/doctor-delivery.test.ts b/src/__tests__/doctor-delivery.test.ts index b2886ac7c..dfc7822c1 100644 --- a/src/__tests__/doctor-delivery.test.ts +++ b/src/__tests__/doctor-delivery.test.ts @@ -179,6 +179,45 @@ describe('doctor — skills delivered on disk', () => { expect(await installed!.check()).toBe(true); }); + describe('OpenClaw hook enabled', () => { + const NAME = 'OpenClaw hook enabled'; + const stateDir = (): string => path.join(homeDir, '.openclaw'); + + async function hookCheck(): Promise { + const ctx = await resolveDoctorContext(); + if (!ctx) throw new Error('expected a resolved doctor context'); + return (await buildChecks(ctx)).find((c) => c.name === NAME); + } + + beforeEach(async () => { + for (const v of ['OPENCLAW_STATE_DIR', 'OPENCLAW_PROFILE', 'OPENCLAW_CONFIG_PATH', 'OPENCLAW_WORKSPACE_DIR']) vi.stubEnv(v, ''); + teamConfig.toolPaths = { openclaw: { skills: '.openclaw/skills' } }; + // The hook teamai injected, in the default workspace. + await fse.ensureDir(path.join(stateDir(), 'workspace', 'hooks', 'teamai-status-report')); + }); + + it.each([ + ['the entry is missing', { hooks: { internal: { enabled: true } } }, false], + ['the entry is disabled', { hooks: { internal: { entries: { 'teamai-status-report': { enabled: false } } } } }, false], + ['internal hooks are off', { hooks: { internal: { enabled: false, entries: { 'teamai-status-report': { enabled: true } } } } }, false], + ['the entry is enabled', { hooks: { internal: { enabled: true, entries: { 'teamai-status-report': { enabled: true } } } } }, true], + ])('reports %s', async (_label, cfg, ok) => { + await fse.writeJson(path.join(stateDir(), 'openclaw.json'), cfg); + + const check = await hookCheck(); + + expect(check).toBeDefined(); + expect(await check!.check()).toBe(ok); + expect(check!.fix).toContain('openclaw hooks enable teamai-status-report'); + }); + + it('is not built when teamai has no hook in the workspace', async () => { + await fse.remove(path.join(stateDir(), 'workspace', 'hooks')); + + expect(await hookCheck()).toBeUndefined(); + }); + }); + it('reports each installed tool separately', async () => { teamConfig.toolPaths = { claude: { skills: '.claude/skills' }, diff --git a/src/__tests__/fixtures/openclaw/events.ts b/src/__tests__/fixtures/openclaw/events.ts new file mode 100644 index 000000000..e66ac7ef2 --- /dev/null +++ b/src/__tests__/fixtures/openclaw/events.ts @@ -0,0 +1,77 @@ +/** + * OpenClaw internal hook events, shaped the way OpenClaw's producers build them + * (openclaw@2026.9.7 source). Every event comes from `createInternalHookEvent` + * (src/hooks/internal-hooks.ts): `{ type, action, sessionKey, context, + * timestamp, messages }`. A handler is called with that event as its only + * argument; there is no `event` field. + * + * Producers: + * - command:new src/gateway/session-create-service.ts (emitCommandHooks) + * - command:reset src/auto-reply/reply/commands-reset-hooks.ts, src/gateway/session-reset-service.ts + * - command:stop src/auto-reply/reply/commands-session-abort.ts (no workspaceDir) + * - session:auto-reset src/hooks/session-auto-reset.ts + * - gateway:startup src/gateway/server-startup-post-attach.ts (sessionKey "gateway:startup") + * - message:received src/auto-reply/reply/message-received-hooks.ts (no workspaceDir) + * - message:sent src/infra/outbound/message-sent-hook.ts + * + * The key list is pinned from docs/automation/hooks/event-types.md (Event types table). + */ + +export const OPENCLAW_EVENT_KEYS = [ + 'command:new', + 'command:reset', + 'command:stop', + 'session:auto-reset', + 'session:compact:before', + 'session:compact:after', + 'session:patch', + 'agent:bootstrap', + 'gateway:startup', + 'gateway:shutdown', + 'gateway:pre-restart', + 'message:received', + 'message:transcribed', + 'message:preprocessed', + 'message:sent', +] as const; + +export interface OpenClawHookEvent { + type: string; + action: string; + sessionKey: string; + context: Record; + timestamp: Date; + messages: string[]; +} + +function event(type: string, action: string, sessionKey: string, context: Record): OpenClawHookEvent { + return { type, action, sessionKey, context, timestamp: new Date(0), messages: [] }; +} + +/** Events for one workspace; `workspaceDir` is where the producer says the agent works. */ +export function openclawEvents(workspaceDir: string): Record { + return { + 'command:new': event('command', 'new', 'agent:main:main', { + agentId: 'main', commandSource: 'webchat', cfg: {}, storePath: '/state/sessions', workspaceDir, + }), + 'command:reset': event('command', 'reset', 'agent:main:main', { + agentId: 'main', commandSource: 'gateway:sessions.reset', cfg: {}, storePath: '/state/sessions', workspaceDir, + }), + 'command:stop': event('command', 'stop', 'agent:main:main', { + sessionId: 's-1', commandSource: 'chat', senderId: 'u-1', + }), + 'session:auto-reset': event('session', 'auto-reset', 'agent:main:main', { + cfg: {}, agentId: 'main', workspaceDir, storePath: '/state/sessions', + sessionEntry: { sessionId: 's-1' }, reason: 'idle', + }), + 'gateway:startup': event('gateway', 'startup', 'gateway:startup', { + cfg: {}, deps: {}, workspaceDir, + }), + 'message:received': event('message', 'received', 'agent:main:main', { + from: 'u-1', content: 'hello', channelId: 'chat', + }), + 'message:sent': event('message', 'sent', 'agent:main:main', { + to: 'u-1', content: 'hi', channelId: 'chat', success: true, + }), + }; +} diff --git a/src/__tests__/hooks-cmd.test.ts b/src/__tests__/hooks-cmd.test.ts index e18532116..542f57c4a 100644 --- a/src/__tests__/hooks-cmd.test.ts +++ b/src/__tests__/hooks-cmd.test.ts @@ -709,7 +709,7 @@ describe('hooksList', () => { expect(omp).toHaveLength(4); expect(omp.join('\n')).not.toContain('[Skill]'); expect(omp.join('\n')).not.toContain('[TodoWrite]'); - // OpenClaw's handler maps session:start and command:new only + // OpenClaw's handler maps its events onto session-start and prompt-submit only // (openclaw-hooks.ts EVENT_MAP). expect(builtinBlock(builtin, 'openclaw')).toEqual([ 'SessionStart → teamai hook-dispatch session-start --tool ', diff --git a/src/__tests__/openclaw-hooks.test.ts b/src/__tests__/openclaw-hooks.test.ts index 6d7eff53a..65b3ace93 100644 --- a/src/__tests__/openclaw-hooks.test.ts +++ b/src/__tests__/openclaw-hooks.test.ts @@ -2,9 +2,15 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import fs from 'node:fs'; import path from 'node:path'; import os from 'node:os'; -import { injectOpenClawHooks, removeOpenClawHooks, OPENCLAW_HOOK_DIR } from '../openclaw-hooks.js'; +import { pathToFileURL } from 'node:url'; +import { transformSync } from 'esbuild'; +import { parseDocument } from 'yaml'; +import { + applyOpenClawAgentHook, removeOpenClawAgentHook, injectOpenClawHooks, removeOpenClawHooks, resolveOpenclawWorkspaceDir, OPENCLAW_HOOK_DIR, +} from '../openclaw-hooks.js'; import { reconcileHooksToAllTools } from '../hooks.js'; import { log } from '../utils/logger.js'; +import { OPENCLAW_EVENT_KEYS, openclawEvents, type OpenClawHookEvent } from './fixtures/openclaw/events.js'; let tmpDir: string; let wsDir: string; @@ -40,23 +46,10 @@ describe('injectOpenClawHooks', () => { expect(hookMd).toContain('metadata:'); expect(hookMd).toContain('"openclaw"'); - expect(hookMd).toContain('session:start'); - expect(hookMd).toContain('command:new'); expect(handler).toContain('hook-dispatch'); expect(handler).toContain('openclaw'); - // Maps OpenClaw events to teamai dispatch events. - expect(handler).toContain('session-start'); - expect(handler).toContain('prompt-submit'); }); - it('enables hooks.internal.enabled in openclaw.json, preserving existing fields', async () => { - await injectOpenClawHooks(wsDir, 'openclaw'); - - const cfg = JSON.parse(fs.readFileSync(path.join(tmpDir, 'openclaw.json'), 'utf-8')); - expect(cfg.hooks.internal.enabled).toBe(true); - // Deep-merge must not clobber pre-existing fields. - expect(cfg.agents.defaults.workspace).toBe(wsDir); - }); it('is idempotent (re-inject overwrites cleanly)', async () => { await injectOpenClawHooks(wsDir, 'openclaw'); @@ -80,6 +73,327 @@ describe('injectOpenClawHooks', () => { }); }); +/** HOOK.md's frontmatter, parsed the way OpenClaw's loader parses it (YAML core schema). */ +function readHookFrontmatter(dir: string): { errors: string[]; data: Record } { + const raw = fs.readFileSync(path.join(dir, 'HOOK.md'), 'utf-8'); + const block = /^---\n([\s\S]*?)\n---/.exec(raw)?.[1] ?? ''; + const doc = parseDocument(block, { schema: 'core', prettyErrors: false }); + return { errors: doc.errors.map((e) => e.message), data: (doc.toJS() ?? {}) as Record }; +} + +describe('injectOpenClawHooks enables the hook in openclaw.json', () => { + const cfgPath = (): string => path.join(tmpDir, 'openclaw.json'); + const writeCfg = (cfg: unknown): void => fs.writeFileSync(cfgPath(), JSON.stringify(cfg, null, 2)); + const readCfg = (): Record => JSON.parse(fs.readFileSync(cfgPath(), 'utf-8')); + + let warn: ReturnType; + beforeEach(() => { warn = vi.spyOn(log, 'warn').mockImplementation(() => {}); }); + afterEach(() => { warn.mockRestore(); }); + + it('adds the entry when the config has no master flag, keeping every other field', async () => { + await injectOpenClawHooks(wsDir, 'openclaw'); + + const cfg = readCfg(); + // Workspace hooks load only with their own entry enabled. + expect(cfg.hooks.internal.entries['teamai-status-report']).toEqual({ enabled: true }); + // The entry alone enables it; the master flag stays unset so removing the + // entry restores the config exactly. + expect(cfg.hooks.internal.enabled).toBeUndefined(); + expect(cfg.agents.defaults.workspace).toBe(wsDir); + expect(warn).not.toHaveBeenCalled(); + }); + + it('adds the entry beside existing named entries', async () => { + writeCfg({ + agents: { defaults: { workspace: wsDir } }, + hooks: { internal: { enabled: true, entries: { 'session-memory': { enabled: true, env: { A: '1' } } } } }, + }); + + await injectOpenClawHooks(wsDir, 'openclaw'); + + expect(readCfg().hooks.internal).toEqual({ + enabled: true, + entries: { + 'session-memory': { enabled: true, env: { A: '1' } }, + 'teamai-status-report': { enabled: true }, + }, + }); + }); + + it('is a no-op once the entry is enabled', async () => { + await injectOpenClawHooks(wsDir, 'openclaw'); + const before = fs.readFileSync(cfgPath(), 'utf-8'); + const mtime = fs.statSync(cfgPath()).mtimeMs; + + await injectOpenClawHooks(wsDir, 'openclaw'); + + expect(fs.readFileSync(cfgPath(), 'utf-8')).toBe(before); + expect(fs.statSync(cfgPath()).mtimeMs).toBe(mtime); + }); + + it.each([ + ['open-ended discovery (master flag on, no named entries)', { enabled: true }, 'allowlist'], + ['the entry disabled', { entries: { 'teamai-status-report': { enabled: false } } }, 'disabled'], + ['internal hooks switched off', { enabled: false }, 'switched off'], + ])('leaves the config unchanged and warns with %s', async (_label, internal, wording) => { + writeCfg({ agents: { defaults: { workspace: wsDir } }, hooks: { internal } }); + const before = fs.readFileSync(cfgPath(), 'utf-8'); + + await injectOpenClawHooks(wsDir, 'openclaw'); + + expect(fs.readFileSync(cfgPath(), 'utf-8')).toBe(before); + expect(warn).toHaveBeenCalledWith(expect.stringContaining(wording)); + expect(warn).toHaveBeenCalledWith(expect.stringContaining('openclaw hooks enable teamai-status-report')); + }); + + it('leaves a config it cannot parse unchanged and warns', async () => { + const json5 = '{\n // comment\n agents: { defaults: { workspace: "x" } },\n}\n'; + fs.writeFileSync(cfgPath(), json5); + + await injectOpenClawHooks(wsDir, 'openclaw'); + + expect(fs.readFileSync(cfgPath(), 'utf-8')).toBe(json5); + expect(warn).toHaveBeenCalledWith(expect.stringContaining('openclaw hooks enable teamai-status-report')); + }); + + it('creates no state dir that OpenClaw does not have', async () => { + process.env.OPENCLAW_STATE_DIR = path.join(tmpDir, 'missing-state'); + + await injectOpenClawHooks(wsDir, 'openclaw'); + + expect(fs.existsSync(path.join(tmpDir, 'missing-state'))).toBe(false); + }); +}); + +describe('the OpenClaw workspace and state dir follow OpenClaw\'s resolution', () => { + let home: string; + beforeEach(() => { + home = path.join(tmpDir, 'home'); + fs.mkdirSync(home, { recursive: true }); + vi.stubEnv('HOME', home); + delete process.env.OPENCLAW_STATE_DIR; + }); + afterEach(() => { vi.unstubAllEnvs(); }); + + it('uses ~/.openclaw- for OPENCLAW_PROFILE, for the hook and its entry', async () => { + vi.stubEnv('OPENCLAW_PROFILE', 'work'); + const state = path.join(home, '.openclaw-work'); + fs.mkdirSync(path.join(state, 'workspace'), { recursive: true }); + fs.mkdirSync(path.join(home, '.openclaw', 'workspace'), { recursive: true }); + + expect(await resolveOpenclawWorkspaceDir()).toBe(path.join(state, 'workspace')); + await injectOpenClawHooks(undefined, 'openclaw'); + + expect(fs.existsSync(path.join(state, 'workspace', 'hooks', OPENCLAW_HOOK_DIR, 'handler.ts'))).toBe(true); + const cfg = JSON.parse(fs.readFileSync(path.join(state, 'openclaw.json'), 'utf-8')); + expect(cfg.hooks.internal.entries['teamai-status-report'].enabled).toBe(true); + expect(fs.existsSync(path.join(home, '.openclaw', 'openclaw.json'))).toBe(false); + }); + + it('treats the "default" profile as no profile', async () => { + vi.stubEnv('OPENCLAW_PROFILE', 'default'); + fs.mkdirSync(path.join(home, '.openclaw', 'workspace'), { recursive: true }); + + expect(await resolveOpenclawWorkspaceDir()).toBe(path.join(home, '.openclaw', 'workspace')); + }); + + it('uses OPENCLAW_WORKSPACE_DIR over the state dir default', async () => { + const custom = path.join(tmpDir, 'custom-ws'); + fs.mkdirSync(custom, { recursive: true }); + fs.mkdirSync(path.join(home, '.openclaw', 'workspace'), { recursive: true }); + vi.stubEnv('OPENCLAW_WORKSPACE_DIR', custom); + + expect(await resolveOpenclawWorkspaceDir()).toBe(custom); + }); + + it('uses agents.defaults.workspace from the config over OPENCLAW_WORKSPACE_DIR, as OpenClaw does', async () => { + const configured = path.join(tmpDir, 'configured-ws'); + const custom = path.join(tmpDir, 'custom-ws'); + fs.mkdirSync(configured, { recursive: true }); + fs.mkdirSync(custom, { recursive: true }); + fs.mkdirSync(path.join(home, '.openclaw'), { recursive: true }); + fs.writeFileSync(path.join(home, '.openclaw', 'openclaw.json'), JSON.stringify({ agents: { defaults: { workspace: configured } } })); + vi.stubEnv('OPENCLAW_WORKSPACE_DIR', custom); + + expect(await resolveOpenclawWorkspaceDir()).toBe(configured); + }); + + it('uses /workspace when the state dir is set', async () => { + const state = path.join(tmpDir, 'state'); + fs.mkdirSync(path.join(state, 'workspace'), { recursive: true }); + vi.stubEnv('OPENCLAW_STATE_DIR', state); + vi.stubEnv('OPENCLAW_PROFILE', 'work'); + + expect(await resolveOpenclawWorkspaceDir()).toBe(path.join(state, 'workspace')); + }); + + it('returns null when the workspace OpenClaw would use does not exist', async () => { + // Another workspace existing does not make it the one OpenClaw reads. + vi.stubEnv('OPENCLAW_PROFILE', 'work'); + fs.mkdirSync(path.join(home, '.openclaw', 'workspace'), { recursive: true }); + + expect(await resolveOpenclawWorkspaceDir()).toBeNull(); + }); +}); + +describe('HOOK.md', () => { + it('parses as YAML and subscribes only to events OpenClaw emits', async () => { + await injectOpenClawHooks(wsDir, 'openclaw'); + const { errors, data } = readHookFrontmatter(path.join(wsDir, 'hooks', OPENCLAW_HOOK_DIR)); + + // A frontmatter error marks the hook's metadata invalid, and OpenClaw then + // refuses to load it. + expect(errors).toEqual([]); + const openclaw = (data.metadata as { openclaw: { events: string[]; hookKey: string } }).openclaw; + expect(openclaw.events.length).toBeGreaterThan(0); + for (const key of openclaw.events) expect(OPENCLAW_EVENT_KEYS).toContain(key); + expect(openclaw.events).not.toContain('session:start'); + // The key `hooks.internal.entries.` enables it under. + expect(openclaw.hookKey).toBe('teamai-status-report'); + }); +}); + +describe('the generated handler', () => { + let binDir: string; + let logFile: string; + let origPath: string | undefined; + + beforeEach(() => { + // A `teamai` first on PATH that records its argv and stdin, one line each call. + binDir = path.join(tmpDir, 'bin'); + logFile = path.join(tmpDir, 'teamai-calls.log'); + fs.mkdirSync(binDir, { recursive: true }); + fs.writeFileSync( + path.join(binDir, 'teamai'), + `#!/bin/sh\nstdin=$(cat)\nprintf '%s\\t%s\\n' "$*" "$stdin" >> "${logFile}"\n`, + { mode: 0o755 }, + ); + origPath = process.env.PATH; + process.env.PATH = `${binDir}${path.delimiter}${origPath ?? ''}`; + }); + + afterEach(() => { + process.env.PATH = origPath; + }); + + /** Transpile handler.ts the way a TypeScript-stripping loader would, beside the original. */ + async function loadHandler(): Promise<(event: unknown) => Promise> { + await injectOpenClawHooks(wsDir, 'openclaw'); + const dir = path.join(wsDir, 'hooks', OPENCLAW_HOOK_DIR); + const { code } = transformSync(fs.readFileSync(path.join(dir, 'handler.ts'), 'utf-8'), { loader: 'ts', format: 'esm' }); + const out = path.join(dir, 'handler.test-build.mjs'); + fs.writeFileSync(out, code); + const mod = await import(pathToFileURL(out).href) as { default: (event: unknown) => Promise }; + return mod.default; + } + + function calls(): Array<{ argv: string; stdin: Record }> { + if (!fs.existsSync(logFile)) return []; + return fs.readFileSync(logFile, 'utf-8').trim().split('\n').filter(Boolean).map((line) => { + const [argv, stdin] = line.split('\t'); + return { argv, stdin: JSON.parse(stdin) as Record }; + }); + } + + /** Wait until the shim has logged `n` calls, then a little longer to catch extras. */ + async function settle(n: number): Promise { + await vi.waitFor(() => expect(calls().length).toBeGreaterThanOrEqual(n), { timeout: 5000, interval: 25 }); + await new Promise((r) => setTimeout(r, 200)); + } + + const workspace = (): string => path.join(tmpDir, 'agent-ws'); + + it.each([ + ['command:new', 'session-start'], + ['command:reset', 'session-start'], + ['session:auto-reset', 'session-start'], + ['gateway:startup', 'session-start'], + ])('runs hook-dispatch for %s in the event\'s workspace', async (key, dispatch) => { + const handler = await loadHandler(); + const event = openclawEvents(workspace())[key]; + + await handler(event); + await settle(1); + + expect(calls()).toEqual([{ + argv: `hook-dispatch ${dispatch} --tool openclaw`, + stdin: { cwd: workspace(), session_id: event.sessionKey }, + }]); + }); + + it('runs prompt-submit for message:received in the hook\'s own workspace, which the event does not carry', async () => { + const handler = await loadHandler(); + + await handler(openclawEvents(workspace())['message:received']); + await settle(1); + + const [call] = calls(); + expect(calls()).toHaveLength(1); + expect(call.argv).toBe('hook-dispatch prompt-submit --tool openclaw'); + expect(fs.realpathSync(call.stdin.cwd as string)).toBe(fs.realpathSync(wsDir)); + }); + + it('runs nothing for unmapped events or an event without type and action', async () => { + const handler = await loadHandler(); + const events = openclawEvents(workspace()); + const ignored: unknown[] = [events['command:stop'], events['message:sent'], {}, { context: {} }, undefined]; + + for (const event of ignored) await handler(event as OpenClawHookEvent); + // A mapped event last, so the wait proves the earlier ones had their chance. + await handler(events['command:new']); + await settle(1); + + expect(calls().map((c) => c.argv)).toEqual(['hook-dispatch session-start --tool openclaw']); + }); +}); + +describe('applyOpenClawAgentHook', () => { + it.each([ + ['SessionStart', ['command:new', 'command:reset']], + ['UserPromptSubmit', ['message:received']], + ])('subscribes a %s hook to the events OpenClaw emits for it', async (event, expected) => { + await applyOpenClawAgentHook({ slug: 'team-check', event, command: 'echo hi' }); + + // Server-pushed hooks are managed hooks, under /hooks. + const { errors, data } = readHookFrontmatter(path.join(tmpDir, 'hooks', 'team-check')); + expect(errors).toEqual([]); + expect((data.metadata as { openclaw: { events: string[] } }).openclaw.events).toEqual(expected); + }); + + it('selects the hook in openclaw.json, so the teamai entry does not leave it out of the allowlist', async () => { + const cfgPath = path.join(tmpDir, 'openclaw.json'); + await injectOpenClawHooks(wsDir, 'openclaw'); + await applyOpenClawAgentHook({ slug: 'team-check', event: 'SessionStart', command: 'echo hi' }); + + const { data } = readHookFrontmatter(path.join(tmpDir, 'hooks', 'team-check')); + const hookKey = (data.metadata as { openclaw: { hookKey: string } }).openclaw.hookKey; + expect(JSON.parse(fs.readFileSync(cfgPath, 'utf-8')).hooks.internal.entries).toEqual({ + 'teamai-status-report': { enabled: true }, + [hookKey]: { enabled: true }, + }); + + await removeOpenClawAgentHook({ slug: 'team-check' }); + expect(fs.existsSync(path.join(tmpDir, 'hooks', 'team-check'))).toBe(false); + expect(JSON.parse(fs.readFileSync(cfgPath, 'utf-8')).hooks.internal.entries).toEqual({ + 'teamai-status-report': { enabled: true }, + }); + }); + + it('adds no entry where OpenClaw already loads every managed hook it discovers', async () => { + const cfgPath = path.join(tmpDir, 'openclaw.json'); + const cfg = { agents: { defaults: { workspace: wsDir } }, hooks: { internal: { enabled: true } } }; + fs.writeFileSync(cfgPath, JSON.stringify(cfg)); + const warn = vi.spyOn(log, 'warn').mockImplementation(() => {}); + try { + await applyOpenClawAgentHook({ slug: 'team-check', event: 'SessionStart', command: 'echo hi' }); + expect(JSON.parse(fs.readFileSync(cfgPath, 'utf-8'))).toEqual(cfg); + expect(warn).not.toHaveBeenCalled(); + } finally { + warn.mockRestore(); + } + }); +}); + describe('removeOpenClawHooks', () => { it('removes the injected hook dir and is a no-op when absent', async () => { const hooksDir = path.join(wsDir, 'hooks'); @@ -109,6 +423,31 @@ describe('reconcileHooksToAllTools routes the OpenClaw family to its adapter', ( expect(fs.existsSync(hookDir)).toBe(false); }); + it('removeAll takes the hook entry back out of openclaw.json', async () => { + const cfgPath = path.join(tmpDir, 'openclaw.json'); + const manifest = path.join(tmpDir, 'managed-hooks.json'); + const original = { agents: { defaults: { workspace: wsDir } }, hooks: { internal: { entries: { other: { enabled: true } } } } }; + fs.writeFileSync(cfgPath, JSON.stringify(original)); + + await reconcileHooksToAllTools(toolPaths, tmpDir, [], manifest); + expect(JSON.parse(fs.readFileSync(cfgPath, 'utf-8')).hooks.internal.entries['teamai-status-report']).toEqual({ enabled: true }); + + await reconcileHooksToAllTools(toolPaths, tmpDir, [], manifest, { removeAll: true }); + expect(JSON.parse(fs.readFileSync(cfgPath, 'utf-8'))).toEqual(original); + }); + + it('removeAll keeps the entry when removing it would turn on every discovered hook', async () => { + // Master flag on with this as the only named entry: without it OpenClaw + // would load every hook it discovers. + const cfgPath = path.join(tmpDir, 'openclaw.json'); + const cfg = { agents: { defaults: { workspace: wsDir } }, hooks: { internal: { enabled: true, entries: { 'teamai-status-report': { enabled: true } } } } }; + fs.writeFileSync(cfgPath, JSON.stringify(cfg)); + + await reconcileHooksToAllTools(toolPaths, tmpDir, [], path.join(tmpDir, 'managed-hooks.json'), { removeAll: true }); + + expect(JSON.parse(fs.readFileSync(cfgPath, 'utf-8'))).toEqual(cfg); + }); + it('does nothing when the workspace cannot be resolved', async () => { delete process.env.OPENCLAW_STATE_DIR; const home = path.join(tmpDir, 'empty-home'); diff --git a/src/__tests__/uninstall.test.ts b/src/__tests__/uninstall.test.ts index 81d6af348..017ecb1ad 100644 --- a/src/__tests__/uninstall.test.ts +++ b/src/__tests__/uninstall.test.ts @@ -1757,6 +1757,11 @@ describe('uninstall', () => { await fse.ensureDir(ocHookDir); await fse.writeFile(path.join(ocHookDir, 'HOOK.md'), '---\nname: [teamai] status-report\n---\n'); await fse.writeFile(path.join(ocHookDir, 'handler.ts'), '// teamai'); + // The entry teamai added to enable the hook, beside one of the user's. + const ocConfig = path.join(homeDir, '.openclaw', 'openclaw.json'); + await fse.writeJson(ocConfig, { + hooks: { internal: { entries: { 'teamai-status-report': { enabled: true }, mine: { enabled: true } } } }, + }); const teamConfig = makeTeamConfig({ toolPaths: { @@ -1777,6 +1782,7 @@ describe('uninstall', () => { // The OpenClaw HOOK.md dir must be removed (regression: previously leaked). expect(await fse.pathExists(ocHookDir)).toBe(false); + expect(await fse.readJson(ocConfig)).toEqual({ hooks: { internal: { entries: { mine: { enabled: true } } } } }); }); it('project scope 卸载同时清掉用户级和项目级的 OpenCode plugin', async () => { diff --git a/src/builtin-hooks.ts b/src/builtin-hooks.ts index e31e41936..979ed67f3 100644 --- a/src/builtin-hooks.ts +++ b/src/builtin-hooks.ts @@ -492,8 +492,9 @@ const ADAPTER_BUILTIN_HOOKS: Record 'Hook dispatch prompt-submit', ], }, - // openclaw-hooks.ts EVENT_MAP maps session:start and command:new only, and - // its generated handler spawns the dispatcher with argv. Only `openclaw`: + // openclaw-hooks.ts EVENT_MAP maps OpenClaw's events onto session-start and + // prompt-submit only, and its generated handler spawns the dispatcher with + // argv. Only `openclaw`: // the other claw variants share its workspace resolver, so reconciliation // does not route them (see reconcileHooksToAllTools). openclaw: { keys: ['Hook dispatch session-start', 'Hook dispatch prompt-submit'] }, diff --git a/src/doctor.ts b/src/doctor.ts index a3fcf8ab0..b8b90dfab 100644 --- a/src/doctor.ts +++ b/src/doctor.ts @@ -262,6 +262,23 @@ async function buildHookChecks( }); continue; } + if (tool === 'openclaw') { + // OpenClaw has no settings file: its hook is a workspace hook directory, + // which OpenClaw loads only once openclaw.json enables its entry. + const { resolveOpenclawWorkspaceDir, isOpenclawHookEnabled, OPENCLAW_HOOK_DIR, OPENCLAW_HOOK_KEY } = await import('./openclaw-hooks.js'); + const workspace = await resolveOpenclawWorkspaceDir(); + if (!workspace || !await pathExists(path.join(workspace, 'hooks', OPENCLAW_HOOK_DIR))) continue; + checks.push({ + name: 'OpenClaw hook enabled', + source: 'local', + check: isOpenclawHookEnabled, + fix: `OpenClaw loads teamai's hook only when openclaw.json sets hooks.internal.entries.${OPENCLAW_HOOK_KEY}.enabled ` + + `(and internal hooks are not switched off), so OpenClaw sends no status report or sync. ` + + `Run \`openclaw hooks enable ${OPENCLAW_HOOK_KEY}\`. If it is enabled already, openclaw.json is not plain JSON ` + + `(comments, JSON5), which teamai cannot read: \`openclaw hooks info ${OPENCLAW_HOOK_KEY}\` shows what OpenClaw sees.`, + }); + continue; + } // A standalone hooks file (Copilot) is injected at the config's own scope // (`reconcileTeamHooksForConfig` joins resolveToolBaseDir with the // config-scoped `hooks`), so it is probed from `toolPaths`. Settings-based diff --git a/src/hooks.ts b/src/hooks.ts index ac6caf17b..aec350d9d 100644 --- a/src/hooks.ts +++ b/src/hooks.ts @@ -1879,9 +1879,10 @@ export async function reconcileHooksToAllTools( if (opts.settingsOnly) continue; try { if (opts.removeAll) { - const { removeOpenClawHooks, resolveOpenclawWorkspaceDir } = await import('./openclaw-hooks.js'); + const { removeOpenClawHooks, removeOpenClawHookEntry, resolveOpenclawWorkspaceDir } = await import('./openclaw-hooks.js'); const wsDir = await resolveOpenclawWorkspaceDir(); if (wsDir) await removeOpenClawHooks(path.join(wsDir, 'hooks')); + await removeOpenClawHookEntry(); } else { const { injectOpenClawHooks } = await import('./openclaw-hooks.js'); await injectOpenClawHooks(undefined, tool); diff --git a/src/openclaw-hooks.ts b/src/openclaw-hooks.ts index 4e09fb607..da6993ace 100644 --- a/src/openclaw-hooks.ts +++ b/src/openclaw-hooks.ts @@ -11,34 +11,52 @@ * plus a `handler.ts` under `//`, both shelling out to the * same `teamai hook-dispatch` entry point. * - * Hook directory resolution: honors OPENCLAW_STATE_DIR env var when set (e.g. - * imate containers where the config dir is at /projects/.openclaw instead of - * ~/.openclaw). Other claw variants fall back to ~/.. + * The teamai hook is a workspace hook (`/hooks/teamai-status-report`), + * which OpenClaw loads only once openclaw.json enables its entry + * (`hooks.internal.entries.teamai-status-report.enabled`). The workspace and + * state dir resolve the way OpenClaw resolves them: OPENCLAW_STATE_DIR, + * OPENCLAW_PROFILE, OPENCLAW_CONFIG_PATH, OPENCLAW_WORKSPACE_DIR. * - * Events: `session:start` → session-start dispatch (report + sync); - * `command:new` → prompt-submit dispatch (sync only). + * Events (OpenClaw calls the handler with `{ type, action, sessionKey, context }`): + * command:new, command:reset, session:auto-reset, gateway:startup + * → session-start dispatch (report + sync); + * message:received → prompt-submit dispatch (sync only). */ import path from 'node:path'; -import { writeFile, writeIfChanged, ensureDir, pathExists, readFileSafe, readJson, writeJsonAtomic, remove } from './utils/fs.js'; +import { writeFile, writeIfChanged, ensureDir, pathExists, readFileSafe, writeJsonAtomic, remove } from './utils/fs.js'; import { log } from './utils/logger.js'; -import { getUserHome } from './utils/home.js'; +import { expandHome, getUserHome } from './utils/home.js'; /** - * Resolve the hooks directory for an OpenClaw-family tool. - * Honors OPENCLAW_STATE_DIR env var when set (used when the config dir is relocated, - * e.g. `/projects/.openclaw` in imate containers). Falls back to `~/.`. - * - * Currently only `openclaw` honors OPENCLAW_STATE_DIR. Other claw variants - * (qclaw/easyclaw/autoclaw) are not confirmed to use the same env var and + * The OpenClaw state dir: `OPENCLAW_STATE_DIR`, else `~/.openclaw-` + * for a non-default `OPENCLAW_PROFILE`, else `~/.openclaw`. It holds + * `openclaw.json`, managed hooks and, by default, the workspace. + */ +export function resolveOpenclawStateDir(): string { + const override = process.env.OPENCLAW_STATE_DIR?.trim(); + if (override) return path.resolve(expandHome(override)); + const profile = process.env.OPENCLAW_PROFILE?.trim(); + const suffix = profile && profile.toLowerCase() !== 'default' ? `-${profile}` : ''; + return path.join(getUserHome(), `.openclaw${suffix}`); +} + +/** The `openclaw.json` OpenClaw reads: `OPENCLAW_CONFIG_PATH`, else the state dir's. */ +export function resolveOpenclawConfigPath(): string { + const override = process.env.OPENCLAW_CONFIG_PATH?.trim(); + if (override) return path.resolve(expandHome(override)); + return path.join(resolveOpenclawStateDir(), 'openclaw.json'); +} + +/** + * Resolve the managed hooks directory for an OpenClaw-family tool: + * `/hooks` for `openclaw`. Other claw variants + * (qclaw/easyclaw/autoclaw) are not confirmed to use OpenClaw's env vars and * always fall back to ~/./hooks. */ export function resolveOpenClawHooksDir(tool: string): string { - if (tool === 'openclaw' && process.env.OPENCLAW_STATE_DIR) { - return path.join(process.env.OPENCLAW_STATE_DIR, 'hooks'); - } - const home = getUserHome(); - return path.join(home, `.${tool}`, 'hooks'); + if (tool === 'openclaw') return path.join(resolveOpenclawStateDir(), 'hooks'); + return path.join(getUserHome(), `.${tool}`, 'hooks'); } /** Sub-directory name under that holds the teamai OpenClaw hook. */ @@ -47,18 +65,35 @@ export const OPENCLAW_HOOK_DIR = 'teamai-status-report'; /** Marker so we can recognize (and cleanly remove) our own hook. */ const TEAMAI_MARKER = '[teamai]'; -/** Map OpenClaw event → teamai dispatch event. */ +/** + * The key OpenClaw reads this hook's settings under + * (`hooks.internal.entries.`). Same as the directory name, so + * `openclaw hooks enable teamai-status-report` finds it by either. + */ +export const OPENCLAW_HOOK_KEY = OPENCLAW_HOOK_DIR; + +/** + * OpenClaw event key (`${type}:${action}`) → teamai dispatch event. Only keys + * OpenClaw emits (docs/automation/hooks/event-types.md). `command:stop` only + * observes a cancel, so it is not a stop. + */ const EVENT_MAP: Record = { - 'session:start': 'session-start', - 'command:new': 'prompt-submit', + 'command:new': 'session-start', + 'command:reset': 'session-start', + 'session:auto-reset': 'session-start', + 'gateway:startup': 'session-start', + 'message:received': 'prompt-submit', }; function buildHookMd(tool: string): string { const events = Object.keys(EVENT_MAP); - const metadata = JSON.stringify({ openclaw: { events } }); + const metadata = JSON.stringify({ openclaw: { events, hookKey: OPENCLAW_HOOK_KEY } }); return [ '---', - `name: ${TEAMAI_MARKER} status-report`, + // A bare `[teamai] ...` value is a YAML flow sequence followed by a + // scalar: a parse error, and OpenClaw then treats the metadata as invalid. + `name: ${OPENCLAW_HOOK_KEY}`, + `description: ${JSON.stringify(`${TEAMAI_MARKER} Reports agent status to the team backend and syncs team resources.`)}`, `metadata:`, ` ${metadata}`, `handler: ./handler.ts`, @@ -71,23 +106,42 @@ function buildHookMd(tool: string): string { } function buildHandlerTs(tool: string): string { - // Map each OpenClaw event to the corresponding teamai dispatch event and shell - // out. Failures are swallowed so the agent is never blocked. + // OpenClaw calls the default export with the event itself + // ({ type, action, sessionKey, context, ... }). Map `${type}:${action}` to a + // teamai dispatch event and shell out, passing the event's workspace as the + // hook cwd so hook-dispatch resolves the scope from it. Events that carry no + // workspaceDir (message:received) use the workspace this hook is installed + // in: /hooks/teamai-status-report/handler.ts. Failures are + // swallowed so the agent is never blocked; the child is not awaited. const mapLiteral = JSON.stringify(EVENT_MAP); return `// ${TEAMAI_MARKER} status-report handler — generated by teamai, do not edit. import { spawn } from 'node:child_process'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; const EVENT_MAP: Record = ${mapLiteral}; const TOOL = ${JSON.stringify(tool)}; +const HOOK_WORKSPACE = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..'); + +type HookEvent = { type?: unknown; action?: unknown; sessionKey?: unknown; context?: { workspaceDir?: unknown } | null }; -export default async function handler(ctx: { event?: string } = {}): Promise { - const dispatchEvent = ctx.event ? EVENT_MAP[ctx.event] : undefined; +export default async function handler(event?: HookEvent): Promise { + if (!event || typeof event.type !== 'string' || typeof event.action !== 'string') return; + const key = event.type + ':' + event.action; + const dispatchEvent = Object.hasOwn(EVENT_MAP, key) ? EVENT_MAP[key] : undefined; if (!dispatchEvent) return; + const workspaceDir = event.context?.workspaceDir; + const payload: Record = { + cwd: typeof workspaceDir === 'string' && workspaceDir ? workspaceDir : HOOK_WORKSPACE, + }; + if (typeof event.sessionKey === 'string' && event.sessionKey) payload.session_id = event.sessionKey; try { const child = spawn('teamai', ['hook-dispatch', dispatchEvent, '--tool', TOOL], { - stdio: ['inherit', 'ignore', 'ignore'], + stdio: ['pipe', 'ignore', 'ignore'], }); child.on('error', () => {}); + child.stdin?.on('error', () => {}); + child.stdin?.end(JSON.stringify(payload)); } catch { // never block the agent } @@ -122,49 +176,160 @@ export async function injectOpenClawHooks(workspacePath?: string, tool = 'opencl } else { log.debug(`teamai OpenClaw hook already up-to-date in ${dir}`); } - await enableOpenClawInternalHooks(tool); + // openclaw-only: other claw variants are not known to use this config. + if (tool === 'openclaw') await enableOpenClawHookEntry(OPENCLAW_HOOK_KEY, 'workspace'); +} + +type OpenclawInternalHooks = { + enabled?: unknown; + entries?: Record; + load?: { extraDirs?: unknown }; +}; + +type OpenclawConfigRead = + | { kind: 'missing' } + | { kind: 'invalid'; error: string } + | { kind: 'ok'; cfg: Record }; + +async function readOpenclawConfig(cfgPath: string): Promise { + const raw = await readFileSafe(cfgPath); + if (raw === null) return { kind: 'missing' }; + try { + const cfg: unknown = JSON.parse(raw); + return cfg && typeof cfg === 'object' && !Array.isArray(cfg) + ? { kind: 'ok', cfg: cfg as Record } + : { kind: 'invalid', error: 'not a JSON object' }; + } catch (e) { + return { kind: 'invalid', error: (e as Error).message }; + } +} + +function internalHooksOf(cfg: Record): OpenclawInternalHooks { + const hooks = cfg.hooks as { internal?: unknown } | undefined; + const internal = hooks && typeof hooks === 'object' ? hooks.internal : undefined; + return internal && typeof internal === 'object' ? internal as OpenclawInternalHooks : {}; } /** - * Enable workspace-hook loading in the OpenClaw engine config. - * - * The engine does not load workspace hooks unless `hooks.internal.enabled` is - * true in `$OPENCLAW_STATE_DIR/openclaw.json`. Writing the hook files alone is - * therefore insufficient. This performs a deep merge: it preserves every - * existing field (e.g. `agents.defaults.workspace`) and only sets that one - * flag. No-op (debug log) when OPENCLAW_STATE_DIR is unset or not absolute. - * Idempotent. + * True when OpenClaw would load every hook it discovers with these entries: + * master flag on, no named entry, no extra dir (src/hooks/configured.ts). + */ +function isOpenEndedDiscovery(internal: OpenclawInternalHooks, entries: Record): boolean { + const extraDirs = internal.load?.extraDirs; + const hasExtraDirs = Array.isArray(extraDirs) && extraDirs.some((d) => typeof d === 'string' && d.trim()); + return internal.enabled === true && !Object.keys(entries).some((name) => name.trim()) && !hasExtraDirs; +} + +/** True when OpenClaw's config loads teamai's workspace hook: internal hooks on and its entry enabled. */ +export async function isOpenclawHookEnabled(): Promise { + const read = await readOpenclawConfig(resolveOpenclawConfigPath()); + if (read.kind !== 'ok') return false; + const internal = internalHooksOf(read.cfg); + return internal.enabled !== false && internal.entries?.[OPENCLAW_HOOK_KEY]?.enabled === true; +} + +/** + * Enable one of teamai's hooks in OpenClaw's config, by its hook key. * - * openclaw-only: other claw variants are not known to use this flag. + * OpenClaw loads a workspace hook only when + * `hooks.internal.entries..enabled` is true, and once any named entry + * exists only the named hooks load (docs/automation/hooks/configuration.md). + * This writes the entry, deep-merged so every other field stays, and without + * the master flag, so removing the entry restores the config. * - * @param tool claw variant; only `openclaw` uses this flag + * Adding the first named entry turns open-ended discovery into an allowlist + * of one, which would silently stop every other hook. There a managed hook + * already loads and needs nothing; for the workspace hook the config is left + * alone and a warning names the command that enables it. The same warning is + * given when the user switched internal hooks or this entry off, or the config + * is not plain JSON. No-op when the config's directory does not exist. */ -export async function enableOpenClawInternalHooks(tool = 'openclaw'): Promise { - if (tool !== 'openclaw') return; - const stateDir = process.env.OPENCLAW_STATE_DIR; - if (!stateDir || !path.isAbsolute(stateDir)) { - log.debug('openclaw: skip enabling internal hooks — OPENCLAW_STATE_DIR unset or not absolute'); +async function enableOpenClawHookEntry(hookKey: string, source: 'workspace' | 'managed'): Promise { + const cfgPath = resolveOpenclawConfigPath(); + if (!await pathExists(path.dirname(cfgPath))) { + log.debug(`openclaw: skip enabling ${hookKey} — ${path.dirname(cfgPath)} does not exist`); + return; + } + const enableCmd = `\`openclaw hooks enable ${hookKey}\``; + const read = await readOpenclawConfig(cfgPath); + if (read.kind === 'invalid') { + log.warn(`OpenClaw: teamai cannot read ${cfgPath} as plain JSON (${read.error}), so it cannot enable ` + + `its hook ${hookKey} there; the hook does not run until you run ${enableCmd}.`); + return; + } + const config = read.kind === 'ok' ? read.cfg : {}; + const internal = internalHooksOf(config); + const entries = internal.entries && typeof internal.entries === 'object' ? internal.entries : {}; + const entry = entries[hookKey]; + if (internal.enabled === false) { + log.warn(`OpenClaw internal hooks are switched off (hooks.internal.enabled: false in ${cfgPath}), ` + + `so teamai's hook ${hookKey} does not run. teamai leaves that setting to you: run ${enableCmd} to turn them on.`); + return; + } + if (entry?.enabled === true) { + log.debug(`openclaw: ${hookKey} already enabled`); return; } - const cfgPath = path.join(stateDir, 'openclaw.json'); + if (entry?.enabled === false) { + log.warn(`teamai's OpenClaw hook ${hookKey} is disabled in ${cfgPath} (hooks.internal.entries.${hookKey}), ` + + `so it does not run. Run ${enableCmd} to turn it back on.`); + return; + } + if (isOpenEndedDiscovery(internal, entries)) { + if (source === 'managed') return; + log.warn(`OpenClaw loads every hook it discovers (${cfgPath} turns internal hooks on with no named entries). ` + + `Enabling teamai's hook adds the first named entry, which makes that an allowlist and stops the other hooks, ` + + `so teamai left the config unchanged and its hook does not run. Run ${enableCmd}, ` + + `then \`openclaw hooks enable \` for each other hook you use.`); + return; + } + const hooks = config.hooks && typeof config.hooks === 'object' ? config.hooks as Record : {}; + config.hooks = { ...hooks, internal: { ...internal, entries: { ...entries, [hookKey]: { ...entry, enabled: true } } } }; try { - const cfg = (await readJson>(cfgPath)) ?? {}; - const hooksVal = cfg.hooks; - const hooks = (hooksVal && typeof hooksVal === 'object') ? hooksVal as Record : {}; - const internalVal = hooks.internal; - const internal = (internalVal && typeof internalVal === 'object') ? internalVal as Record : {}; - if (internal.enabled === true) { - log.debug('openclaw: internal hooks already enabled'); - return; - } - internal.enabled = true; - hooks.internal = internal; - cfg.hooks = hooks; - await writeJsonAtomic(cfgPath, cfg); - log.success(`Enabled OpenClaw internal hooks in ${cfgPath}`); + await writeJsonAtomic(cfgPath, config); + log.success(`Enabled the teamai OpenClaw hook ${hookKey} in ${cfgPath}`); } catch (e) { - log.warn(`openclaw: failed to enable internal hooks: ${(e as Error).message}`); + log.warn(`OpenClaw: could not enable the teamai hook ${hookKey} in ${cfgPath}: ${(e as Error).message}. Run ${enableCmd}.`); + } +} + +/** `obj` without `key`; `undefined` when nothing is left. */ +function withoutKey(obj: Record, key: string): Record | undefined { + const { [key]: _dropped, ...rest } = obj; + return Object.keys(rest).length > 0 ? rest : undefined; +} + +/** + * Take one of teamai's entries back out of openclaw.json (uninstall, + * `hooks remove`, a removed agent hook), dropping parents it leaves empty. + * + * Kept when removing it would turn the allowlist the entry made back into + * open-ended discovery (master flag on, no other named entry). + */ +export async function removeOpenClawHookEntry(hookKey: string = OPENCLAW_HOOK_KEY): Promise { + const cfgPath = resolveOpenclawConfigPath(); + const read = await readOpenclawConfig(cfgPath); + if (read.kind === 'missing') return; + if (read.kind === 'invalid') { + log.warn(`OpenClaw: teamai cannot read ${cfgPath} as plain JSON (${read.error}), so it left ` + + `hooks.internal.entries.${hookKey} there; remove it by hand or run \`openclaw hooks disable ${hookKey}\`.`); + return; } + const { cfg } = read; + const internal = internalHooksOf(cfg); + const entries = internal.entries; + if (!entries || typeof entries !== 'object' || !Object.hasOwn(entries, hookKey)) return; + const rest = withoutKey(entries, hookKey); + if (isOpenEndedDiscovery(internal, rest ?? {})) { + log.debug(`openclaw: keeping ${hookKey} in ${cfgPath}; removing it would load every discovered hook`); + return; + } + const nextInternal = rest ? { ...internal, entries: rest } : withoutKey(internal, 'entries'); + const hooks = cfg.hooks as Record; + const nextHooks = nextInternal ? { ...hooks, internal: nextInternal } : withoutKey(hooks, 'internal'); + const next = nextHooks ? { ...cfg, hooks: nextHooks } : withoutKey(cfg, 'hooks') ?? {}; + await writeJsonAtomic(cfgPath, next); + log.success(`Removed the teamai OpenClaw hook entry ${hookKey} from ${cfgPath}`); } /** Remove the teamai OpenClaw hook from `` if present. */ @@ -184,17 +349,27 @@ export async function removeOpenClawHooks(hooksDir: string): Promise { } } -/** Map Claude PascalCase agent-hook events → OpenClaw colon-separated events. */ -const CLAUDE_TO_OPENCLAW_EVENTS: Record = { - SessionStart: 'session:start', - UserPromptSubmit: 'command:new', +/** + * Map Claude PascalCase agent-hook events → the OpenClaw events that mean the + * same (docs/automation/hooks/event-types.md). OpenClaw emits no + * `session:start`: a session begins with `/new` or `/reset`. + */ +const CLAUDE_TO_OPENCLAW_EVENTS: Record = { + SessionStart: ['command:new', 'command:reset'], + UserPromptSubmit: ['message:received'], }; -function buildAgentHookMd(slug: string, openclawEvent: string): string { - const metadata = JSON.stringify({ openclaw: { events: [openclawEvent] } }); +/** The key a server-pushed agent hook is selected and configured under in openclaw.json. */ +function agentHookKey(slug: string): string { + return `teamai-agent-${slug}`; +} + +function buildAgentHookMd(slug: string, openclawEvents: string[]): string { + const metadata = JSON.stringify({ openclaw: { events: openclawEvents, hookKey: agentHookKey(slug) } }); return [ '---', - `name: ${TEAMAI_MARKER} ${slug}`, + // Quoted: a bare `[teamai] ...` is not valid YAML (see buildHookMd). + `name: ${JSON.stringify(`${TEAMAI_MARKER} ${slug}`)}`, `metadata:`, ` ${metadata}`, `handler: ./handler.ts`, @@ -239,8 +414,8 @@ export async function applyOpenClawAgentHook(def: { matcher?: string; timeout?: number; }): Promise { - const openclawEvent = CLAUDE_TO_OPENCLAW_EVENTS[def.event]; - if (!openclawEvent) { + const openclawEvents = CLAUDE_TO_OPENCLAW_EVENTS[def.event]; + if (!openclawEvents) { log.warn(`OpenClaw does not support event "${def.event}" — skipping hook [${def.slug}]`); return; } @@ -248,9 +423,11 @@ export async function applyOpenClawAgentHook(def: { const hooksDir = resolveOpenClawHooksDir(tool); const dir = path.join(hooksDir, def.slug); await ensureDir(dir); - await writeFile(path.join(dir, 'HOOK.md'), buildAgentHookMd(def.slug, openclawEvent)); + await writeFile(path.join(dir, 'HOOK.md'), buildAgentHookMd(def.slug, openclawEvents)); await writeFile(path.join(dir, 'handler.ts'), buildAgentHandlerTs(def.command, def.timeout ?? 10)); log.success(`Installed OpenClaw agent hook [${def.slug}] in ${dir}`); + // Once openclaw.json names any hook (teamai's own does), only named hooks load. + if (tool === 'openclaw') await enableOpenClawHookEntry(agentHookKey(def.slug), 'managed'); } /** @@ -267,41 +444,46 @@ export async function removeOpenClawAgentHook(opts: { await remove(dir); log.success(`Removed OpenClaw agent hook [${opts.slug}] from ${dir}`); } + if (tool === 'openclaw') await removeOpenClawHookEntry(agentHookKey(opts.slug)); } /** - * Resolve the OpenClaw workspace directory by priority: - * 1. Explicit workspacePath (server-sent) - * 2. $OPENCLAW_STATE_DIR/openclaw.json → agents.defaults.workspace - * 3. $HOME/.openclaw/workspace (default convention) + * Resolve the OpenClaw workspace directory the way OpenClaw resolves its + * default agent's workspace (agents/agent-scope-config.ts, + * agents/workspace-default-path.ts): + * 1. Explicit workspacePath (server-sent), when it exists + * 2. `agents.defaults.workspace` in the resolved openclaw.json + * 3. `OPENCLAW_WORKSPACE_DIR` + * 4. `/workspace` (`OPENCLAW_STATE_DIR`, profile, `~/.openclaw`) * - * Returns the first candidate whose directory exists, or null. + * Returns the directory from that order when it exists, or null: another + * workspace that happens to exist is not the one OpenClaw reads. + * Per-agent workspaces in `agents.list` are not followed. */ export async function resolveOpenclawWorkspaceDir(workspacePath?: string): Promise { - const candidates: string[] = []; - if (workspacePath) candidates.push(workspacePath); - const stateDir = process.env.OPENCLAW_STATE_DIR; - if (stateDir && path.isAbsolute(stateDir)) { - const cfgRaw = await readFileSafe(path.join(stateDir, 'openclaw.json')); - if (cfgRaw) { - try { - const cfg = JSON.parse(cfgRaw); - const ws = cfg?.agents?.defaults?.workspace; - if (typeof ws === 'string' && ws) candidates.push(ws); - } catch (e) { log.debug(`openclaw: failed to parse openclaw.json: ${(e as Error).message}`); } - } + if (workspacePath && await pathExists(workspacePath)) { + log.debug(`openclaw: resolved workspace dir to ${workspacePath}`); + return workspacePath; } - candidates.push(path.join(getUserHome(), '.openclaw', 'workspace')); - for (const candidate of candidates) { - if (await pathExists(candidate)) { - log.debug(`openclaw: resolved workspace dir to ${candidate}`); - return candidate; - } + const cfgPath = resolveOpenclawConfigPath(); + const read = await readOpenclawConfig(cfgPath); + if (read.kind === 'invalid') log.debug(`openclaw: could not parse ${cfgPath} as JSON: ${read.error}`); + const agents = read.kind === 'ok' ? read.cfg.agents as { defaults?: { workspace?: unknown } } | undefined : undefined; + const configured = agents?.defaults?.workspace; + const envDir = process.env.OPENCLAW_WORKSPACE_DIR?.trim(); + const candidate = typeof configured === 'string' && configured.trim() + ? path.resolve(expandHome(configured.trim())) + : envDir + ? path.resolve(expandHome(envDir)) + : path.join(resolveOpenclawStateDir(), 'workspace'); + if (await pathExists(candidate)) { + log.debug(`openclaw: resolved workspace dir to ${candidate}`); + return candidate; } // A missing workspace dir is the normal case when OpenClaw is not installed; // callers treat null as "skip openclaw" and log their own debug line, so keep // this at debug level to avoid warning noise (one line per skill/file) on // machines without OpenClaw. - log.debug(`openclaw: no workspace dir found (tried: ${candidates.join(', ') || 'none'})`); + log.debug(`openclaw: no workspace dir found (tried: ${[workspacePath, candidate].filter(Boolean).join(', ')})`); return null; } diff --git a/src/uninstall.ts b/src/uninstall.ts index 0e8329946..8364a3e4b 100644 --- a/src/uninstall.ts +++ b/src/uninstall.ts @@ -4,6 +4,7 @@ import { autoDetectInit, saveLocalConfig, saveLocalConfigForScope, UnreadablePro import { reconcileHooks, hasTeamaiHooks, mainCheckoutHookFile, resolveMainCheckoutHooks } from './hooks.js'; import { removeOpenClawHooks, + removeOpenClawHookEntry, OPENCLAW_HOOK_DIR, resolveOpenClawHooksDir, resolveOpenclawWorkspaceDir, @@ -476,9 +477,9 @@ async function discoverToolResources( } else { // OpenClaw-style agents (no settings file) inject a HOOK.md + handler.ts // under /. Check the default path, the - // OPENCLAW_STATE_DIR override (imate containers), and the resolved - // workspace dir — injection now targets `/hooks`, so teardown - // must cover it too, otherwise the hook is orphaned on uninstall. + // resolved state dir (OPENCLAW_STATE_DIR or OPENCLAW_PROFILE), and the + // resolved workspace dir — injection now targets `/hooks`, so + // teardown must cover it too, otherwise the hook is orphaned on uninstall. const defaultHooksDir = path.join(baseDir, `.${tool}`, 'hooks'); const resolvedHooksDir = resolveOpenClawHooksDir(tool); const dirsToCheck = new Set([defaultHooksDir, resolvedHooksDir]); @@ -1161,7 +1162,7 @@ async function executeRemoval(plan: RemovalPlan): Promise tool === 'openclaw')) { + try { + await removeOpenClawHookEntry(); + } catch (e) { + log.warn(`Failed to remove the teamai hook entry from OpenClaw's config: ${(e as Error).message}`); + } + } // (a2b) Remove OpenCode teamai plugin files (main hook + any agent-hook plugins). for (const { baseDir, scope } of plan.opencodeHookScopes) { From 03e2b43e338681d8b388485eb02fef6e176ee265 Mon Sep 17 00:00:00 2001 From: Saul Moro Date: Fri, 2 Oct 2026 01:34:48 +0200 Subject: [PATCH 02/20] fix(rules): render Kiro and Qoder rules in their own formats (#946) --- CHANGELOG.md | 2 + docs/designs/data-directory-layout.md | 14 +- docs/usage-guide.md | 8 +- docs/usage-guide.zh-CN.md | 8 +- .../core/references/contribute-member.md | 7 +- src/__tests__/builtin-rules.test.ts | 15 ++ src/__tests__/cursor-mdc.test.ts | 29 ++- src/__tests__/doctor-rules-delivery.test.ts | 22 +++ src/__tests__/helpers/rule-parsers.ts | 77 ++++++++ src/__tests__/pre-push-sync.test.ts | 16 ++ .../pull-rule-format-upgrade.test.ts | 132 ++++++++++++++ src/__tests__/rule-parsers.test.ts | 31 ++++ src/__tests__/rule-render-contracts.test.ts | 72 ++++++++ src/__tests__/rules.test.ts | 147 +++++++++++++++ src/builtin-rules.ts | 12 +- src/doctor-delivery.ts | 25 ++- src/pull.ts | 43 +++++ src/resources/copilot-instructions.ts | 12 +- src/resources/cursor-mdc.ts | 135 ++------------ src/resources/kiro-steering.ts | 31 ++++ src/resources/qoder-rule.ts | 37 ++++ src/resources/rule-format.ts | 96 +++++++--- src/resources/rules.ts | 165 ++++++++++------- src/resources/team-rule.ts | 172 ++++++++++++++++++ src/utils/pre-push-sync.ts | 22 +-- 25 files changed, 1072 insertions(+), 258 deletions(-) create mode 100644 src/__tests__/helpers/rule-parsers.ts create mode 100644 src/__tests__/pull-rule-format-upgrade.test.ts create mode 100644 src/__tests__/rule-parsers.test.ts create mode 100644 src/__tests__/rule-render-contracts.test.ts create mode 100644 src/resources/kiro-steering.ts create mode 100644 src/resources/qoder-rule.ts create mode 100644 src/resources/team-rule.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index b36c068aa..0287ea465 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -45,6 +45,8 @@ All notable changes to this project will be documented in this file. See [standa ### 🐛 Bug Fixes +- Kiro and Qoder (and Qoder CN) now get each rule in their own rules format. Kiro ignores `paths:`, so the verbatim copy pull wrote applied every rule everywhere, and Qoder documents `paths:` only for its CLI, not for Desktop. Kiro steering files get `inclusion: fileMatch` with a `fileMatchPattern` list, or `inclusion: always`; Qoder rules get `trigger: glob` with one comma-separated `glob:` line, `{a,b}` expanded, or `trigger: always_on`. The first pull after upgrading rewrites a copy still holding what teamai delivered, even when the team repo has not moved; a copy you edited is kept and named. `teamai push` sends back only the body, and a steering or Qoder rule file with no matching team rule is the member's own: pull no longer deletes it, and push no longer offers it as a new team rule. `teamai doctor` compares each copy with the new render and its fix names the fields that tool scopes by (for [#946](https://github.com/Tencent/teamai-cli/issues/946)). +- A rule scoped with an unquoted glob such as `paths: **/*.ts` lost its scope on every render after the first in a run, so Cursor, JoyCode or doctor could see it as always on: the frontmatter parse kept a failed attempt in gray-matter's cache. A `paths:` string is now split on its top-level commas only, so `src/{a,b}/**` stays one glob for Cursor instead of becoming `src/{a, b}/**` (for [#946](https://github.com/Tencent/teamai-cli/issues/946)). - teamai's OpenClaw hook runs. Its handler read an `event` field OpenClaw never sends and subscribed to `session:start`, which OpenClaw does not emit; its `HOOK.md` name was not valid YAML; and OpenClaw loads a workspace hook only once `openclaw.json` enables its entry, which teamai never wrote. The handler now keys on the event's `type` and `action`, runs `session-start` on `command:new`, `command:reset`, `session:auto-reset` and `gateway:startup` and `prompt-submit` on `message:received`, and passes the event's workspace as the hook's `cwd`. init, pull and `hooks inject` add `hooks.internal.entries.teamai-status-report.enabled`, unless that would narrow OpenClaw's open-ended hook discovery or you switched it off, in which case they warn and name `openclaw hooks enable teamai-status-report` (earlier versions set `hooks.internal.enabled` when `OPENCLAW_STATE_DIR` was set, so those machines see this warning until the command is run); `hooks remove` and uninstall take the entry out. The workspace and config follow `OPENCLAW_PROFILE`, `OPENCLAW_CONFIG_PATH` and `OPENCLAW_WORKSPACE_DIR` as OpenClaw does, a server-pushed `SessionStart` or `UserPromptSubmit` agent hook subscribes to the events OpenClaw emits and gets its own entry (`teamai-agent-`) so the allowlist keeps it, and `teamai doctor` fails `OpenClaw hook enabled` while the entry is missing or off (for [#946](https://github.com/Tencent/teamai-cli/issues/946)). - Team rules reach Codex, `codex-internal` and `tcodex`. Pull used to copy them to `.codex/rules/.md`, which Codex does not read, and `teamai doctor` reported them delivered. In user scope they now go into a team-rules block of the tool's own `AGENTS.md` (`~/.codex/AGENTS.md`), beside the culture, shared-instructions and recall blocks. In a project the session-start hook adds the project's rules and those blocks to each session, and to a spawned subagent through a new `SubagentStart` entry, with `additionalContextLimit: 0` so Codex keeps them whole; pull leaves the project `AGENTS.md`, which other tools read too, unchanged. Pull removes the `.md` copies earlier pulls left in the Codex rules directory at its recorded `toolRoots` location, including a publisher's bare local copy, keeping an edited one with a warning. Pull and uninstall keep a removed rule's legacy copy unless it matches its recorded delivery hash; a missing record proves nothing about local edits. A path-scoped rule is rendered without frontmatter after an `Applies to files matching: ` line, in Hermes' `SOUL.md` too. `teamai doctor` checks the user-scope block and, in a project, both hook entries' limit. The public Codex asks once to approve the changed teamai hooks (for [#938](https://github.com/Tencent/teamai-cli/issues/938)). - `teamai pull` names the skills it removes because they are no longer delivered here, in one line, instead of a `debug` line calling them excluded and a summary that says `No resources to sync`. Picking a role or project, or an admin adding `manifest/projects.yaml`, takes root skills away, since the root `skills/` is then the tag catalog; the line then says that `teamai tags subscribe ` brings one back. A namespace skill removed because its namespace is no longer active is named too. The usage guide and the multi-project design no longer say a member with no project still gets `common` or every root item (for [#911](https://github.com/Tencent/teamai-cli/issues/911)). diff --git a/docs/designs/data-directory-layout.md b/docs/designs/data-directory-layout.md index 11d9c5744..5ed005a74 100644 --- a/docs/designs/data-directory-layout.md +++ b/docs/designs/data-directory-layout.md @@ -104,9 +104,10 @@ resets nothing, since a checkout recorded at an older revision already misses the fast path. `push` needs that entry too: before scanning, it syncs each rule and skill the member never edited, and "never edited" means equal to the version at a revision *this* checkout synced, not the shared `lastPullRev` -another checkout may have moved (#812). Cursor and Copilot rules compare bodies -against those revisions, ignoring derived frontmatter, and render refreshed -copies in the tool's native format. Rule sync uses the same tool root as the +another checkout may have moved (#812). Rules of a tool with its own rules +format (`RULE_FORMATS` in `rule-format.ts`: Cursor, JoyCode, Copilot, Kiro, +Qoder) compare bodies against those revisions, ignoring derived frontmatter, +and render refreshed copies in the tool's native format. Rule sync uses the same tool root as the scanner, including `COPILOT_HOME` for user-scope Copilot instructions. It checks `isAgentExcluded` before installation detection, so retained tool directories do not authorize writes to rules excluded by the local configuration. @@ -175,6 +176,13 @@ older CLI's render, such as Claude extras in a Qoder copy). Without a `delivered` entry for the copy nothing tells that render from an edit, so it is left alone. +Rules get the same treatment for a render change (#946): when a CLI upgrade +gives a tool its own rules format (Kiro, Qoder), the fast path rewrites each +rule copy that still has the bytes `delivered` records but is not the current +render, records the new bytes, and leaves a copy without an entry alone. A +copy the member changed is kept and named when teamai would now deliver other +bytes there. + ### Why the main worktree, not `git-common-dir` (verified) `projectAnchor` uses the first entry of `git worktree list --porcelain` rather than diff --git a/docs/usage-guide.md b/docs/usage-guide.md index c09da66a5..c6f288c2f 100644 --- a/docs/usage-guide.md +++ b/docs/usage-guide.md @@ -832,7 +832,7 @@ Exclusion rules take effect after role and tag filtering. When running `teamai p ### Push local resources -Before scanning, `push` refreshes unedited old rule copies from the team repo. For Copilot, it compares Markdown bodies independently of the generated `applyTo` header and renders updates in `.instructions.md` format. Local body edits are preserved. This applies to project rules and user rules under `COPILOT_HOME`. Each copy it refreshes is recorded as delivered, so the next `teamai pull` still updates it instead of keeping it as your change. +Before scanning, `push` refreshes unedited old rule copies from the team repo. For a tool with a rules format of its own (Cursor and JoyCode `.mdc`, Copilot `.instructions.md`, Kiro steering, Qoder rules), it compares Markdown bodies independently of the generated header and renders updates in that tool's format. Local body edits are preserved. For Copilot this applies to project rules and user rules under `COPILOT_HOME`. Each copy it refreshes is recorded as delivered, so the next `teamai pull` still updates it instead of keeping it as your change. When only the team's `paths` change, `push` also refreshes Copilot's `applyTo` if the local file still matches a recorded version's generated copy. A locally edited header is preserved in this case. @@ -2296,12 +2296,16 @@ Team hooks still come from the team's `hooks/hooks.yaml`: edit that source in th Qoder is available as a built-in target. TeamAI deploys skills, rules, and subagents to `.qoder/skills/`, `.qoder/rules/`, and `.qoder/agents/`. Hooks and MCP servers are merged into the scope-specific `.qoder/settings.json`, preserving unrelated user settings. The paths match Qoder's user and project configuration contracts. +Rules are written in the form Qoder Desktop writes, which Qoder CLI also reads: a rule with `paths:` gets `trigger: glob` and one unquoted `glob:` line of comma-separated globs, with each `{a,b}` alternation expanded into separate globs because the line is split on every comma; a rule without `paths` gets `trigger: always_on`. Qoder publishes no schema for this frontmatter; the form comes from Desktop's rule files in `alibaba/tron-one-agent`. On `push`, only the Markdown body flows back, and a rule file in `.qoder/rules/` with no matching team rule is yours: `pull` leaves it and `push` never offers it as a new team rule. Copies an older teamai wrote verbatim are rewritten on the next `pull` when they still hold what teamai delivered; one you edited is kept and named. + Qoder CN is a separate distribution that keeps its **user** directory at `~/.qoder-cn` instead of `~/.qoder`, so it is a separate built-in target (`qoder-cn`) rather than part of `qoder`. Only the user scope differs: user-scope resources go to `~/.qoder-cn/{skills,rules,agents}` and hooks/MCP to `~/.qoder-cn/settings.json`, while project-scope resources keep Qoder's `/.qoder/` layout. It reads the same Claude-compatible resource formats, so content is identical and only the user-scope root changes. Install both editions and TeamAI syncs each one to its own user directory; neither needs a symlink. ### Kiro Kiro is available as a built-in target. TeamAI deploys skills, rules, and subagents to `.kiro/skills/`, `.kiro/steering/`, and `.kiro/agents/`, matching [Kiro's documented layouts](https://kiro.dev/docs/skills/) for workspace skills, [steering](https://kiro.dev/docs/steering/), and custom agents. Subagents are rendered as JSON so they work with both Kiro CLI 2.x and 3.x. Each rendered agent preserves Kiro-specific fields and custom hooks, and adds a managed `hooks.agentSpawn` command that dispatches TeamAI's `session-start` event when that custom agent is activated in an interactive CLI session. This verified CLI 2.x hook is embedded in `.kiro/agents/*.json`, not written to the standalone `.kiro/hooks/` surface introduced for IDE 1.x and CLI 3.x; Kiro's in-memory built-in default agent cannot be modified, and `--no-interactive` does not fire `agentSpawn`. MCP servers merge into the scope-specific `.kiro/settings/mcp.json` (see the MCP section above). +Rules are steering files with Kiro's inclusion frontmatter, in `.kiro/steering/` and `~/.kiro/steering/`: a rule with `paths:` gets `inclusion: fileMatch` and `fileMatchPattern` as a list of its globs; a rule without `paths` gets `inclusion: always`. On `push`, only the Markdown body flows back, and a steering file with no matching team rule (such as Kiro's own `product.md`) is yours: `pull` leaves it and `push` never offers it as a new team rule. Copies an older teamai wrote verbatim are rewritten on the next `pull` when they still hold what teamai delivered; one you edited is kept and named. Kiro CLI loads every steering file whatever its `inclusion` ([kirodotdev/Kiro#7950](https://github.com/kirodotdev/Kiro/issues/7950)), so a scoped rule is always on there. + ### ZCode ZCode is available as a built-in target. Skills deploy to `.zcode/skills/` (ZCode also reads the central `~/.agents/skills/`, which the `agents` entry covers), and subagents deploy as Claude-style Markdown to `.zcode/agents/`. Hooks are merged into the shared `~/.zcode/cli/config.json`, preserving unrelated keys such as plugin state. Two ZCode specifics the writer handles for you: @@ -2372,7 +2376,7 @@ teamai remove rules --force # Skip the prompt, for scripts and CI Besides the provider, clone, config and hook checks, `doctor` verifies what reached your machine. ` is installed` fails when `enabledAgents` lists a tool that nothing would be delivered to, which is the case where a pull reports success and that tool receives nothing. It asks the same resolver the sync uses, so a tool that keeps its skills somewhere other than its tool root, as OpenClaw does with its workspace directory, is judged where the sync would actually write. It reports an installed tool as passing too, so `--json` carries one entry per enabled tool either way. The checks at the end of a pull cover the scope that pull resolved from the current directory; run `teamai doctor` in another scope to check that one. `Skills delivered to ` compares the skills your role namespaces, tag subscriptions and exclusions resolve to against what is on disk for each installed tool: it reports a skill that was never delivered separately from one that arrived unreadable — `SKILL.md` missing, its frontmatter unparseable, or its `name` not matching the directory, which keeps the agent from ever discovering it. `Team docs delivered` compares the docs you receive (a docs namespace you do not have active is left out) against `sharing.docs.localDir`, which has one destination rather than one per tool; each expected document has to be a file that can be read, so a directory or a dangling link sitting on the name counts as missing. It also reports extra non-hidden local files as stale, including when the team bundle is empty. Hidden local files are preserved and do not fail this check, and neither does a local copy of a team doc in a namespace you do not have active: pull removes it when it is unchanged and names it when you edited it. `doctor` also prints notes, which are information rather than failed checks. Each note names a namespace skill, agent, rule, shared-instructions file, env variable, hook, MCP server or team model profile that replaces a root one here (`rules: "style" from rules/checkout/style.md replaces rules/style.md`). When a namespace contributes env variables, hooks, MCP servers or team model profiles, a note also counts where that type's entries come from (`env: 3 received here (2 root, 1 checkout)`). Without roles or projects, the notes name each file the team repo defines more than once instead, and each env variable, hook or MCP server name repeated in its root file. -`Rules delivered to ` and `Agents delivered to ` do the same for the other two per-tool resources, and both ask the handler where an item lands rather than deriving a path: a rule's filename and content change per tool (`.md` verbatim, `.mdc` with derived `globs`/`alwaysApply`, `.instructions.md` with `applyTo`), and an agent's destination comes from its render, with `targets:` deciding which tools are owed a copy at all. A delivered rule is compared with the bytes the handler renders for that tool, not merely read for the keys its tool needs: a `.mdc` whose `globs` no longer match the team rule's `paths:` applies to the wrong files while carrying a perfectly legal `alwaysApply`, and that reads here as `delivered from an older copy` — the same label as a body that drifted, because both landed successfully and are still wrong. An agent is compared with the bytes its render produces, so a copy left behind by an older spec — a plain pull skips a scope whose team repo has not changed, so it can sit there indefinitely — is reported as `delivered from an older spec` rather than passing as present. `Every team agent reaches a tool` names an agent that renders for no installed tool — usually a spec that does not parse, or a `targets:` list naming only tools you do not have. These two are `doctor`-only: they read every rule per tool and parse every agent, which would spend the budget the checks at the end of a pull run under. +`Rules delivered to ` and `Agents delivered to ` do the same for the other two per-tool resources, and both ask the handler where an item lands rather than deriving a path: a rule's filename and content change per tool (`.md` verbatim, `.mdc` with derived `globs`/`alwaysApply`, `.instructions.md` with `applyTo`, Kiro's `.md` with `inclusion`/`fileMatchPattern`, Qoder's `.md` with `trigger`/`glob`), and an agent's destination comes from its render, with `targets:` deciding which tools are owed a copy at all. A delivered rule is compared with the bytes the handler renders for that tool, not merely read for the keys its tool needs: a `.mdc` whose `globs` no longer match the team rule's `paths:` applies to the wrong files while carrying a perfectly legal `alwaysApply`, and that reads here as `delivered from an older copy` — the same label as a body that drifted, because both landed successfully and are still wrong. An agent is compared with the bytes its render produces, so a copy left behind by an older spec — a plain pull skips a scope whose team repo has not changed, so it can sit there indefinitely — is reported as `delivered from an older spec` rather than passing as present. `Every team agent reaches a tool` names an agent that renders for no installed tool — usually a spec that does not parse, or a `targets:` list naming only tools you do not have. These two are `doctor`-only: they read every rule per tool and parse every agent, which would spend the budget the checks at the end of a pull run under. Three tools do not read a rules directory, so a per-file check cannot speak for them and each gets one of its own. `Team rules are active in opencode` checks that `opencode.json` still lists the glob the pull owns under `instructions`: OpenCode does not auto-scan `.opencode/rules`, so without it every delivered `.md` is inert while the per-file check keeps passing. `Team rules are inlined in Hermes SOUL.md` compares the teamai-managed block of `SOUL.md` with what the team rules inline to, since Hermes reads standing instructions from that one file rather than from a directory — a deleted block, or one left on an older rule set, is a tool reading the wrong rules with nothing on disk to show for it. In user scope, `Team rules are inlined in Codex AGENTS.md` compares the team-rules block of the tool's `AGENTS.md` with what the team rules inline to, and fails when an `AGENTS.override.md` beside it shadows the file. In a project, `Project rules and instructions reach whole through its session hooks` fails when the teamai `SessionStart` or `SubagentStart` entry in that tool's `hooks.json` is missing or does not set `additionalContextLimit: 0`, without which Codex keeps only the start and end of a large set. Codex has no `Rules delivered to ` check. diff --git a/docs/usage-guide.zh-CN.md b/docs/usage-guide.zh-CN.md index c2da664fa..f89089f42 100644 --- a/docs/usage-guide.zh-CN.md +++ b/docs/usage-guide.zh-CN.md @@ -738,7 +738,7 @@ excludedSkills: ### 推送本地资源 -扫描前,`push` 会用团队仓库的新版刷新未修改的旧规则副本。对于 Copilot,会单独比较 Markdown 正文,忽略自动生成的 `applyTo` 头,并以 `.instructions.md` 格式写入更新;本地正文编辑会保留。此行为适用于项目规则和 `COPILOT_HOME` 下的用户规则。它刷新的每份副本都会记录为 teamai 写入的内容,因此下一次 `teamai pull` 仍会更新它,而不会当作你的修改保留。 +扫描前,`push` 会用团队仓库的新版刷新未修改的旧规则副本。对于有自有规则格式的工具(Cursor 与 JoyCode 的 `.mdc`、Copilot 的 `.instructions.md`、Kiro steering、Qoder rules),会单独比较 Markdown 正文,忽略自动生成的头部,并以该工具的格式写入更新;本地正文编辑会保留。对 Copilot,此行为适用于项目规则和 `COPILOT_HOME` 下的用户规则。它刷新的每份副本都会记录为 teamai 写入的内容,因此下一次 `teamai pull` 仍会更新它,而不会当作你的修改保留。 团队仅修改 `paths` 时,只要本地文件仍与某个已记录版本的生成副本一致,`push` 也会刷新 Copilot 的 `applyTo`;此时本地手动修改过的头部会保留。 @@ -2139,12 +2139,16 @@ GitHub Copilot CLI 已支持其官方自定义指令、Rules、Skills、自定 Qoder 已作为内置目标支持。TeamAI 会将 Skills、Rules 和 Subagents 分别下发到 `.qoder/skills/`、`.qoder/rules/` 和 `.qoder/agents/`。Hooks 与 MCP Server 会合并进对应作用域的 `.qoder/settings.json`,并保留用户已有的其他设置;这些路径与 Qoder 的用户级和项目级配置约定一致。 +Rules 按 Qoder Desktop 写入的形式生成,Qoder CLI 也读取这种形式:带 `paths:` 的规则写成 `trigger: glob` 加一行不带引号、以逗号分隔的 `glob:`,由于该行会按每个逗号切分,`{a,b}` 形式的选择会展开为多个 glob;没有 `paths` 的规则写成 `trigger: always_on`。Qoder 未公开这种 frontmatter 的 schema,该形式取自 `alibaba/tron-one-agent` 中 Desktop 生成的规则文件。`push` 时只有 Markdown 正文回流;`.qoder/rules/` 中没有对应团队规则的文件属于你自己:`pull` 不会删除它,`push` 也不会把它当作新的团队规则提交。旧版 teamai 原样写入的副本,若仍是 teamai 写入的内容,下一次 `pull` 会改写为新格式;你修改过的副本会保留并给出提示。 + Qoder CN 是独立发行的版本,其**用户级**目录为 `~/.qoder-cn` 而非 `~/.qoder`,因此它作为独立的内置目标 `qoder-cn` 支持,而不是并入 `qoder`。两者仅用户作用域不同:用户级的资源写入 `~/.qoder-cn/{skills,rules,agents}`,Hooks 与 MCP 写入 `~/.qoder-cn/settings.json`;项目作用域则沿用 Qoder 的 `/.qoder/` 布局。两者读取相同的 Claude 兼容资源格式,因此下发内容一致,仅用户级根目录不同。同时安装两个版本时,TeamAI 会分别同步到各自的用户目录,无需再建软链接。 ### Kiro Kiro 已作为内置目标支持。TeamAI 会将 Skills、Rules 和 Subagents 分别下发到 `.kiro/skills/`、`.kiro/steering/` 和 `.kiro/agents/`,与 Kiro 官方文档定义的[工作区 Skills](https://kiro.dev/docs/skills/)、[Steering](https://kiro.dev/docs/steering/)和自定义 agents 布局一致。Subagents 渲染为 Kiro CLI 2.x 与 3.x 都支持的 JSON;每个文件都会保留 Kiro 私有字段和自定义 Hooks,并加入 TeamAI 管理的 `hooks.agentSpawn` 命令,在交互式 CLI 会话激活该自定义 agent 时派发 `session-start`。这一经验证的 CLI 2.x Hook 内嵌在 `.kiro/agents/*.json`,而不是写入 IDE 1.x / CLI 3.x 引入的独立 `.kiro/hooks/`;Kiro 内存中的内置默认 agent 无法修改,`--no-interactive` 也不会触发 `agentSpawn`。MCP Server 会合并进对应作用域的 `.kiro/settings/mcp.json`(见上文 MCP 章节)。 +Rules 以带 Kiro inclusion frontmatter 的 steering 文件写入 `.kiro/steering/` 与 `~/.kiro/steering/`:带 `paths:` 的规则写成 `inclusion: fileMatch`,并把其 glob 列表写入 `fileMatchPattern`;没有 `paths` 的规则写成 `inclusion: always`。`push` 时只有 Markdown 正文回流;没有对应团队规则的 steering 文件(例如 Kiro 自己生成的 `product.md`)属于你自己:`pull` 不会删除它,`push` 也不会把它当作新的团队规则提交。旧版 teamai 原样写入的副本,若仍是 teamai 写入的内容,下一次 `pull` 会改写为新格式;你修改过的副本会保留并给出提示。Kiro CLI 无论 `inclusion` 取值都会加载全部 steering 文件([kirodotdev/Kiro#7950](https://github.com/kirodotdev/Kiro/issues/7950)),因此在 CLI 中限定路径的规则也会始终生效。 + ### ZCode ZCode 已作为内置目标支持。Skills 下发到 `.zcode/skills/`(ZCode 同时会读取中央目录 `~/.agents/skills/`,该目录由 `agents` 条目覆盖),Subagents 以 Claude 风格 Markdown 下发到 `.zcode/agents/`。Hooks 会合并进共享的 `~/.zcode/cli/config.json`,并保留插件状态等无关键值。写入器为你处理了两个 ZCode 特有的细节: @@ -2215,7 +2219,7 @@ teamai remove rules --force # 跳过确认,用于脚本和 CI 除了托管平台、clone、配置和 hook 检查之外,`doctor` 还会验证落到本机上的内容。` is installed` 在 `enabledAgents` 列出了不会收到任何内容的工具时失败——这正是 pull 报告成功、而该工具什么都没收到的情况。它使用与同步相同的解析逻辑,因此像 OpenClaw 这样把 skills 放在 workspace 目录而非工具根目录的工具,会在同步真正写入的位置被判断。工具已安装时也会作为通过项报告,因此 `--json` 无论哪种情况都会为每个已启用工具给出一条记录。pull 结束时的检查只覆盖它从当前目录解析出的那个 scope;其他 scope 请在对应目录下运行 `teamai doctor`。`Skills delivered to ` 会把角色命名空间、标签订阅与排除规则解析出的 skill 集合,与每个已安装工具磁盘上的内容比对:从未送达的 skill 与送达但不可读的 skill 会分别报告——后者指 `SKILL.md` 缺失、frontmatter 无法解析,或其 `name` 与目录名不一致,导致 agent 永远发现不了它。`Team docs delivered` 将你应收到的文档(不含未激活的 docs namespace)与 `sharing.docs.localDir` 比对(它只有一个目标目录,而非每个工具一个);每个应有的文档都必须是可读取的文件,因此占用了该名字的目录或断链接也算缺失。它还会将本地多余的非隐藏文件报告为过期文档,即使团队文档已经删空也会检查;本地隐藏文件会保留,不会使检查失败,未激活 namespace 中团队文档的本地副本也不会:pull 会删除未修改的副本,并点名你修改过的副本。`doctor` 还会输出提示,它们只是信息,不是失败的检查。每条提示指出一个在本机替换了根目录条目的 namespace skill、agent、rule、共享指令文件、env 变量、hook、MCP server 或团队模型配置(`rules: "style" from rules/checkout/style.md replaces rules/style.md`)。当某个 namespace 提供了 env 变量、hook、MCP server 或团队模型配置时,还会有一条提示按来源统计该类型的条目(`env: 3 received here (2 root, 1 checkout)`)。未配置角色或项目时,提示改为列出团队仓库中重复定义的每个文件,以及在根文件中重复出现的每个 env 变量、hook 或 MCP server 名称。 -`Rules delivered to ` 与 `Agents delivered to ` 对另外两类按工具下发的资源做同样的事,并且都向 handler 询问落点,而不是自行拼路径:rule 的文件名和内容因工具而异(`.md` 原样、`.mdc` 带派生的 `globs`/`alwaysApply`、`.instructions.md` 带 `applyTo`),agent 的落点来自渲染结果,且由 `targets:` 决定哪些工具应当收到。已送达的 rule 会与 handler 为该工具渲染出的字节逐一比对,而不只是检查该工具所需的键是否存在:`globs` 与团队 rule 的 `paths:` 不再一致的 `.mdc`,即使 `alwaysApply` 取值合法,也会作用到错误的文件上;这里会报告为 `delivered from an older copy`——正文漂移的副本同样如此,因为两者都写入成功,却都是错的。agent 会与渲染结果逐字节比对:旧版 spec 留下的副本(普通 pull 会跳过团队仓库未变化的 scope,它可能一直留在那里)报告为 `delivered from an older spec`,而不是当作已送达。`Every team agent reaches a tool` 会指出在任何已安装工具上都无法渲染的 agent,通常是 spec 解析失败,或 `targets:` 只列了本机没有的工具。这两项仅在 `doctor` 中运行:它们会按工具读取每条 rule、解析每个 agent,放进 pull 结束时的检查会耗尽其时间预算。 +`Rules delivered to ` 与 `Agents delivered to ` 对另外两类按工具下发的资源做同样的事,并且都向 handler 询问落点,而不是自行拼路径:rule 的文件名和内容因工具而异(`.md` 原样、`.mdc` 带派生的 `globs`/`alwaysApply`、`.instructions.md` 带 `applyTo`、Kiro 的 `.md` 带 `inclusion`/`fileMatchPattern`、Qoder 的 `.md` 带 `trigger`/`glob`),agent 的落点来自渲染结果,且由 `targets:` 决定哪些工具应当收到。已送达的 rule 会与 handler 为该工具渲染出的字节逐一比对,而不只是检查该工具所需的键是否存在:`globs` 与团队 rule 的 `paths:` 不再一致的 `.mdc`,即使 `alwaysApply` 取值合法,也会作用到错误的文件上;这里会报告为 `delivered from an older copy`——正文漂移的副本同样如此,因为两者都写入成功,却都是错的。agent 会与渲染结果逐字节比对:旧版 spec 留下的副本(普通 pull 会跳过团队仓库未变化的 scope,它可能一直留在那里)报告为 `delivered from an older spec`,而不是当作已送达。`Every team agent reaches a tool` 会指出在任何已安装工具上都无法渲染的 agent,通常是 spec 解析失败,或 `targets:` 只列了本机没有的工具。这两项仅在 `doctor` 中运行:它们会按工具读取每条 rule、解析每个 agent,放进 pull 结束时的检查会耗尽其时间预算。 有三个工具并不读取 rules 目录,按文件比对的检查无法代表它们,因此各自单列一项。`Team rules are active in opencode` 检查 `opencode.json` 的 `instructions` 中是否仍列着 teamai 所拥有的那条 glob:OpenCode 不会自动扫描 `.opencode/rules`,缺了它,已送达的每个 `.md` 都不会生效,而按文件比对的检查依旧通过。`Team rules are inlined in Hermes SOUL.md` 把 `SOUL.md` 中 teamai 管理的代码块与团队 rule 内联后的内容比对——Hermes 的常驻指令来自这一个文件而非某个目录,因此代码块被删除或停留在旧版规则集上,都意味着该工具读到的是错误的规则,而磁盘上看不出任何异常。user scope 下,`Team rules are inlined in Codex AGENTS.md` 把该工具 `AGENTS.md` 中的 team-rules 区块与团队 rule 内联后的内容比对,旁边有 `AGENTS.override.md` 遮蔽该文件时也会失败。在项目中,当该工具 `hooks.json` 中的 teamai `SessionStart` 或 `SubagentStart` 条目缺失或没有设置 `additionalContextLimit: 0` 时,`Project rules and instructions reach whole through its session hooks` 会失败:没有它,Codex 对较大的内容只保留开头和结尾。Codex 没有 `Rules delivered to ` 检查。 diff --git a/skill-data/core/references/contribute-member.md b/skill-data/core/references/contribute-member.md index 40a3f8b36..d0b1c0e95 100644 --- a/skill-data/core/references/contribute-member.md +++ b/skill-data/core/references/contribute-member.md @@ -117,9 +117,10 @@ The doc lands in the team's `learnings/` and appears for teammates on their next - Teammates receive it automatically on their next session, or via `teamai pull`. Before listing rules, `push` refreshes copies whose bodies still match a recorded -sync revision. Copilot's generated `applyTo` header does not count as a local -edit: unedited old instructions update in native format, including under -`COPILOT_HOME` in user scope. Genuine local body edits remain push candidates. +sync revision. The header teamai generates for a tool's own rules format +(Cursor and JoyCode `.mdc`, Copilot `applyTo`, Kiro `inclusion`, Qoder +`trigger`) does not count as a local edit: unedited old copies update in that +format, including under `COPILOT_HOME` in user scope. Genuine local body edits remain push candidates. Rule pre-sync leaves tools excluded by `enabledAgents` or `disabledAgents` untouched. When only team `paths` change, `applyTo` refreshes if the local file still matches a recorded version's generated copy; locally edited headers are kept. diff --git a/src/__tests__/builtin-rules.test.ts b/src/__tests__/builtin-rules.test.ts index 5f2b75356..470a6ad40 100644 --- a/src/__tests__/builtin-rules.test.ts +++ b/src/__tests__/builtin-rules.test.ts @@ -79,6 +79,21 @@ describe('builtin-rules', () => { expect(content).toContain('Team Knowledge Recall'); }); + it.each([ + ['kiro', '.kiro/steering', 'inclusion: always'], + ['qoder', '.qoder/rules', 'trigger: always_on'], + ])('deploys the recall rule to %s in its own rules format (#946)', async (tool, dir, header) => { + fs.mkdirSync(path.join(tmpDir, dir), { recursive: true }); + const teamConfig = { toolPaths: { [tool]: { rules: dir } } } as any; + + const { deployBuiltinRules } = await import('../builtin-rules.js'); + await deployBuiltinRules(teamConfig); + + const content = fs.readFileSync(path.join(tmpDir, dir, 'teamai-recall.md'), 'utf-8'); + expect(content.startsWith(`---\n${header}\n---\n\n`)).toBe(true); + expect(content).toContain('Team Knowledge Recall'); + }); + it('should skip tool directories that do not exist (tool not installed)', async () => { // Arrange: only create one tool directory const claudeRulesDir = path.join(tmpDir, '.claude', 'rules'); diff --git a/src/__tests__/cursor-mdc.test.ts b/src/__tests__/cursor-mdc.test.ts index 50b49afcf..9e6c14206 100644 --- a/src/__tests__/cursor-mdc.test.ts +++ b/src/__tests__/cursor-mdc.test.ts @@ -1,10 +1,7 @@ import { describe, it, expect } from 'vitest'; import matter from 'gray-matter'; -import { - teamRuleToCursorMdc, - mergeCursorBodyIntoTeamMd, - cursorMdcBodyEqualsTeamMd, -} from '../resources/cursor-mdc.js'; +import { teamRuleToCursorMdc } from '../resources/cursor-mdc.js'; +import { mergeRuleBodyIntoTeamMd, ruleBodyEqualsTeamMd } from '../resources/team-rule.js'; describe('teamRuleToCursorMdc', () => { it('maps a team `paths:` array to Cursor `globs` with alwaysApply=false', () => { @@ -83,7 +80,7 @@ Body.`; }); }); -describe('mergeCursorBodyIntoTeamMd', () => { +describe('mergeRuleBodyIntoTeamMd', () => { it('keeps the team rule frontmatter and replaces only the body', () => { const team = `--- paths: @@ -97,7 +94,7 @@ alwaysApply: false --- Edited body.`; - const merged = mergeCursorBodyIntoTeamMd(mdc, team); + const merged = mergeRuleBodyIntoTeamMd(mdc, team); expect(merged).toContain('paths:'); expect(merged).toContain('- "**/*.ts"'); expect(merged).toContain('Edited body.'); @@ -113,7 +110,7 @@ paths: ["**/*.ts"] Same body.`; const mdc = teamRuleToCursorMdc(team); - expect(mergeCursorBodyIntoTeamMd(mdc, team)).toBe(team); + expect(mergeRuleBodyIntoTeamMd(mdc, team)).toBe(team); }); it('writes body only for a rule that does not exist upstream yet', () => { @@ -122,20 +119,20 @@ alwaysApply: true --- Brand new rule.`; - expect(mergeCursorBodyIntoTeamMd(mdc, null)).toBe('Brand new rule.\n'); + expect(mergeRuleBodyIntoTeamMd(mdc, null)).toBe('Brand new rule.\n'); }); it('handles a team rule that has no frontmatter', () => { - expect(mergeCursorBodyIntoTeamMd('just a body', 'old body')).toBe('just a body\n'); + expect(mergeRuleBodyIntoTeamMd('just a body', 'old body')).toBe('just a body\n'); }); it('does not leak an empty `---/---` block into the body', () => { const mdc = '---\n---\nThe rule body.'; - expect(mergeCursorBodyIntoTeamMd(mdc, null)).toBe('The rule body.\n'); + expect(mergeRuleBodyIntoTeamMd(mdc, null)).toBe('The rule body.\n'); }); }); -describe('cursorMdcBodyEqualsTeamMd — round-trip stability', () => { +describe('ruleBodyEqualsTeamMd — round-trip stability', () => { it('a pulled .mdc compares equal to its source team .md (no spurious modified)', () => { const team = `--- paths: @@ -144,13 +141,13 @@ paths: Rule text that must not drift.`; const mdc = teamRuleToCursorMdc(team); - expect(cursorMdcBodyEqualsTeamMd(mdc, team)).toBe(true); + expect(ruleBodyEqualsTeamMd(mdc, team)).toBe(true); }); it('a mandatory (no-frontmatter) team rule round-trips equal', () => { const team = 'A mandatory rule with no frontmatter.'; const mdc = teamRuleToCursorMdc(team); - expect(cursorMdcBodyEqualsTeamMd(mdc, team)).toBe(true); + expect(ruleBodyEqualsTeamMd(mdc, team)).toBe(true); }); it('detects a genuine body edit as different', () => { @@ -165,7 +162,7 @@ alwaysApply: false --- Edited body.`; - expect(cursorMdcBodyEqualsTeamMd(editedMdc, team)).toBe(false); + expect(ruleBodyEqualsTeamMd(editedMdc, team)).toBe(false); }); it('ignores frontmatter-only differences', () => { @@ -179,6 +176,6 @@ alwaysApply: true --- Same body.`; - expect(cursorMdcBodyEqualsTeamMd(differentFrontmatter, team)).toBe(true); + expect(ruleBodyEqualsTeamMd(differentFrontmatter, team)).toBe(true); }); }); diff --git a/src/__tests__/doctor-rules-delivery.test.ts b/src/__tests__/doctor-rules-delivery.test.ts index 58ddf97a5..1a73416dc 100644 --- a/src/__tests__/doctor-rules-delivery.test.ts +++ b/src/__tests__/doctor-rules-delivery.test.ts @@ -203,6 +203,28 @@ describe('doctor — rules delivered on disk', () => { expect(claude.fix).toContain('delivered from an older copy: reviews'); }); + it.each([ + ['kiro', '.kiro/steering', '---\ninclusion: fileMatch\nfileMatchPattern: ["**/*.ts"]\n---\n\n', '`inclusion` or `fileMatchPattern`'], + ['qoder', '.qoder/rules', '---\ntrigger: glob\nglob: **/*.ts\n---\n\n', '`trigger` or `glob`'], + ])('checks %s against its own render, and names the fields it scopes by (#946)', async (tool, dir, frontmatter, fields) => { + await writeTeamRule('reviews', '---\npaths:\n - "**/*.ts"\n---\n'); + teamConfig.toolPaths = { [tool]: { rules: dir } }; + const always = tool === 'kiro' ? '---\ninclusion: always\n---\n\n' : '---\ntrigger: always_on\n---\n\n'; + await fse.outputFile(path.join(homeDir, dir, 'coding-style.md'), `${always}Body of coding-style\n`); + const reviews = path.join(homeDir, dir, 'reviews.md'); + await fse.outputFile(reviews, `${frontmatter}Body of reviews\n`); + + expect(await (await rulesCheck(tool)).check()).toBe(true); + + // A hand edit to the glob: well-formed, and wrong. + await fse.writeFile(reviews, `${frontmatter.replace('**/*.ts', '**/*.py')}Body of reviews\n`); + const check = await rulesCheck(tool); + expect(await check.check()).toBe(false); + expect(check.fix).toContain('delivered from an older copy: reviews'); + expect(check.fix).toContain(fields); + expect(check.fix).not.toContain('.mdc'); + }); + it('passes a copy the member changed since teamai delivered it, which pull keeps (#822)', async () => { const edited = path.join(homeDir, CLAUDE_RULES, 'reviews.md'); await fse.writeFile(edited, 'My own version\n'); diff --git a/src/__tests__/helpers/rule-parsers.ts b/src/__tests__/helpers/rule-parsers.ts new file mode 100644 index 000000000..ae1e82a55 --- /dev/null +++ b/src/__tests__/helpers/rule-parsers.ts @@ -0,0 +1,77 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +/** + * Rule parsers taken from a tool's installed bundle, so a render is checked + * against the code that reads it (#946). The bundles are not vendored: point + * `TEAMAI_RULE_PARSER_BUNDLES` at them, as `=` entries separated + * by the platform's path delimiter, e.g. + * + * TEAMAI_RULE_PARSER_BUNDLES=cursor=$HOME/.local/share/cursor-agent/versions/ + * + * A test whose tool has no entry is skipped; CI runs the byte-exact contract + * tests instead. A loader for another tool goes here beside Cursor's. + */ + +/** The path given for `tool`, or undefined when there is none. */ +export function ruleParserBundle(tool: string): string | undefined { + for (const entry of (process.env.TEAMAI_RULE_PARSER_BUNDLES ?? '').split(path.delimiter)) { + const at = entry.indexOf('='); + if (at > 0 && entry.slice(0, at) === tool) return entry.slice(at + 1); + } + return undefined; +} + +/** The source of the function declaration that starts at `start`, braces matched. */ +function functionSource(source: string, start: number): string { + let depth = 0; + for (let i = source.indexOf('{', start); i < source.length; i++) { + if (source[i] === '{') depth++; + else if (source[i] === '}' && --depth === 0) return source.slice(start, i + 1); + } + throw new Error('unbalanced function body'); +} + +/** The last `function NAME(...)` matching `pattern` before `before`. */ +function lastFunctionBefore(source: string, pattern: RegExp, before: number): { name: string; start: number } { + let found: { name: string; start: number } | undefined; + for (const match of source.slice(0, before).matchAll(pattern)) found = { name: match[1], start: match.index }; + if (!found) throw new Error(`no function matching ${pattern} in the bundle`); + return found; +} + +export interface CursorRuleParser { + /** Cursor's `.mdc` frontmatter parse: `{ frontmatter, body }`, or null without frontmatter. */ + parse(text: string): { frontmatter: Record; body: string } | null; + /** How Cursor turns `frontmatter.globs` into globs before matching. */ + globs(value: unknown): string[] | undefined; +} + +/** + * Cursor CLI's rule parser, out of `index.js` in a cursor-agent version + * directory (checked against 2026.09.22 and 2026.09.28). The minified names + * change per build, so the functions are found by their code: the line + * parser by its `metadata.disabledEnvironments` keys and `rawFrontmatter` + * result, its scalar reader by `"true"===`, and the glob splitter by its + * brace-depth comma split. + */ +export function loadCursorRuleParser(bundle: string): CursorRuleParser { + const file = fs.statSync(bundle).isDirectory() ? path.join(bundle, 'index.js') : bundle; + const source = fs.readFileSync(file, 'utf8'); + + const parseAt = source.indexOf('rawFrontmatter:`---\\n${'); + if (parseAt < 0) throw new Error(`no Cursor rule parser in ${file}`); + const parse = lastFunctionBefore(source, /function ([\w$]+)\((\w)\)\{const (\w)=\2\.trimStart\(\);if\(!\3\.startsWith\("---"\)\)return null/g, parseAt); + const scalar = lastFunctionBefore(source, /function ([\w$]+)\((\w)\)\{const (\w)=\2\.trim\(\);return"true"===\3\|\|"false"!==\3&&/g, parse.start); + const splitAt = source.search(/if\("\{"===(\w)\)(\w)\+\+;else if\("\}"===\1&&\2>0\)\2--;else if\(","===\1&&0===\2\)/); + if (splitAt < 0) throw new Error(`no Cursor glob splitter in ${file}`); + const split = lastFunctionBefore(source, /function ([\w$]+)\((\w)\)\{if\("string"==typeof \2\)/g, splitAt); + + const factory = new Function([ + functionSource(source, scalar.start), + functionSource(source, parse.start), + functionSource(source, split.start), + `return { parse: ${parse.name}, globs: ${split.name} };`, + ].join('\n')); + return factory() as CursorRuleParser; +} diff --git a/src/__tests__/pre-push-sync.test.ts b/src/__tests__/pre-push-sync.test.ts index 42dff39cc..0c8e07de0 100644 --- a/src/__tests__/pre-push-sync.test.ts +++ b/src/__tests__/pre-push-sync.test.ts @@ -30,6 +30,7 @@ vi.mock('../utils/git.js', () => ({ import { syncTeamUpdatesToLocal } from '../utils/pre-push-sync.js'; import { fileHash } from '../utils/fs.js'; import { teamRuleToCopilotInstructions } from '../resources/copilot-instructions.js'; +import { teamRuleToKiroSteering } from '../resources/kiro-steering.js'; import type { TeamaiConfig, LocalConfig } from '../types.js'; describe('syncTeamUpdatesToLocal — rules', () => { @@ -449,6 +450,21 @@ describe('syncTeamUpdatesToLocal — rules', () => { expect(mockGetFileContentAtRev).not.toHaveBeenCalled(); }); + it('refreshes an unedited Kiro render to the team update, in Kiro\'s format (#946)', async () => { + await fse.ensureDir(path.join(homeDir, '.kiro', 'steering')); + teamConfig.toolPaths.kiro = { rules: '.kiro/steering' }; + const oldRule = '---\npaths: ["src/**"]\n---\n\nv1 content\n'; + const newRule = '---\npaths: ["src/**"]\n---\n\nv2 content\n'; + await fse.writeFile(path.join(repoPath, 'rules', 'my-rule.md'), newRule); + const localFile = path.join(homeDir, '.kiro/steering', 'my-rule.md'); + await fse.writeFile(localFile, teamRuleToKiroSteering(oldRule)); + mockGetFileContentAtRev.mockResolvedValue(Buffer.from(oldRule)); + + await syncTeamUpdatesToLocal(teamConfig, localConfig, 'abc1234'); + + expect(await fse.readFile(localFile, 'utf-8')).toBe(teamRuleToKiroSteering(newRule)); + }); + describe.each(['project', 'user'] as const)('Copilot rules in %s scope', (scope) => { let instructionsDir: string; const oldRule = '---\npaths: ["src/**/*.ts"]\n---\n\nv1 content\n'; diff --git a/src/__tests__/pull-rule-format-upgrade.test.ts b/src/__tests__/pull-rule-format-upgrade.test.ts new file mode 100644 index 000000000..39a14496c --- /dev/null +++ b/src/__tests__/pull-rule-format-upgrade.test.ts @@ -0,0 +1,132 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import crypto from 'node:crypto'; +import path from 'node:path'; +import os from 'node:os'; +import fse from 'fs-extra'; + +vi.mock('../config.js', async (importOriginal) => ({ + ...(await importOriginal()), + requireInit: vi.fn(), + loadState: vi.fn().mockResolvedValue({ lastPull: null, lastPullRev: null }), + saveState: vi.fn(), + loadStateForScope: vi.fn(async () => ({})), + saveStateForScope: vi.fn(), + loadLocalConfigForScope: vi.fn(), + loadTeamConfig: vi.fn(), + detectProjectConfig: vi.fn().mockResolvedValue(null), + autoDetectInit: vi.fn(), +})); + +vi.mock('../utils/git.js', async (importOriginal) => ({ + ...(await importOriginal()), + pullRepo: vi.fn().mockResolvedValue('already up to date'), + getHeadRev: vi.fn().mockResolvedValue('abc1234'), + createGit: vi.fn(), +})); + +// pull() takes a real ~/.teamai/.sync-lock; parallel workers would race on it. +vi.mock('../update.js', () => ({ + acquireLock: vi.fn().mockResolvedValue(true), + releaseLock: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock('../utils/logger.js', () => ({ + log: { + persist: vi.fn(), + info: vi.fn(), + success: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + dim: vi.fn(), + }, + spinner: vi.fn(() => ({ + start: vi.fn().mockReturnThis(), + succeed: vi.fn().mockReturnThis(), + fail: vi.fn().mockReturnThis(), + warn: vi.fn().mockReturnThis(), + info: vi.fn().mockReturnThis(), + stop: vi.fn().mockReturnThis(), + })), +})); + +import { pull } from '../pull.js'; +import { log } from '../utils/logger.js'; +import { loadLocalConfigForScope, loadStateForScope, loadTeamConfig, saveStateForScope } from '../config.js'; +import { TeamaiConfigSchema, type LocalConfig, type State } from '../types.js'; + +const sha256 = (text: string) => crypto.createHash('sha256').update(text).digest('hex'); + +/** + * A CLI upgrade that gives a tool its own rules format must reach a machine + * whose team revision has not moved, or its rules stay verbatim until the + * team next changes (#946). + */ +describe('a pull at an unchanged team revision after the rule formats change (#946)', () => { + let tmpDir: string; + let homeDir: string; + let saved: State; + + const SCOPED = '---\npaths:\n - "src/**"\n---\n\nUse named exports.\n'; + const KIRO = '---\ninclusion: fileMatch\nfileMatchPattern: ["src/**"]\n---\n\nUse named exports.\n'; + const kiroCopy = () => path.join(homeDir, '.kiro', 'steering', 'scoped.md'); + const qoderCopy = () => path.join(homeDir, '.qoder', 'rules', 'scoped.md'); + const delivered = () => Object.values(saved.lastPullByWorkspace ?? {})[0]?.delivered ?? {}; + + beforeEach(async () => { + tmpDir = await fse.mkdtemp(path.join(os.tmpdir(), 'teamai-rule-format-upgrade-')); + homeDir = path.join(tmpDir, 'home'); + const repoPath = path.join(tmpDir, 'team-repo'); + await fse.ensureDir(path.join(homeDir, '.kiro')); + await fse.ensureDir(path.join(homeDir, '.qoder')); + await fse.outputFile(path.join(repoPath, 'rules', 'scoped.md'), SCOPED); + vi.stubEnv('HOME', homeDir); + saved = {} as State; + vi.mocked(saveStateForScope).mockImplementation(async (state) => { + saved = structuredClone(state); + }); + vi.mocked(loadStateForScope).mockImplementation(async () => structuredClone(saved) as never); + vi.mocked(loadTeamConfig).mockResolvedValue( + TeamaiConfigSchema.parse({ team: 'test', repo: 'https://example.invalid/x/team.git' }), + ); + vi.mocked(loadLocalConfigForScope).mockResolvedValue({ + repo: { localPath: repoPath, remote: 'https://example.invalid/x/team.git' }, + username: 'u', + updatePolicy: 'auto', + additionalRoles: [], + scope: 'user', + enabledAgents: ['kiro', 'qoder'], + } as LocalConfig); + await pull({}); + // What an older CLI left at this revision: the team rule verbatim, on + // record as delivered; the member then edited the Qoder copy. + for (const copy of [kiroCopy(), qoderCopy()]) await fse.writeFile(copy, SCOPED); + const record = Object.values(saved.lastPullByWorkspace ?? {})[0]; + record.delivered = { ...record.delivered, [kiroCopy()]: sha256(SCOPED), [qoderCopy()]: sha256(SCOPED) }; + await fse.writeFile(qoderCopy(), 'My own wording.\n'); + vi.mocked(log.success).mockClear(); + vi.mocked(log.warn).mockClear(); + }); + + afterEach(async () => { + vi.mocked(saveStateForScope).mockReset(); + vi.mocked(loadStateForScope).mockImplementation(async () => ({}) as never); + vi.unstubAllEnvs(); + await fse.remove(tmpDir); + }); + + it('re-renders the unedited copy, records it, and leaves the edited one', async () => { + await pull({}); + + const successes = vi.mocked(log.success).mock.calls.map(([message]) => String(message)); + expect(successes.some((message) => message.includes('Already synced at abc1234'))).toBe(true); + expect(await fse.readFile(kiroCopy(), 'utf8')).toBe(KIRO); + expect(await fse.readFile(qoderCopy(), 'utf8')).toBe('My own wording.\n'); + expect(delivered()[kiroCopy()]).toBe(sha256(KIRO)); + expect(delivered()[qoderCopy()]).toBe(sha256(SCOPED)); + expect(successes).toContain('[user] Rewrote 1 rule(s) in their tool\'s own format: scoped'); + // Kept and named, as a full sync names it (#822). + const warnings = vi.mocked(log.warn).mock.calls.map(([message]) => String(message)); + expect(warnings.filter((message) => message.includes(`Kept ${qoderCopy()}`))).toHaveLength(1); + }); +}); diff --git a/src/__tests__/rule-parsers.test.ts b/src/__tests__/rule-parsers.test.ts new file mode 100644 index 000000000..545055f9e --- /dev/null +++ b/src/__tests__/rule-parsers.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from 'vitest'; +import { teamRuleToCursorMdc } from '../resources/cursor-mdc.js'; +import { loadCursorRuleParser, ruleParserBundle } from './helpers/rule-parsers.js'; + +/** + * Each render read back by the tool's own parser, taken from its installed + * bundle (`TEAMAI_RULE_PARSER_BUNDLES`, see helpers/rule-parsers.ts). Skipped + * where the bundle is absent; the contract tests pin the bytes everywhere. + */ +const BODY = 'Use named exports.\n\n---\n\nA rule with a horizontal rule in it.'; + +const cursorBundle = ruleParserBundle('cursor'); + +describe.skipIf(!cursorBundle)('Cursor reads the .mdc render as intended', () => { + const parser = cursorBundle ? loadCursorRuleParser(cursorBundle) : undefined; + + it.each([ + ['an unscoped rule', BODY, true, undefined], + ['an inline list', `---\npaths: ["src/**/*.ts", "test/**"]\n---\n\n${BODY}`, false, ['src/**/*.ts', 'test/**']], + ['a block list', `---\npaths:\n - "src/**/*.ts"\n - test/**\n---\n\n${BODY}`, false, ['src/**/*.ts', 'test/**']], + ['a brace glob', `---\npaths:\n - "src/{a,b}/**"\n - "**/*.{ts,tsx}"\n---\n\n${BODY}`, false, ['src/{a,b}/**', '**/*.{ts,tsx}']], + ['an unquoted alias-like glob', `---\npaths: **/*.ts\n---\n\n${BODY}`, false, ['**/*.ts']], + ])('%s', (_label, source, alwaysApply, globs) => { + const parsed = parser!.parse(teamRuleToCursorMdc(source)); + + expect(parsed).not.toBeNull(); + expect(parsed!.frontmatter.alwaysApply).toBe(alwaysApply); + expect(parser!.globs(parsed!.frontmatter.globs)).toEqual(globs); + expect(parsed!.body).toBe(BODY); + }); +}); diff --git a/src/__tests__/rule-render-contracts.test.ts b/src/__tests__/rule-render-contracts.test.ts new file mode 100644 index 000000000..a4c588b6e --- /dev/null +++ b/src/__tests__/rule-render-contracts.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from 'vitest'; +import { teamRuleToKiroSteering } from '../resources/kiro-steering.js'; +import { teamRuleToQoderRule } from '../resources/qoder-rule.js'; + +/** + * The exact bytes each tool's rule render writes (#946). Kiro and Qoder ship + * no parser to run, so these pin the documented form. + */ +const UNSCOPED = 'Use named exports.\n'; +const INLINE = '---\npaths: ["src/**/*.ts", "test/**"]\n---\n\nUse named exports.\n'; +const BLOCK = '---\npaths:\n - "src/**/*.ts"\n - test/**\n---\n\nUse named exports.\n'; +const BRACE = '---\npaths:\n - "src/{a,b}/**"\n---\n\nUse named exports.\n'; + +describe('Kiro steering render', () => { + it('makes an unscoped rule always included', () => { + expect(teamRuleToKiroSteering(UNSCOPED)).toBe('---\ninclusion: always\n---\n\nUse named exports.\n'); + }); + + it.each([ + ['an inline list', INLINE], + ['a block list', BLOCK], + ])('scopes %s with fileMatch and a fileMatchPattern list', (_label, source) => { + expect(teamRuleToKiroSteering(source)).toBe( + '---\ninclusion: fileMatch\nfileMatchPattern: ["src/**/*.ts", "test/**"]\n---\n\nUse named exports.\n', + ); + }); + + it('keeps a brace glob whole, since the pattern list does not split on commas', () => { + expect(teamRuleToKiroSteering(BRACE)).toBe( + '---\ninclusion: fileMatch\nfileMatchPattern: ["src/{a,b}/**"]\n---\n\nUse named exports.\n', + ); + }); +}); + +describe('Qoder rule render', () => { + it('makes an unscoped rule always on', () => { + expect(teamRuleToQoderRule(UNSCOPED)).toBe('---\ntrigger: always_on\n---\n\nUse named exports.\n'); + }); + + it.each([ + ['an inline list', INLINE], + ['a block list', BLOCK], + ])('scopes %s with trigger glob and one comma-joined glob line, as Qoder Desktop writes it', (_label, source) => { + expect(teamRuleToQoderRule(source)).toBe( + '---\ntrigger: glob\nglob: src/**/*.ts, test/**\n---\n\nUse named exports.\n', + ); + }); + + it('expands a brace glob, since the glob line is split on every comma', () => { + expect(teamRuleToQoderRule(BRACE)).toBe( + '---\ntrigger: glob\nglob: src/a/**, src/b/**\n---\n\nUse named exports.\n', + ); + }); + + it('expands a brace glob given as a comma-separated paths string', () => { + const source = '---\npaths: "src/{a,b}/**, test/**"\n---\n\nUse named exports.\n'; + expect(teamRuleToQoderRule(source)).toBe( + '---\ntrigger: glob\nglob: src/a/**, src/b/**, test/**\n---\n\nUse named exports.\n', + ); + }); +}); + +describe('team rule paths, shared by every render', () => { + // gray-matter caches a parse by content, failures included: the retry that + // quotes `**/*.ts` must not lose to a cached failure on the next render. + it('scopes an unquoted alias-like glob on every render, not just the first', () => { + const source = '---\npaths: **/*.ts\n---\n\nUse named exports.\n'; + const scoped = '---\ninclusion: fileMatch\nfileMatchPattern: ["**/*.ts"]\n---\n\nUse named exports.\n'; + expect(teamRuleToKiroSteering(source)).toBe(scoped); + expect(teamRuleToKiroSteering(source)).toBe(scoped); + }); +}); diff --git a/src/__tests__/rules.test.ts b/src/__tests__/rules.test.ts index 3c752ba7c..12acfdebb 100644 --- a/src/__tests__/rules.test.ts +++ b/src/__tests__/rules.test.ts @@ -1385,6 +1385,153 @@ describe('RulesHandler — Cursor-compatible .mdc handling', () => { }); }); +describe('RulesHandler — Kiro and Qoder rule formats (#946)', () => { + let tmpDir: string; + let homeDir: string; + let repoPath: string; + let handler: RulesHandler; + let teamConfig: TeamaiConfig; + let localConfig: LocalConfig; + + const SCOPED = '---\npaths:\n - "src/{a,b}/**"\n---\n\nUse named exports.\n'; + const KIRO = '---\ninclusion: fileMatch\nfileMatchPattern: ["src/{a,b}/**"]\n---\n\nUse named exports.\n'; + const QODER = '---\ntrigger: glob\nglob: src/a/**, src/b/**\n---\n\nUse named exports.\n'; + + beforeEach(async () => { + tmpDir = await fse.mkdtemp(path.join(os.tmpdir(), 'teamai-rules-formats-')); + homeDir = path.join(tmpDir, 'home'); + repoPath = path.join(tmpDir, 'team-repo'); + await fse.ensureDir(path.join(repoPath, 'rules')); + await fse.writeFile(path.join(repoPath, 'rules', 'scoped.md'), SCOPED); + for (const dir of ['.claude/rules', '.kiro/steering', '.qoder/rules', '.qoder-cn/rules']) { + await fse.ensureDir(path.join(homeDir, dir)); + } + vi.stubEnv('HOME', homeDir); + handler = new RulesHandler(); + teamConfig = { + team: 'test', + description: '', + repo: 'https://git.woa.com/test/repo.git', + provider: 'tgit' as const, + reviewers: [], + sharing: { skills: {}, rules: { enforced: [] }, docs: { localDir: '' }, env: { injectShellProfile: true } }, + toolPaths: { + claude: { rules: '.claude/rules' }, + kiro: { rules: '.kiro/steering' }, + qoder: { rules: '.qoder/rules' }, + 'qoder-cn': { rules: '.qoder/rules', userScope: { rules: '.qoder-cn/rules' } }, + }, + }; + localConfig = { + repo: { localPath: repoPath, remote: 'https://git.woa.com/test/repo.git' }, + username: 'testuser', + updatePolicy: 'auto', + additionalRoles: [], + scope: 'user', + }; + }); + + afterEach(async () => { + vi.unstubAllEnvs(); + await fse.remove(tmpDir); + }); + + it('writes each tool its own render in user scope', async () => { + await handler.pullAllRules(teamConfig, localConfig); + + expect(await fse.readFile(path.join(homeDir, '.kiro/steering/scoped.md'), 'utf-8')).toBe(KIRO); + expect(await fse.readFile(path.join(homeDir, '.qoder/rules/scoped.md'), 'utf-8')).toBe(QODER); + expect(await fse.readFile(path.join(homeDir, '.qoder-cn/rules/scoped.md'), 'utf-8')).toBe(QODER); + expect(await fse.readFile(path.join(homeDir, '.claude/rules/scoped.md'), 'utf-8')).toBe(SCOPED); + }); + + it('writes the same renders in project scope', async () => { + const projectRoot = path.join(tmpDir, 'project'); + for (const dir of ['.kiro/steering', '.qoder/rules']) await fse.ensureDir(path.join(projectRoot, dir)); + localConfig.scope = 'project'; + localConfig.projectRoot = projectRoot; + + await handler.pullAllRules(teamConfig, localConfig); + + expect(await fse.readFile(path.join(projectRoot, '.kiro/steering/scoped.md'), 'utf-8')).toBe(KIRO); + expect(await fse.readFile(path.join(projectRoot, '.qoder/rules/scoped.md'), 'utf-8')).toBe(QODER); + }); + + it('a clean pull leaves nothing to push', async () => { + await handler.pullAllRules(teamConfig, localConfig); + + expect(await handler.scanLocalForPush(teamConfig, localConfig)).toEqual([]); + }); + + it.each([ + ['kiro', '.kiro/steering', KIRO], + ['qoder', '.qoder/rules', QODER], + ])('pushes an edited %s body into the team rule without the tool frontmatter', async (_tool, dir, render) => { + await handler.pullAllRules(teamConfig, localConfig); + const copy = path.join(homeDir, dir, 'scoped.md'); + await fse.writeFile(copy, render.replace('Use named exports.', 'Use default exports.')); + + const items = await handler.scanLocalForPush(teamConfig, localConfig); + expect(items).toMatchObject([{ name: 'scoped', status: 'modified', sourcePath: copy }]); + await handler.pushItem(items[0], teamConfig, localConfig); + + expect(await fse.readFile(path.join(repoPath, 'rules', 'scoped.md'), 'utf-8')) + .toBe('---\npaths:\n - "src/{a,b}/**"\n---\n\nUse default exports.\n'); + }); + + it("does not offer a member's own steering file as a new team rule", async () => { + await fse.writeFile(path.join(homeDir, '.kiro/steering/product.md'), '---\ninclusion: always\n---\n\nOur product.\n'); + await fse.writeFile(path.join(homeDir, '.qoder/rules/mine.md'), '---\ntrigger: always_on\n---\n\nMine.\n'); + + const items = await handler.scanLocalForPush(teamConfig, localConfig); + + expect(items.map((item) => item.name)).toEqual([]); + }); + + it("keeps a member's own steering and Qoder rule files on pull, and still reclaims a team copy on record", async () => { + const product = path.join(homeDir, '.kiro/steering/product.md'); + const mine = path.join(homeDir, '.qoder/rules/mine.md'); + await fse.writeFile(product, '---\ninclusion: always\n---\n\nOur product.\n'); + await fse.writeFile(mine, '---\ntrigger: always_on\n---\n\nMine.\n'); + // A team rule this checkout received before, no longer delivered here. + const formerKiro = path.join(homeDir, '.kiro/steering/former.md'); + const formerQoder = path.join(homeDir, '.qoder/rules/former.md'); + await fse.writeFile(formerKiro, '---\ninclusion: always\n---\n\nFormer.\n'); + await fse.writeFile(formerQoder, '---\ntrigger: always_on\n---\n\nFormer.\n'); + const previous: DeliveredHashes = {}; + await recordDelivered(previous, formerKiro); + await recordDelivered(previous, formerQoder); + const ledger = openLedger(previous); + + await handler.pullAllRules(teamConfig, localConfig, undefined, [], ledger); + + expect(await fse.readFile(product, 'utf-8')).toBe('---\ninclusion: always\n---\n\nOur product.\n'); + expect(await fse.readFile(mine, 'utf-8')).toBe('---\ntrigger: always_on\n---\n\nMine.\n'); + expect(await fse.pathExists(formerKiro)).toBe(false); + expect(await fse.pathExists(formerQoder)).toBe(false); + expect(Object.keys(ledger.hashes).filter((file) => file.endsWith('former.md'))).toEqual([]); + }); + + it('re-renders a verbatim copy an older teamai delivered, and keeps one the member edited (#822)', async () => { + const kiroCopy = path.join(homeDir, '.kiro/steering/scoped.md'); + const qoderCopy = path.join(homeDir, '.qoder/rules/scoped.md'); + await fse.writeFile(kiroCopy, SCOPED); + await fse.writeFile(qoderCopy, SCOPED); + const previous: DeliveredHashes = {}; + await recordDelivered(previous, kiroCopy); + await recordDelivered(previous, qoderCopy); + const edited = SCOPED.replace('Use named exports.', 'My own wording.'); + await fse.writeFile(qoderCopy, edited); + const ledger = openLedger(previous); + + await handler.pullAllRules(teamConfig, localConfig, undefined, [], ledger); + + expect(await fse.readFile(kiroCopy, 'utf-8')).toBe(KIRO); + expect(await fse.readFile(qoderCopy, 'utf-8')).toBe(edited); + expect(ledger.kept.map((kept) => kept.dest)).toEqual([qoderCopy]); + }); +}); + describe('inlinedRulesText — rules inlined into one instructions file (#938)', () => { let tmpDir: string; diff --git a/src/builtin-rules.ts b/src/builtin-rules.ts index cbe19bbb6..456e6a018 100644 --- a/src/builtin-rules.ts +++ b/src/builtin-rules.ts @@ -2,8 +2,7 @@ import path from 'node:path'; import { ensureDir, writeFile, pathExists } from './utils/fs.js'; import { log } from './utils/logger.js'; import { isToolInstalledForConfig, ResourceHandler } from './resources/base.js'; -import { ruleFileExtensionForTool, usesCursorMdcRules } from './resources/rule-format.js'; -import { teamRuleToCursorMdc } from './resources/cursor-mdc.js'; +import { renderRuleForTool, ruleFileExtensionForTool } from './resources/rule-format.js'; import type { TeamaiConfig, LocalConfig } from './types.js'; import { resolveToolBaseDir, isAgentExcluded, scopedToolPaths } from './types.js'; import fs from 'node:fs/promises'; @@ -86,14 +85,13 @@ export async function deployBuiltinRules( try { await ensureDir(rulesDir); - // Deploy current built-in rules. Cursor-compatible tools use `.mdc` - // with derived frontmatter; every other tool gets canonical `.md`. + // Deploy current built-in rules in each tool's own rules format + // (`.mdc` for Cursor, Kiro's and Qoder's frontmatter, …); a tool + // without one gets the canonical `.md`. const ext = ruleFileExtensionForTool(tool); for (const rule of builtinRules) { const destFile = path.join(rulesDir, `${rule.name}${ext}`); - const content = usesCursorMdcRules(tool) - ? teamRuleToCursorMdc(rule.content) - : rule.content; + const content = renderRuleForTool(tool, rule.content); await writeFile(destFile, content); log.debug(`Deployed built-in rule ${rule.name} → ${tool}`); diff --git a/src/doctor-delivery.ts b/src/doctor-delivery.ts index 2adc5b5d0..b9e7a6712 100644 --- a/src/doctor-delivery.ts +++ b/src/doctor-delivery.ts @@ -20,6 +20,7 @@ import { SHELL_PROFILE_CANDIDATE_NAMES, } from './utils/shell-profile.js'; import { getUserHome } from './utils/home.js'; +import { ruleFormatForTool } from './resources/rule-format.js'; /** * The checks that verify the payload rather than the plumbing: what each tool @@ -272,9 +273,10 @@ export async function buildRulesDeliveryChecks(ctx: DoctorContext): Promise `\`${field}\``); + return `An older copy is one whose bytes are no longer what teamai renders for ${tool}, frontmatter included: ` + + `one whose ${named.slice(0, -1).join(', ')}${named.length > 1 ? ' or ' : ''}${named[named.length - 1]} drifted ` + + 'from the team `.md` applies to the wrong files while looking perfectly well-formed.'; +} + /** * The two rule destinations that are not a file per tool. * diff --git a/src/pull.ts b/src/pull.ts index 115d9adf1..d5515cbd6 100644 --- a/src/pull.ts +++ b/src/pull.ts @@ -826,6 +826,46 @@ async function openCheckoutLedger(localConfig: LocalConfig, state?: State): Prom return openLedger(record?.delivered, record?.agentModels); } +/** + * Rules on the "Already synced" fast path (#946): a CLI upgrade can change + * what a tool's rule copy should hold while the team repo stays put, as when + * Kiro and Qoder got their own format. Only a copy still on record as what + * teamai wrote is rewritten (`RulesHandler.rerenderOutdatedCopies`), and the + * record follows, so the next pull does not read the new bytes as an edit. A + * copy the member changed is kept and named, as a full sync names it. + */ +async function rerenderOutdatedRules( + freshConfig: TeamaiConfig, + localConfig: LocalConfig, + roleContext: RolePullContext | null, + scopeLabel: string, +): Promise { + try { + const key = await checkoutRecordKey(localConfig); + if (!key) return; + const state = await loadStateForScope(localConfig); + const ledger = await openCheckoutLedger(localConfig, state); + if (ledger.previous === undefined) return; + const { items } = await resolveDesiredRules(freshConfig, localConfig, roleContext); + const rewritten = await (getHandler('rules') as RulesHandler).rerenderOutdatedCopies( + freshConfig, localConfig, items, ledger, + ); + reportKept(ledger, scopeLabel); + if (rewritten.length === 0) return; + const record = localConfig.scope === 'user' ? await userScopeRecord(state) : state.lastPullByWorkspace?.[key]; + if (record) { + record.delivered = ledger.hashes; + await saveStateForScope(state, localConfig); + } + log.success(`[${scopeLabel}] Rewrote ${rewritten.length} rule(s) in their tool's own format: ${rewritten.join(', ')}`); + } catch (e) { + log.warn( + `[${scopeLabel}] Could not check whether delivered rules need their tool's format: ${(e as Error).message}. ` + + 'Copies may still be in an older format; fix the cause, then run `teamai pull --force`.', + ); + } +} + /** * Agents on the "Already synced" fast path (#830): an agent's model can * change while the team repo stays put — a CLI upgrade that resolves an alias @@ -1315,6 +1355,9 @@ async function pullForScope( } catch (error) { log.warn(`[${scopeLabel}] Codex's team rules were not updated: ${(error as Error).message}. Run \`teamai pull --force\` to retry.`); } + // Same reason: a CLI that gives a tool its own rules format must + // re-render the copies an older one wrote verbatim (#946). + await rerenderOutdatedRules(freshConfig, localConfig, roleContext, scopeLabel); } // The repo has not moved, but an agent's model may have (#830). if (resourceTypes.includes('agents')) { diff --git a/src/resources/copilot-instructions.ts b/src/resources/copilot-instructions.ts index bf5202225..6c0fc86e9 100644 --- a/src/resources/copilot-instructions.ts +++ b/src/resources/copilot-instructions.ts @@ -1,5 +1,6 @@ import { splitFrontmatter, stringifyFrontmatter } from '../utils/frontmatter.js'; -import { rulePaths } from './rule-format.js'; +import type { RuleFormat } from './rule-format.js'; +import { rulePaths } from './team-rule.js'; const ALL_FILES_GLOB = '**'; @@ -43,3 +44,12 @@ export function copilotInstructionsBodyEqualsTeamMd( return normalizeBody(splitFrontmatter(rawCopilotInstructions).body) === normalizeBody(splitFrontmatter(rawTeamRule).body); } + +/** GitHub Copilot's instructions format. */ +export const COPILOT_INSTRUCTIONS_FORMAT: RuleFormat = { + extension: '.instructions.md', + render: teamRuleToCopilotInstructions, + bodyEquals: copilotInstructionsBodyEqualsTeamMd, + mergeBodyIntoTeam: mergeCopilotBodyIntoTeamMd, + scopeFields: ['applyTo'], +}; diff --git a/src/resources/cursor-mdc.ts b/src/resources/cursor-mdc.ts index 1e83614a7..84f01642b 100644 --- a/src/resources/cursor-mdc.ts +++ b/src/resources/cursor-mdc.ts @@ -1,4 +1,5 @@ -import matter from 'gray-matter'; +import type { RuleFormat } from './rule-format.js'; +import { mergeRuleBodyIntoTeamMd, ruleBodyEqualsTeamMd, rulePaths, teamRuleBody, teamRuleData } from './team-rule.js'; /** * Cursor project rules must live in `.cursor/rules/*.mdc` with YAML frontmatter @@ -10,8 +11,8 @@ import matter from 'gray-matter'; * frontmatter (currently a `paths:` array used to scope a rule to file globs). * This module converts between the two representations: * - * team `.md` ──teamRuleToCursorMdc───────▶ Cursor `.mdc` (pull) - * Cursor `.mdc` ──mergeCursorBodyIntoTeamMd──▶ team `.md` (push) + * team `.md` ──teamRuleToCursorMdc──────▶ Cursor `.mdc` (pull) + * Cursor `.mdc` ──mergeRuleBodyIntoTeamMd──▶ team `.md` (push) * * Mapping: * - team `paths: [glob, ...]` → Cursor `globs: ""` + `alwaysApply: false` @@ -23,7 +24,7 @@ import matter from 'gray-matter'; * frontmatter is machine-derived, and on push the team file keeps its own * frontmatter and only its body is replaced. That is what lets a pull→push * round-trip avoid both spurious "modified" diffs (see - * cursorMdcBodyEqualsTeamMd) and silent loss of the team rule's `paths:` scope. + * ruleBodyEqualsTeamMd) and silent loss of the team rule's `paths:` scope. */ /** The Cursor frontmatter fields we emit. */ @@ -32,73 +33,6 @@ interface CursorFrontmatter { alwaysApply: boolean; } -/** - * A leading `---\n...\n---` frontmatter block, with an optional BOM. The inner - * group is optional so an empty block (`---\n---`) matches too — otherwise its - * delimiters would leak into the body and get pushed to the team repo verbatim. - */ -const FRONTMATTER_RE = /^?---\r?\n(?:[\s\S]*?\r?\n)?---\r?\n?/; - -/** - * Split a rule file into its leading frontmatter block (empty string when there - * is none) and its body. Deliberately textual, NOT a YAML parse: the Cursor - * `globs` value is a glob, and a strict parse of a malformed one would fail and - * swallow the frontmatter into the body. Delimiter-based splitting round-trips - * regardless of YAML validity. - */ -function splitFrontmatter(raw: string): { block: string; body: string } { - const m = raw.match(FRONTMATTER_RE); - return m ? { block: m[0], body: raw.slice(m[0].length) } : { block: '', body: raw }; -} - -/** Extract the markdown body of a rule file, dropping any frontmatter block. */ -function extractBody(raw: string): string { - return splitFrontmatter(raw).body; -} - -/** - * Quote scalars that YAML would read as an alias (`*`) or anchor (`&`) node. - * A glob is the common case: `globs: **\/*.ts` is not valid YAML, so a strict - * parse of an otherwise fine frontmatter block throws on it. - */ -function quoteYamlUnsafeScalars(block: string): string { - return block - .split(/\r?\n/) - .map((line) => { - const m = line.match(/^(\s*(?:-\s+|[A-Za-z0-9_.-]+:[ \t]+))([*&][^"']*)$/); - return m ? `${m[1]}"${m[2].trimEnd()}"` : line; - }) - .join('\n'); -} - -/** - * Parse a team rule's frontmatter data with gray-matter, retrying once with - * alias-unsafe scalars quoted so a rule authored as `globs: **\/*.ts` is still - * honoured rather than silently falling back to always-on. Returns empty data - * when both attempts fail. - */ -function parseFrontmatterData(raw: string): Record { - try { - return matter(raw).data; - } catch { - // Invalid YAML — retry below with unsafe scalars quoted. - } - - const { block } = splitFrontmatter(raw); - if (!block) return {}; - const quoted = quoteYamlUnsafeScalars(block); - try { - return matter(quoted.endsWith('\n') ? quoted : `${quoted}\n`).data; - } catch { - return {}; - } -} - -/** Normalize a markdown body for comparison (ignore leading/trailing whitespace). */ -function normalizeBody(body: string): string { - return body.replace(/^\s+/, '').replace(/\s+$/, ''); -} - /** * Derive Cursor frontmatter from a team rule's frontmatter data. * @@ -107,13 +41,8 @@ function normalizeBody(body: string): string { * mandatory team rule and made always-on (`alwaysApply: true`). */ function deriveCursorFrontmatter(data: Record): CursorFrontmatter { - const rawPaths = data.paths ?? data.globs; - const patterns = Array.isArray(rawPaths) - ? rawPaths.map((p) => String(p).trim()).filter(Boolean) - : typeof rawPaths === 'string' && rawPaths.trim() !== '' - ? rawPaths.split(',').map((p) => p.trim()).filter(Boolean) - : []; - + // `globs` is read too: a rule authored in Cursor's own spelling stays scoped. + const patterns = rulePaths({ paths: data.paths ?? data.globs }); if (patterns.length > 0) { return { globs: patterns.join(', '), alwaysApply: false }; } @@ -129,51 +58,21 @@ function renderCursorMdc(fm: CursorFrontmatter, body: string): string { if (fm.globs !== undefined) lines.push(`globs: ${JSON.stringify(fm.globs)}`); lines.push(`alwaysApply: ${fm.alwaysApply}`); lines.push('---'); - return `${lines.join('\n')}\n\n${normalizeBody(body)}\n`; + return `${lines.join('\n')}\n\n${body}\n`; } /** * Convert a team repo rule file (`.md`) into Cursor `.mdc` content. */ export function teamRuleToCursorMdc(rawTeamRule: string): string { - const data = parseFrontmatterData(rawTeamRule); - return renderCursorMdc(deriveCursorFrontmatter(data), extractBody(rawTeamRule)); + return renderCursorMdc(deriveCursorFrontmatter(teamRuleData(rawTeamRule)), teamRuleBody(rawTeamRule)); } -/** - * Write a Cursor `.mdc` file's markdown body back into the team repo `.md`, - * keeping the team file's own frontmatter. - * - * Only the body crosses back: the Cursor-specific frontmatter (globs/alwaysApply) - * is machine-derived on pull and is dropped, while the team rule's tool-neutral - * frontmatter (`paths:`, …) is preserved from `existingTeamMd`. Dropping it - * instead would silently un-scope the rule for the whole team on the next pull. - * - * `existingTeamMd` is null for a rule that does not exist upstream yet, in which - * case the body alone becomes the new team file. - */ -export function mergeCursorBodyIntoTeamMd( - rawCursorMdc: string, - existingTeamMd: string | null, -): string { - const body = normalizeBody(extractBody(rawCursorMdc)); - if (existingTeamMd === null) return `${body}\n`; - - // Body unchanged — hand back the team file byte-for-byte so a no-op push - // never shows up as a diff. - if (normalizeBody(extractBody(existingTeamMd)) === body) return existingTeamMd; - - const { block } = splitFrontmatter(existingTeamMd); - if (!block) return `${body}\n`; - return `${block.endsWith('\n') ? block : `${block}\n`}\n${body}\n`; -} - -/** - * Compare the markdown body of a Cursor `.mdc` file against a team repo `.md` - * file, ignoring frontmatter on both sides. Used by push scanning so that a - * pull-then-push round-trip (which rewrites frontmatter) is not seen as a - * content modification. - */ -export function cursorMdcBodyEqualsTeamMd(rawCursorMdc: string, rawTeamRule: string): boolean { - return normalizeBody(extractBody(rawCursorMdc)) === normalizeBody(extractBody(rawTeamRule)); -} +/** Cursor's rules format, also JoyCode's (`.mdc`). */ +export const CURSOR_MDC_FORMAT: RuleFormat = { + extension: '.mdc', + render: teamRuleToCursorMdc, + bodyEquals: ruleBodyEqualsTeamMd, + mergeBodyIntoTeam: mergeRuleBodyIntoTeamMd, + scopeFields: ['globs', 'alwaysApply'], +}; diff --git a/src/resources/kiro-steering.ts b/src/resources/kiro-steering.ts new file mode 100644 index 000000000..ed61bea4a --- /dev/null +++ b/src/resources/kiro-steering.ts @@ -0,0 +1,31 @@ +import type { RuleFormat } from './rule-format.js'; +import { mergeRuleBodyIntoTeamMd, ruleBodyEqualsTeamMd, rulePaths, teamRuleBody, teamRuleData } from './team-rule.js'; + +/** + * Kiro steering files (`.kiro/steering/*.md`, `~/.kiro/steering/*.md`) choose + * when they load from their own frontmatter, which must open the file + * (https://kiro.dev/docs/steering/): + * + * - team `paths: [glob, ...]` → `inclusion: fileMatch` + `fileMatchPattern: ["glob", ...]` + * - no `paths` → `inclusion: always` + * + * Kiro ignores `paths:`, so a verbatim copy of a scoped rule was always on. + * The pattern is always a list, Kiro's documented form for several globs, so + * a brace glob stays one entry. + */ +export function teamRuleToKiroSteering(rawTeamRule: string): string { + const paths = rulePaths(teamRuleData(rawTeamRule)); + const frontmatter = paths.length > 0 + ? ['inclusion: fileMatch', `fileMatchPattern: [${paths.map((glob) => JSON.stringify(glob)).join(', ')}]`] + : ['inclusion: always']; + return `---\n${frontmatter.join('\n')}\n---\n\n${teamRuleBody(rawTeamRule)}\n`; +} + +/** Kiro's steering format. */ +export const KIRO_STEERING_FORMAT: RuleFormat = { + extension: '.md', + render: teamRuleToKiroSteering, + bodyEquals: ruleBodyEqualsTeamMd, + mergeBodyIntoTeam: mergeRuleBodyIntoTeamMd, + scopeFields: ['inclusion', 'fileMatchPattern'], +}; diff --git a/src/resources/qoder-rule.ts b/src/resources/qoder-rule.ts new file mode 100644 index 000000000..dac9c4d59 --- /dev/null +++ b/src/resources/qoder-rule.ts @@ -0,0 +1,37 @@ +import type { RuleFormat } from './rule-format.js'; +import { + expandBraces, + mergeRuleBodyIntoTeamMd, + ruleBodyEqualsTeamMd, + rulePaths, + teamRuleBody, + teamRuleData, +} from './team-rule.js'; + +/** + * Qoder rules (`.qoder/rules`, `~/.qoder/rules`, `~/.qoder-cn/rules`) in the + * form Qoder Desktop writes them, which Qoder CLI reads too. Qoder publishes no + * schema; the form comes from the rule files in `alibaba/tron-one-agent`: + * + * - team `paths: [glob, ...]` → `trigger: glob` + `glob: a, b` on one line + * - no `paths` → `trigger: always_on` + * + * The glob line is split on every comma, so a `{a,b}` alternation is expanded + * into separate globs. It is written unquoted, as Desktop writes it. + */ +export function teamRuleToQoderRule(rawTeamRule: string): string { + const globs = [...new Set(rulePaths(teamRuleData(rawTeamRule)).flatMap(expandBraces))]; + const frontmatter = globs.length > 0 + ? ['trigger: glob', `glob: ${globs.join(', ')}`] + : ['trigger: always_on']; + return `---\n${frontmatter.join('\n')}\n---\n\n${teamRuleBody(rawTeamRule)}\n`; +} + +/** Qoder's rules format, Qoder CN's too. */ +export const QODER_RULE_FORMAT: RuleFormat = { + extension: '.md', + render: teamRuleToQoderRule, + bodyEquals: ruleBodyEqualsTeamMd, + mergeBodyIntoTeam: mergeRuleBodyIntoTeamMd, + scopeFields: ['trigger', 'glob'], +}; diff --git a/src/resources/rule-format.ts b/src/resources/rule-format.ts index 754b85726..f62dd5c54 100644 --- a/src/resources/rule-format.ts +++ b/src/resources/rule-format.ts @@ -1,38 +1,98 @@ /** * Per-tool on-disk format for rule files. * - * The team repo always stores rules as tool-neutral `.md`. Most tools take - * a verbatim `.md` copy. Cursor and JoyCode use `.mdc` rules, while GitHub - * Copilot CLI uses `.instructions.md`; those copies carry native frontmatter. + * The team repo always stores rules as tool-neutral `.md`. A tool with + * a rules format of its own gets a render of it (`RULE_FORMATS`): Cursor and + * JoyCode `.mdc`, Copilot `.instructions.md`, Kiro steering and Qoder rules + * `.md` with their own frontmatter. Every other tool takes a verbatim `.md` + * copy. * * This module is the single place that decision lives, mirroring * `agentFileExtensionForTool` in `./agent-format.ts`. Every site that writes, - * scans, or deletes files in a tool's rules directory must go through it, so a - * new per-tool extension never has to be re-discovered call site by call site. + * scans, compares, pushes or deletes files in a tool's rules directory must go + * through it, so a new format never has to be re-discovered call site by call + * site. Adding one is a render module exporting a `RuleFormat` plus one entry + * below. */ import type { TeamaiConfig } from '../types.js'; +import { COPILOT_INSTRUCTIONS_FORMAT } from './copilot-instructions.js'; +import { CURSOR_MDC_FORMAT } from './cursor-mdc.js'; +import { KIRO_STEERING_FORMAT } from './kiro-steering.js'; +import { QODER_RULE_FORMAT } from './qoder-rule.js'; type ToolPath = TeamaiConfig['toolPaths'][string]; -const CURSOR_MDC_RULE_TOOLS = new Set(['cursor', 'joycode']); -const COPILOT_INSTRUCTIONS_RULE_TOOLS = new Set(['copilot']); +/** How one tool's rule file is written from, and read back into, the team `.md`. */ +export interface RuleFormat { + /** The extension the rule file is written with. */ + readonly extension: '.md' | '.mdc' | '.instructions.md'; + /** The bytes the team rule becomes for the tool. */ + render(rawTeamRule: string): string; + /** Whether a tool copy carries the team rule's body; frontmatter is derived, so not compared. */ + bodyEquals(rawToolRule: string, rawTeamRule: string): boolean; + /** The team `.md` with a tool copy's body pushed into it, the team frontmatter kept; null for a new rule. */ + mergeBodyIntoTeam(rawToolRule: string, existingTeamMd: string | null): string; + /** The frontmatter fields the tool scopes a rule by, named in doctor's fix. */ + readonly scopeFields: readonly string[]; +} + +/** + * The tools with a rules format of their own. A copy there is a render, so + * push compares and sends back its body only, and a file teamai did not + * deliver is the member's own rule in the tool's format, never a new team + * rule. + */ +const RULE_FORMATS: Readonly> = { + cursor: CURSOR_MDC_FORMAT, + joycode: CURSOR_MDC_FORMAT, + copilot: COPILOT_INSTRUCTIONS_FORMAT, + kiro: KIRO_STEERING_FORMAT, + qoder: QODER_RULE_FORMAT, + 'qoder-cn': QODER_RULE_FORMAT, +}; + const SESSION_HOOK_RULE_TOOLS = new Set(['codex', 'codex-internal', 'tcodex']); +/** The tool's own rules format; undefined when it takes the team `.md` verbatim. */ +export function ruleFormatForTool(tool: string): RuleFormat | undefined { + return Object.hasOwn(RULE_FORMATS, tool) ? RULE_FORMATS[tool] : undefined; +} + +/** + * The bytes a team rule becomes for one tool: its render, or the team `.md` + * verbatim. This is the single spelling of that mapping: `pullItem` writes it + * and `doctor` compares the delivered file against it, so a stale render is a + * reported failure rather than a file that merely exists. + */ +export function renderRuleForTool(tool: string, rawTeamRule: string): string { + return ruleFormatForTool(tool)?.render(rawTeamRule) ?? rawTeamRule; +} + +/** + * True when the tool's rules directory also holds rules the member wrote in + * the tool's own format, so pull removes only a copy it can prove it wrote + * there: every tool with a rules format, except Cursor (teamai owns + * `.cursor/rules`), plus OMP and Pi. + */ +export function sharesRulesDirWithMember(tool: string): boolean { + if (tool === 'cursor') return false; + return ruleFormatForTool(tool) !== undefined || tool === 'omp' || tool === 'pi'; +} + /** Extension teamai writes rules with for a given tool. */ -export function ruleFileExtensionForTool(tool: string): '.md' | '.mdc' | '.instructions.md' { - if (usesCursorMdcRules(tool)) return '.mdc'; - return usesCopilotInstructions(tool) ? '.instructions.md' : '.md'; +export function ruleFileExtensionForTool(tool: string): RuleFormat['extension'] { + return ruleFormatForTool(tool)?.extension ?? '.md'; } /** True when the tool stores rules in Cursor-compatible `.mdc` format. */ export function usesCursorMdcRules(tool: string): boolean { - return CURSOR_MDC_RULE_TOOLS.has(tool); + return ruleFileExtensionForTool(tool) === '.mdc'; } /** True when the tool stores rules as GitHub Copilot instruction files. */ export function usesCopilotInstructions(tool: string): boolean { - return COPILOT_INSTRUCTIONS_RULE_TOOLS.has(tool); + return ruleFileExtensionForTool(tool) === '.instructions.md'; } /** @@ -92,18 +152,6 @@ export const LEGACY_RULE_DIRS: Readonly> = { tcodex: '.tcodex/rules', }; -/** The globs a team rule's `paths:` frontmatter scopes it to; empty when unscoped. */ -export function rulePaths(data: Record): string[] { - const value = data.paths; - if (Array.isArray(value)) { - return value.map((entry) => String(entry).trim()).filter(Boolean); - } - if (typeof value === 'string') { - return value.split(',').map((entry) => entry.trim()).filter(Boolean); - } - return []; -} - /** * Every extension a rule file may carry on disk, newest layout first. * diff --git a/src/resources/rules.ts b/src/resources/rules.ts index 7ef6973c5..6bd5cd533 100644 --- a/src/resources/rules.ts +++ b/src/resources/rules.ts @@ -1,3 +1,4 @@ +import crypto from 'node:crypto'; import path from 'node:path'; import { isToolInstalledForConfig, ResourceHandler } from './base.js'; import type { ResourceItem, ResourceItemStatus, DeliveryTarget, TeamaiConfig, LocalConfig } from '../types.js'; @@ -5,13 +6,8 @@ import { listFilesRecursive, pathExists, copyFile, ensureDir, remove, fileConten import { log } from '../utils/logger.js'; import { TEAMAI_RULES_START, TEAMAI_RULES_END, TEAMAI_TEAM_RULES_START, TEAMAI_TEAM_RULES_END, resolveBaseDir, resolveToolBaseDir, resolveToolRootDir, isAgentExcluded, scopedToolPaths, SELF_KNOWLEDGE_SCAN_KEY } from '../types.js'; import { EXCLUDED_RULE_NAMES, isDeployedRecallRule, TEAMAI_CONTEXT_RULE_NAME } from '../builtin-rules.js'; -import { teamRuleToCursorMdc, mergeCursorBodyIntoTeamMd, cursorMdcBodyEqualsTeamMd } from './cursor-mdc.js'; -import { - copilotInstructionsBodyEqualsTeamMd, - mergeCopilotBodyIntoTeamMd, - teamRuleToCopilotInstructions, -} from './copilot-instructions.js'; import { splitFrontmatter } from '../utils/frontmatter.js'; +import { rulePaths } from './team-rule.js'; import { assertWithinRoot } from '../utils/path-safety.js'; import { loadStateForScope } from '../config.js'; import { placedResourcePath } from '../push-namespaces.js'; @@ -20,12 +16,13 @@ import { getFileContentAtRev, isPastVersionOf } from '../utils/git.js'; import { forgetDelivered, keepsEditedCopy, recordDelivered, removedCopyChanged, type DeliveredHashes, type DeliveryLedger } from './delivered-copies.js'; import { ruleFileExtensionForTool, + ruleFormatForTool, + renderRuleForTool, ruleStemFromFilename, - usesCursorMdcRules, - usesCopilotInstructions, + sharesRulesDirWithMember, isLegacyCursorRuleFile, LEGACY_RULE_DIRS, - rulePaths, + type RuleFormat, writesInstructionBlock, instructionFileInstallProbe, } from './rule-format.js'; @@ -89,10 +86,10 @@ export class RulesHandler extends ResourceHandler { const rulesDir = path.join(resolveToolBaseDir(tool, localConfig), rulesPath); if (!await pathExists(rulesDir)) continue; - // Some tools require native rule extensions and derived frontmatter. + // A tool with its own rules format holds a render: its frontmatter is + // derived on pull, so only the body is compared. const ext = ruleFileExtensionForTool(tool); - const isMdcTool = usesCursorMdcRules(tool); - const isCopilotTool = usesCopilotInstructions(tool); + const format = ruleFormatForTool(tool); const files = await listFilesRecursive(rulesDir); for (const file of files) { @@ -121,15 +118,8 @@ export class RulesHandler extends ResourceHandler { const teamFilePath = path.join(teamRulesDir, teamFileName); // For native formats, compare markdown bodies only: frontmatter is // machine-derived on pull, so a clean round trip is not a change. - const localRule = (await readFileSafe(localFilePath)) ?? ''; - const teamRule = await readTeamRule(teamFilePath); - const equal = isMdcTool - ? cursorMdcBodyEqualsTeamMd( - localRule, - teamRule, - ) - : isCopilotTool - ? copilotInstructionsBodyEqualsTeamMd(localRule, teamRule) + const equal = format + ? format.bodyEquals((await readFileSafe(localFilePath)) ?? '', await readTeamRule(teamFilePath)) : await fileContentEqual(localFilePath, teamFilePath); if (equal) continue; // This tool dir's copy is identical, skip // Single-repo mode: nothing refreshes the active tree's @@ -157,9 +147,9 @@ export class RulesHandler extends ResourceHandler { } else { // File does not exist in team repo — candidate for "new". // Native rule directories can contain personal rules created by the - // target tool. Keep unknown files in the .mdc, Copilot-instructions, - // OMP, and Pi rule directories local. - if (isMdcTool || isCopilotTool || tool === 'omp' || tool === 'pi') continue; + // target tool, in its own format. Keep unknown files in a tool with a + // rules format, and in the OMP and Pi rule directories, local. + if (format || tool === 'omp' || tool === 'pi') continue; const existing = candidates.get(name); if (!existing) { const mtime = await getFileMtime(localFilePath); @@ -212,7 +202,7 @@ export class RulesHandler extends ResourceHandler { })); } - async pushItem(item: ResourceItem, _teamConfig: TeamaiConfig, localConfig: LocalConfig): Promise { + async pushItem(item: ResourceItem, teamConfig: TeamaiConfig, localConfig: LocalConfig): Promise { const rulesRoot = path.join(localConfig.repo.localPath, 'rules'); const dest = path.resolve(localConfig.repo.localPath, item.relativePath); assertWithinRoot( @@ -221,8 +211,9 @@ export class RulesHandler extends ResourceHandler { `Invalid rule destination outside team repo rules directory: ${item.relativePath}`, ); if (item.sourcePath !== dest) { - if (item.sourcePath.endsWith('.mdc')) { - // Source is a tool-native `.mdc`. Only its markdown body is pushed: the + const format = this.ruleFormatOfSource(item.sourcePath, teamConfig, localConfig); + if (format) { + // Source is a tool's render. Only its markdown body is pushed: the // tool frontmatter is machine-derived, and the team file keeps its own // tool-neutral frontmatter (`paths:`, …) — dropping that would silently // un-scope the rule for the whole team on the next pull. @@ -231,13 +222,7 @@ export class RulesHandler extends ResourceHandler { // Never turn an unreadable source into an empty team rule. throw new Error(`Cannot read rule source ${item.sourcePath}`); } - await writeFile(dest, mergeCursorBodyIntoTeamMd(raw, await readFileSafe(dest))); - } else if (item.sourcePath.endsWith('.instructions.md')) { - const raw = await readFileSafe(item.sourcePath); - if (raw === null) { - throw new Error(`Cannot read rule source ${item.sourcePath}`); - } - await writeFile(dest, mergeCopilotBodyIntoTeamMd(raw, await readFileSafe(dest))); + await writeFile(dest, format.mergeBodyIntoTeam(raw, await readFileSafe(dest))); } else { await copyFile(item.sourcePath, dest); } @@ -246,19 +231,36 @@ export class RulesHandler extends ResourceHandler { } /** - * Where `item` lands for each tool that receives rules. The filename is - * tool-dependent — `.md` verbatim, `.mdc` for Cursor-compatible tools, - * `.instructions.md` for Copilot — so a reader cannot derive it from the - * rule's name alone. + * The rules format of the tool whose rules directory holds `sourcePath`, as + * `scanLocalForPush` found it there; undefined for a verbatim copy. The + * deepest directory wins, should one tool's sit inside another's. + */ + private ruleFormatOfSource(sourcePath: string, teamConfig: TeamaiConfig, localConfig: LocalConfig): RuleFormat | undefined { + let match: { dirLength: number; tool: string } | undefined; + for (const [tool, toolPath] of Object.entries(scopedToolPaths(teamConfig, localConfig))) { + if (!toolPath.rules) continue; + const dir = path.join(resolveToolBaseDir(tool, localConfig), toolPath.rules); + if (!sourcePath.startsWith(dir + path.sep)) continue; + if (!match || dir.length > match.dirLength) match = { dirLength: dir.length, tool }; + } + return match ? ruleFormatForTool(match.tool) : undefined; + } + + /** + * Where `item` lands for each tool that receives rules. The filename and + * bytes are tool-dependent (`RULE_FORMATS`) — `.md` verbatim, `.mdc` for + * Cursor-compatible tools, `.instructions.md` for Copilot, `.md` with its + * own frontmatter for Kiro and Qoder — so a reader cannot derive them from + * the rule's name alone. */ async deliveryTargets( teamConfig: TeamaiConfig, localConfig: LocalConfig, item: ResourceItem, ): Promise { - // The bytes as well as the path: Cursor and Copilot read frontmatter this - // derives from the team `.md`, so a copy whose `globs`, `alwaysApply` or - // `applyTo` no longer match the source is inert in exactly the way a + // The bytes as well as the path: a tool with its own rules format reads + // frontmatter this derives from the team `.md`, so a copy whose scoping + // fields no longer match the source is inert in exactly the way a // missing file is. Only a comparison against the render can see that, and // the render belongs here rather than in a second copy inside `doctor`. const source = await readFileSafe(item.sourcePath); @@ -347,6 +349,42 @@ export class RulesHandler extends ResourceHandler { } } + /** + * Rewrite each delivered copy of `rules` that still holds what teamai + * recorded writing there but is no longer the render: what an older CLI + * wrote before the tool got a rules format of its own (#946). For the + * "Already synced" pull, which does not run `pullItem`. A copy the member + * changed is kept, and queued on `ledger.kept` to be named when teamai + * would now deliver other bytes there; one with no record is left to the + * next full sync. Returns the names of the rules rewritten. + */ + async rerenderOutdatedCopies( + teamConfig: TeamaiConfig, + localConfig: LocalConfig, + rules: readonly ResourceItem[], + ledger: DeliveryLedger, + ): Promise { + const rewritten = new Set(); + for (const item of rules) { + for (const target of await this.deliveryTargets(teamConfig, localConfig, item)) { + const { dest, content } = target; + const recorded = ledger.previous?.[dest]; + const disk = await fileHash(dest); + if (content === undefined || recorded === undefined || disk === null || disk === contentHash(content)) continue; + if (disk !== recorded) { + // Named by the caller's reportKept; a render unchanged since + // delivery is the member's plain edit, which needs no word. + if (contentHash(content) !== recorded) await keepsEditedCopy(ledger, item, target); + continue; + } + await writeFile(dest, content); + await recordDelivered(ledger.hashes, dest); + rewritten.add(item.name); + } + } + return [...rewritten]; + } + /** * `my-rule` when push placed it at `rules/fe-know/my-rule.md`: the author * types the name their local copy has, which is the bare one. @@ -580,15 +618,26 @@ export class RulesHandler extends ResourceHandler { const ruleName = ruleStemFromFilename(localFile); if (ruleName === null) continue; - // JoyCode, OMP, Pi, and Copilot rule directories are shared with - // user-authored rules. Absence from the current team set is not proof - // of TeamAI ownership (including legacy .md files); only explicit team - // removals authorize cleanup, and a replaced root rule's copy that is - // still exactly what pull wrote. Cursor is deliberately absent — teamai - // owns .cursor/rules and sweeps it. - if ((tool === 'joycode' || tool === 'omp' || tool === 'pi' || usesCopilotInstructions(tool)) && !tombstones.has(ruleName)) { - const replaced = teamRuleNames.has(ruleName) ? undefined : replacedByName.get(ruleName); - if (replaced === undefined || localFile !== `${ruleName}${ext}`) continue; + // These rule directories are shared with rules the member wrote in the + // tool's own format (`sharesRulesDirWithMember`). Absence from the + // current team set is not proof of TeamAI ownership (including legacy + // .md files); only explicit team removals authorize cleanup, a copy the + // delivery ledger shows unchanged since teamai wrote it, and a replaced + // root rule's copy that is still exactly what pull wrote. Cursor is + // deliberately absent — teamai owns .cursor/rules and sweeps it. + if (sharesRulesDirWithMember(tool) && !tombstones.has(ruleName)) { + if (teamRuleNames.has(ruleName) || localFile !== `${ruleName}${ext}` || EXCLUDED_RULE_NAMES.has(ruleName)) continue; + const replaced = replacedByName.get(ruleName); + if (replaced === undefined) { + const fullPath = path.join(destDir, localFile); + const recorded = ledger?.previous?.[fullPath]; + if (recorded !== undefined && recorded === await fileHash(fullPath)) { + await remove(fullPath); + if (ledger) forgetDelivered(ledger.hashes, fullPath); + log.debug(`Removed stale rule ${localFile} from ${tool}`); + } + continue; + } const deployed = path.join(destDir, localFile); if (await isDeliveredRender(tool, deployed, replaced, localConfig.repo.localPath, deliveredRevs)) { await remove(deployed); @@ -901,19 +950,9 @@ export class RulesHandler extends ResourceHandler { } } -/** - * The bytes a team rule becomes for one tool. `.md` is copied verbatim; - * Cursor-compatible tools and Copilot read frontmatter derived from the same - * source, so their file is a render rather than a copy. - * - * This is the single spelling of that mapping: `pullItem` writes it and - * `doctor` compares the delivered file against it, so a stale render is a - * reported failure rather than a file that merely exists. - */ -function renderRuleForTool(tool: string, source: string): string { - if (usesCursorMdcRules(tool)) return teamRuleToCursorMdc(source); - if (usesCopilotInstructions(tool)) return teamRuleToCopilotInstructions(source); - return source; +/** sha256 of `content`, as `fileHash` and the delivery ledger spell it. */ +function contentHash(content: string): string { + return crypto.createHash('sha256').update(content).digest('hex'); } /** diff --git a/src/resources/team-rule.ts b/src/resources/team-rule.ts new file mode 100644 index 000000000..32da68fc8 --- /dev/null +++ b/src/resources/team-rule.ts @@ -0,0 +1,172 @@ +import matter from 'gray-matter'; + +/** + * The tool-neutral team rule: a `.md` whose optional frontmatter holds + * `paths:` (the globs it is scoped to) and whose Markdown body is the rule. + * Every per-tool render (`rule-format.ts`) reads a team rule through here, so + * `paths:` is parsed one way, and every push merges a tool copy back through + * here, so only the body crosses back. + */ + +/** + * A leading `---\n...\n---` frontmatter block, with an optional BOM. The inner + * group is optional so an empty block (`---\n---`) matches too — otherwise its + * delimiters would leak into the body and get pushed to the team repo verbatim. + */ +const FRONTMATTER_RE = /^?---\r?\n(?:[\s\S]*?\r?\n)?---\r?\n?/; + +/** + * Split a rule file into its leading frontmatter block (empty string when there + * is none) and its body. Deliberately textual, NOT a YAML parse: a tool's glob + * value may be invalid YAML, and a strict parse of it would fail and swallow + * the frontmatter into the body. Delimiter-based splitting round-trips + * regardless of YAML validity. + */ +function splitRuleFrontmatter(raw: string): { block: string; body: string } { + const m = raw.match(FRONTMATTER_RE); + return m ? { block: m[0], body: raw.slice(m[0].length) } : { block: '', body: raw }; +} + +/** + * Quote scalars that YAML would read as an alias (`*`) or anchor (`&`) node. + * A glob is the common case: `paths: **\/*.ts` is not valid YAML, so a strict + * parse of an otherwise fine frontmatter block throws on it. + */ +function quoteYamlUnsafeScalars(block: string): string { + return block + .split(/\r?\n/) + .map((line) => { + const m = line.match(/^(\s*(?:-\s+|[A-Za-z0-9_.-]+:[ \t]+))([*&][^"']*)$/); + return m ? `${m[1]}"${m[2].trimEnd()}"` : line; + }) + .join('\n'); +} + +/** + * A team rule's frontmatter data, parsed with gray-matter and retried once + * with alias-unsafe scalars quoted, so a rule authored as `paths: **\/*.ts` is + * still scoped rather than silently always on. Empty when both attempts fail. + * + * Options are passed to disable gray-matter's module-level cache: it keeps a + * failed parse too, so a second render of the same rule skipped the retry and + * came out unscoped. + */ +export function teamRuleData(raw: string): Record { + try { + return matter(raw, {}).data; + } catch { + // Invalid YAML — retry below with unsafe scalars quoted. + } + + const { block } = splitRuleFrontmatter(raw); + if (!block) return {}; + const quoted = quoteYamlUnsafeScalars(block); + try { + return matter(quoted.endsWith('\n') ? quoted : `${quoted}\n`, {}).data; + } catch { + return {}; + } +} + +/** Normalize a markdown body for comparison (ignore leading/trailing whitespace). */ +function normalizeBody(body: string): string { + return body.replace(/^\s+/, '').replace(/\s+$/, ''); +} + +/** The Markdown body of a rule file, frontmatter dropped and outer whitespace trimmed. */ +export function teamRuleBody(raw: string): string { + return normalizeBody(splitRuleFrontmatter(raw).body); +} + +/** `value` split on the commas outside `{...}`, so `src/{a,b}/**` stays one glob. */ +function splitTopLevelCommas(value: string): string[] { + const parts: string[] = []; + let depth = 0; + let start = 0; + for (let i = 0; i < value.length; i++) { + const c = value[i]; + if (c === '{') depth++; + else if (c === '}' && depth > 0) depth--; + else if (c === ',' && depth === 0) { + parts.push(value.slice(start, i)); + start = i + 1; + } + } + parts.push(value.slice(start)); + return parts; +} + +/** + * The globs a team rule's `paths:` frontmatter scopes it to; empty when + * unscoped. A string is split on its top-level commas. + */ +export function rulePaths(data: Record): string[] { + const value = data.paths; + if (Array.isArray(value)) { + return value.map((entry) => String(entry).trim()).filter(Boolean); + } + if (typeof value === 'string') { + return splitTopLevelCommas(value).map((entry) => entry.trim()).filter(Boolean); + } + return []; +} + +/** + * `glob` with every `{a,b}` alternation expanded, for a tool that splits a + * glob list on every comma: `src/{a,b}/**` becomes `src/a/**`, `src/b/**`. + * A group without a comma stays literal. + */ +export function expandBraces(glob: string): string[] { + let depth = 0; + let open = -1; + for (let i = 0; i < glob.length; i++) { + const c = glob[i]; + if (c === '{') { + if (depth === 0) open = i; + depth++; + } else if (c === '}' && depth > 0) { + depth--; + if (depth > 0) continue; + const alternatives = splitTopLevelCommas(glob.slice(open + 1, i)); + if (alternatives.length < 2) continue; + const prefix = glob.slice(0, open); + const suffix = glob.slice(i + 1); + return alternatives.flatMap((alternative) => expandBraces(`${prefix}${alternative}${suffix}`)); + } + } + return [glob]; +} + +/** + * Write a tool copy's Markdown body back into the team repo `.md`, keeping the + * team file's own frontmatter. + * + * Only the body crosses back: the tool's frontmatter is machine-derived on + * pull and is dropped, while the team rule's tool-neutral frontmatter + * (`paths:`, …) is preserved from `existingTeamMd`. Dropping it instead would + * silently un-scope the rule for the whole team on the next pull. + * + * `existingTeamMd` is null for a rule that does not exist upstream yet, in which + * case the body alone becomes the new team file. + */ +export function mergeRuleBodyIntoTeamMd(rawToolRule: string, existingTeamMd: string | null): string { + const body = teamRuleBody(rawToolRule); + if (existingTeamMd === null) return `${body}\n`; + + // Body unchanged — hand back the team file byte-for-byte so a no-op push + // never shows up as a diff. + if (teamRuleBody(existingTeamMd) === body) return existingTeamMd; + + const { block } = splitRuleFrontmatter(existingTeamMd); + if (!block) return `${body}\n`; + return `${block.endsWith('\n') ? block : `${block}\n`}\n${body}\n`; +} + +/** + * Compare the Markdown body of a tool copy against a team repo `.md`, ignoring + * frontmatter on both sides. Used by push scanning so that a pull-then-push + * round trip (which rewrites frontmatter) is not seen as a content change. + */ +export function ruleBodyEqualsTeamMd(rawToolRule: string, rawTeamRule: string): boolean { + return teamRuleBody(rawToolRule) === teamRuleBody(rawTeamRule); +} diff --git a/src/utils/pre-push-sync.ts b/src/utils/pre-push-sync.ts index 3ee91664c..d98cc657f 100644 --- a/src/utils/pre-push-sync.ts +++ b/src/utils/pre-push-sync.ts @@ -17,9 +17,7 @@ import { } from './fs.js'; import { getFileContentAtRev, getFileContentWhenAdded } from './git.js'; import { isToolInstalledForConfig, ResourceHandler } from '../resources/base.js'; -import { ruleFileExtensionForTool, usesCopilotInstructions, usesCursorMdcRules } from '../resources/rule-format.js'; -import { teamRuleToCursorMdc, cursorMdcBodyEqualsTeamMd } from '../resources/cursor-mdc.js'; -import { teamRuleToCopilotInstructions, copilotInstructionsBodyEqualsTeamMd } from '../resources/copilot-instructions.js'; +import { ruleFileExtensionForTool, ruleFormatForTool, usesCopilotInstructions } from '../resources/rule-format.js'; import { EXCLUDED_RULE_NAMES } from '../builtin-rules.js'; import { log } from './logger.js'; import { placedResourcePath } from '../push-namespaces.js'; @@ -101,12 +99,11 @@ async function syncRulesToLocal( const rulesDir = path.join(resolveToolBaseDir(tool, localConfig), toolPath.rules); if (!await pathExists(rulesDir)) continue; - // Cursor and Copilot copies have native extensions and derived frontmatter. - // Compare their bodies with the team Markdown so stale copies are refreshed - // rather than offered as edits that revert a teammate's update. + // A tool with its own rules format holds a render with derived + // frontmatter. Compare its body with the team Markdown so stale copies are + // refreshed rather than offered as edits that revert a teammate's update. const ext = ruleFileExtensionForTool(tool); - const isMdcTool = usesCursorMdcRules(tool); - const isCopilotTool = usesCopilotInstructions(tool); + const format = ruleFormatForTool(tool); const files = await listFilesRecursive(rulesDir); for (const file of files) { @@ -148,14 +145,15 @@ async function syncRulesToLocal( // Only process files that exist in both places but differ if (!await pathExists(teamFilePath)) continue; - if (isMdcTool || isCopilotTool) { - const bodyEquals = isCopilotTool ? copilotInstructionsBodyEqualsTeamMd : cursorMdcBodyEqualsTeamMd; - const render = isCopilotTool ? teamRuleToCopilotInstructions : teamRuleToCursorMdc; + if (format) { + const { bodyEquals, render } = format; const localRaw = await readFileSafe(localFilePath); const teamRaw = await readFileSafe(teamFilePath); if (localRaw === null || teamRaw === null) continue; const sameBody = bodyEquals(localRaw, teamRaw); - if (sameBody && (!isCopilotTool || localRaw === render(teamRaw))) continue; + // Only Copilot's header is refreshed on a `paths`-only change; for the + // other formats an equal body is left to the next full pull. + if (sameBody && (!usesCopilotInstructions(tool) || localRaw === render(teamRaw))) continue; const oldContents = await baseVersions(); if (oldContents.length === 0) continue; // Didn't exist at any base — ambiguous, skip From 625771863e241e2db737738dc56879f5ab7b4622 Mon Sep 17 00:00:00 2001 From: Saul Moro Date: Fri, 2 Oct 2026 01:34:48 +0200 Subject: [PATCH 03/20] fix(rules): isolate Hermes project pulls and load OpenCode user rules (#946) --- CHANGELOG.md | 2 + README.ja.md | 8 +- README.ko.md | 8 +- README.md | 8 +- README.th.md | 8 +- README.zh-CN.md | 8 +- docs/usage-guide.md | 6 +- docs/usage-guide.zh-CN.md | 6 +- skill-data/setup/references/uninstall.md | 3 + src/__tests__/doctor-rules-delivery.test.ts | 67 ++++++++- src/__tests__/e2e/doctor-delivery-cli.test.ts | 6 +- src/__tests__/opencode-config.test.ts | 50 ++++++- src/__tests__/rules.test.ts | 83 ++++++++++- src/__tests__/uninstall.test.ts | 56 ++++++++ src/doctor-delivery.ts | 40 ++++-- src/doctor.ts | 2 + src/init.ts | 7 +- src/resources/opencode-config.ts | 131 +++++++++++++----- src/resources/rules.ts | 76 +++++++--- src/uninstall.ts | 48 ++++++- 20 files changed, 527 insertions(+), 96 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0287ea465..cba5babbc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -48,6 +48,8 @@ All notable changes to this project will be documented in this file. See [standa - Kiro and Qoder (and Qoder CN) now get each rule in their own rules format. Kiro ignores `paths:`, so the verbatim copy pull wrote applied every rule everywhere, and Qoder documents `paths:` only for its CLI, not for Desktop. Kiro steering files get `inclusion: fileMatch` with a `fileMatchPattern` list, or `inclusion: always`; Qoder rules get `trigger: glob` with one comma-separated `glob:` line, `{a,b}` expanded, or `trigger: always_on`. The first pull after upgrading rewrites a copy still holding what teamai delivered, even when the team repo has not moved; a copy you edited is kept and named. `teamai push` sends back only the body, and a steering or Qoder rule file with no matching team rule is the member's own: pull no longer deletes it, and push no longer offers it as a new team rule. `teamai doctor` compares each copy with the new render and its fix names the fields that tool scopes by (for [#946](https://github.com/Tencent/teamai-cli/issues/946)). - A rule scoped with an unquoted glob such as `paths: **/*.ts` lost its scope on every render after the first in a run, so Cursor, JoyCode or doctor could see it as always on: the frontmatter parse kept a failed attempt in gray-matter's cache. A `paths:` string is now split on its top-level commas only, so `src/{a,b}/**` stays one glob for Cursor instead of becoming `src/{a, b}/**` (for [#946](https://github.com/Tencent/teamai-cli/issues/946)). - teamai's OpenClaw hook runs. Its handler read an `event` field OpenClaw never sends and subscribed to `session:start`, which OpenClaw does not emit; its `HOOK.md` name was not valid YAML; and OpenClaw loads a workspace hook only once `openclaw.json` enables its entry, which teamai never wrote. The handler now keys on the event's `type` and `action`, runs `session-start` on `command:new`, `command:reset`, `session:auto-reset` and `gateway:startup` and `prompt-submit` on `message:received`, and passes the event's workspace as the hook's `cwd`. init, pull and `hooks inject` add `hooks.internal.entries.teamai-status-report.enabled`, unless that would narrow OpenClaw's open-ended hook discovery or you switched it off, in which case they warn and name `openclaw hooks enable teamai-status-report` (earlier versions set `hooks.internal.enabled` when `OPENCLAW_STATE_DIR` was set, so those machines see this warning until the command is run); `hooks remove` and uninstall take the entry out. The workspace and config follow `OPENCLAW_PROFILE`, `OPENCLAW_CONFIG_PATH` and `OPENCLAW_WORKSPACE_DIR` as OpenClaw does, a server-pushed `SessionStart` or `UserPromptSubmit` agent hook subscribes to the events OpenClaw emits and gets its own entry (`teamai-agent-`) so the allowlist keeps it, and `teamai doctor` fails `OpenClaw hook enabled` while the entry is missing or off (for [#946](https://github.com/Tencent/teamai-cli/issues/946)). +- A project-scope `teamai pull` no longer rewrites Hermes' global `SOUL.md` rules block, and a project with no rules no longer erases it: only a user-scope pull writes it, `doctor` checks it only in user scope, and a project-scope `uninstall` leaves it. Hermes gets no project rules; in a project `init` and `doctor` say why (for [#946](https://github.com/Tencent/teamai-cli/issues/946)). +- OpenCode loads the user-scope team rules again. The user `opencode.json` listed `rules/*.md`, which OpenCode resolves from the session's working directory, so it loaded the project's `rules/` instead. Pull now lists the absolute `~/.config/opencode/rules/*.md` plus one glob per namespace directory a rule lands in, replaces the old relative glob, and drops a namespace's glob once its rules no longer reach you, keeping a glob you added for a directory of your own; `doctor` checks every glob and flags a stale one, and `uninstall` removes them (for [#946](https://github.com/Tencent/teamai-cli/issues/946)). - Team rules reach Codex, `codex-internal` and `tcodex`. Pull used to copy them to `.codex/rules/.md`, which Codex does not read, and `teamai doctor` reported them delivered. In user scope they now go into a team-rules block of the tool's own `AGENTS.md` (`~/.codex/AGENTS.md`), beside the culture, shared-instructions and recall blocks. In a project the session-start hook adds the project's rules and those blocks to each session, and to a spawned subagent through a new `SubagentStart` entry, with `additionalContextLimit: 0` so Codex keeps them whole; pull leaves the project `AGENTS.md`, which other tools read too, unchanged. Pull removes the `.md` copies earlier pulls left in the Codex rules directory at its recorded `toolRoots` location, including a publisher's bare local copy, keeping an edited one with a warning. Pull and uninstall keep a removed rule's legacy copy unless it matches its recorded delivery hash; a missing record proves nothing about local edits. A path-scoped rule is rendered without frontmatter after an `Applies to files matching: ` line, in Hermes' `SOUL.md` too. `teamai doctor` checks the user-scope block and, in a project, both hook entries' limit. The public Codex asks once to approve the changed teamai hooks (for [#938](https://github.com/Tencent/teamai-cli/issues/938)). - `teamai pull` names the skills it removes because they are no longer delivered here, in one line, instead of a `debug` line calling them excluded and a summary that says `No resources to sync`. Picking a role or project, or an admin adding `manifest/projects.yaml`, takes root skills away, since the root `skills/` is then the tag catalog; the line then says that `teamai tags subscribe ` brings one back. A namespace skill removed because its namespace is no longer active is named too. The usage guide and the multi-project design no longer say a member with no project still gets `common` or every root item (for [#911](https://github.com/Tencent/teamai-cli/issues/911)). - `teamai pull` keeps the `teamai tags subscribe ` recovery line when the skill directory it removes is byte-identical to an inactive namespace copy: that copy made the namespace cleanup phase remove the directory first, and the hint was lost, because pull inferred whether a root skill had left from which phase did the removing. The hint now follows the repo — it appears exactly when the team repo holds the removed skill at the root, the copy a tag delivers — so a namespace-only skill removed on deactivation is still named without the hint. The usage guide no longer says a member with no role gets no skills at all, in English or Chinese: root skills still arrive through a tag (review of [#917](https://github.com/Tencent/teamai-cli/pull/917)). diff --git a/README.ja.md b/README.ja.md index 6f5e30863..4f1f24ad0 100644 --- a/README.ja.md +++ b/README.ja.md @@ -123,15 +123,15 @@ Git を基盤に、3 層の能力を構築します: Claude Code✓✓✓✓✓✓✓✓✓✓✓✓✓✓ - Codex✓✓✓✓✓✓✓✓✓✓✓✓✓✓ + Codex✓✓*✓✓✓✓✓✓✓✓✓✓✓✓ Cursor✓✓✓✓✓✓✓—✓✓✓✓✓✓ GitHub Copilot CLI✓✓✓✓✓✓✓—✓✓✓✓✓✓ CodeBuddy✓✓✓✓✓✓✓✓✓✓✓✓✓✓ WorkBuddy✓✓✓✓—✓✓✓✓✓✓✓✓✓ - OpenCode✓✓✓✓✓✓✓✓✓✓✓——— + OpenCode✓✓*✓✓✓✓✓✓✓✓✓——— Pi Coding Agent✓✓✓——✓✓✓✓✓✓——— OpenClaw✓✓✓✓————✓✓✓——— - Hermes✓—✓✓————✓✓✓——— + Hermes✓✓*✓✓————✓✓✓——— DeepSeek Harness✓—✓—————✓✓✓——— Qoder✓✓✓✓✓✓✓—✓✓✓✓✓✓ Qoder CN✓✓✓✓✓✓✓—✓✓✓✓✓✓ @@ -141,6 +141,8 @@ Git を基盤に、3 層の能力を構築します: +✓* rules はツールに届きますが常に有効です。パスによるスコープは適用されません。Hermes はユーザースコープでのみ受け取ります。 + ## 詳細情報 - [Usage Guide](docs/usage-guide.md) — setup, onboarding, daily workflows, and commands diff --git a/README.ko.md b/README.ko.md index adffb37a3..038c10061 100644 --- a/README.ko.md +++ b/README.ko.md @@ -123,15 +123,15 @@ Git을 기반으로 세 층의 역량을 구축합니다: Claude Code✓✓✓✓✓✓✓✓✓✓✓✓✓✓ - Codex✓✓✓✓✓✓✓✓✓✓✓✓✓✓ + Codex✓✓*✓✓✓✓✓✓✓✓✓✓✓✓ Cursor✓✓✓✓✓✓✓—✓✓✓✓✓✓ GitHub Copilot CLI✓✓✓✓✓✓✓—✓✓✓✓✓✓ CodeBuddy✓✓✓✓✓✓✓✓✓✓✓✓✓✓ WorkBuddy✓✓✓✓—✓✓✓✓✓✓✓✓✓ - OpenCode✓✓✓✓✓✓✓✓✓✓✓——— + OpenCode✓✓*✓✓✓✓✓✓✓✓✓——— Pi Coding Agent✓✓✓——✓✓✓✓✓✓——— OpenClaw✓✓✓✓————✓✓✓——— - Hermes✓—✓✓————✓✓✓——— + Hermes✓✓*✓✓————✓✓✓——— DeepSeek Harness✓—✓—————✓✓✓——— Qoder✓✓✓✓✓✓✓—✓✓✓✓✓✓ Qoder CN✓✓✓✓✓✓✓—✓✓✓✓✓✓ @@ -141,6 +141,8 @@ Git을 기반으로 세 층의 역량을 구축합니다: +✓* rules가 도구에 전달되지만 항상 적용됩니다. 경로별로 범위를 한정하지 않습니다. Hermes는 사용자 스코프에서만 받습니다. + ## 자세히 알아보기 - [Usage Guide](docs/usage-guide.md) — setup, onboarding, daily workflows, and commands diff --git a/README.md b/README.md index 343a45c73..7aab2df3d 100644 --- a/README.md +++ b/README.md @@ -123,15 +123,15 @@ Three layers of capability, built on Git: Claude Code✓✓✓✓✓✓✓✓✓✓✓✓✓✓ - Codex✓✓✓✓✓✓✓✓✓✓✓✓✓✓ + Codex✓✓*✓✓✓✓✓✓✓✓✓✓✓✓ Cursor✓✓✓✓✓✓✓—✓✓✓✓✓✓ GitHub Copilot CLI✓✓✓✓✓✓✓—✓✓✓✓✓✓ CodeBuddy✓✓✓✓✓✓✓✓✓✓✓✓✓✓ WorkBuddy✓✓✓✓—✓✓✓✓✓✓✓✓✓ - OpenCode✓✓✓✓✓✓✓✓✓✓✓——— + OpenCode✓✓*✓✓✓✓✓✓✓✓✓——— Pi Coding Agent✓✓✓——✓✓✓✓✓✓——— OpenClaw✓✓✓✓————✓✓✓——— - Hermes✓—✓✓————✓✓✓——— + Hermes✓✓*✓✓————✓✓✓——— DeepSeek Harness✓—✓—————✓✓✓——— Qoder✓✓✓✓✓✓✓—✓✓✓✓✓✓ Qoder CN✓✓✓✓✓✓✓—✓✓✓✓✓✓ @@ -141,6 +141,8 @@ Three layers of capability, built on Git: +✓* The rules reach the tool, but always on: it does not scope them by path. Hermes gets them in user scope only. + ## Learn More - [Usage Guide](docs/usage-guide.md) ([中文版](docs/usage-guide.zh-CN.md)) — setup, onboarding, daily workflows, and commands diff --git a/README.th.md b/README.th.md index 05838c6f8..e626dc08b 100644 --- a/README.th.md +++ b/README.th.md @@ -123,15 +123,15 @@ teamai init https://github.com/your-org/your-repo --scope user Claude Code✓✓✓✓✓✓✓✓✓✓✓✓✓✓ - Codex✓✓✓✓✓✓✓✓✓✓✓✓✓✓ + Codex✓✓*✓✓✓✓✓✓✓✓✓✓✓✓ Cursor✓✓✓✓✓✓✓—✓✓✓✓✓✓ GitHub Copilot CLI✓✓✓✓✓✓✓—✓✓✓✓✓✓ CodeBuddy✓✓✓✓✓✓✓✓✓✓✓✓✓✓ WorkBuddy✓✓✓✓—✓✓✓✓✓✓✓✓✓ - OpenCode✓✓✓✓✓✓✓✓✓✓✓——— + OpenCode✓✓*✓✓✓✓✓✓✓✓✓——— Pi Coding Agent✓✓✓——✓✓✓✓✓✓——— OpenClaw✓✓✓✓————✓✓✓——— - Hermes✓—✓✓————✓✓✓——— + Hermes✓✓*✓✓————✓✓✓——— DeepSeek Harness✓—✓—————✓✓✓——— Qoder✓✓✓✓✓✓✓—✓✓✓✓✓✓ Qoder CN✓✓✓✓✓✓✓—✓✓✓✓✓✓ @@ -141,6 +141,8 @@ teamai init https://github.com/your-org/your-repo --scope user +✓* rules ส่งถึงเครื่องมือ แต่มีผลเสมอ: เครื่องมือไม่จำกัดขอบเขตตาม path Hermes ได้รับเฉพาะใน user scope + ## เรียนรู้เพิ่มเติม - [Usage Guide](docs/usage-guide.md) — setup, onboarding, daily workflows, and commands diff --git a/README.zh-CN.md b/README.zh-CN.md index 2af3f6d25..ad4455dd8 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -129,15 +129,15 @@ teamai init https://github.com/your-org/your-repo --scope user Claude Code✓✓✓✓✓✓✓✓✓✓✓✓✓✓ - Codex✓✓✓✓✓✓✓✓✓✓✓✓✓✓ + Codex✓✓*✓✓✓✓✓✓✓✓✓✓✓✓ Cursor✓✓✓✓✓✓✓—✓✓✓✓✓✓ GitHub Copilot CLI✓✓✓✓✓✓✓—✓✓✓✓✓✓ CodeBuddy✓✓✓✓✓✓✓✓✓✓✓✓✓✓ WorkBuddy✓✓✓✓—✓✓✓✓✓✓✓✓✓ - OpenCode✓✓✓✓✓✓✓✓✓✓✓——— + OpenCode✓✓*✓✓✓✓✓✓✓✓✓——— Pi Coding Agent✓✓✓——✓✓✓✓✓✓——— OpenClaw✓✓✓✓————✓✓✓——— - Hermes✓—✓✓————✓✓✓——— + Hermes✓✓*✓✓————✓✓✓——— DeepSeek Harness✓—✓—————✓✓✓——— Qoder✓✓✓✓✓✓✓—✓✓✓✓✓✓ Qoder CN✓✓✓✓✓✓✓—✓✓✓✓✓✓ @@ -147,6 +147,8 @@ teamai init https://github.com/your-org/your-repo --scope user +✓* rules 能送达该工具,但始终生效:它不按路径限定作用范围。Hermes 只在 user scope 下得到它们。 + ## 了解更多 - [使用指南](docs/usage-guide.zh-CN.md)([English](docs/usage-guide.md))— 安装、成员接入、日常流程与命令参考 diff --git a/docs/usage-guide.md b/docs/usage-guide.md index c6f288c2f..780d9f45e 100644 --- a/docs/usage-guide.md +++ b/docs/usage-guide.md @@ -1022,7 +1022,7 @@ teamai push > Admins can set enforced rules in `teamai.yaml` (`sharing.rules.enforced`), which members cannot delete. -Most tools get one file per rule in their rules directory. Codex, `codex-internal` and `tcodex` read no rules directory (`.codex/rules/` holds Codex's own `*.rules` command policies), so `pull` writes no rule file for them. In user scope the team rules go into a `` block of the tool's own `AGENTS.md` (`~/.codex/AGENTS.md`, `~/.codex-internal/AGENTS.md`, `~/.tcodex/AGENTS.md`; a `toolRoots` entry moves it), which only that tool reads. In a project their session-start hook adds the project's team rules to each session instead: the project `AGENTS.md` is the owners' file, and other tools with a rules format of their own read it too. Hermes gets the same text in its `SOUL.md` block. Frontmatter is dropped, so a rule with `paths:` applies everywhere there, led by an `Applies to files matching: ` line. Codex runs the hook again after a compaction or a clear, and adds nothing when it resumes a session, which already holds the rules. A subagent Codex spawns gets them through the `SubagentStart` hook. The public Codex runs only trusted hooks. teamai trusts the hooks it writes automatically; if automatic trust is disabled or fails, approve them in `/hooks` to receive the project rules. +Most tools get one file per rule in their rules directory. Codex, `codex-internal` and `tcodex` read no rules directory (`.codex/rules/` holds Codex's own `*.rules` command policies), so `pull` writes no rule file for them. In user scope the team rules go into a `` block of the tool's own `AGENTS.md` (`~/.codex/AGENTS.md`, `~/.codex-internal/AGENTS.md`, `~/.tcodex/AGENTS.md`; a `toolRoots` entry moves it), which only that tool reads. In a project their session-start hook adds the project's team rules to each session instead: the project `AGENTS.md` is the owners' file, and other tools with a rules format of their own read it too. Hermes gets the same text in its `SOUL.md` block, from a user-scope pull only: `SOUL.md` is global, so a project pull leaves it as the user-scope pull wrote it. Hermes gets no project rules, and in a project `init` and `doctor` say why: `.hermes.md` would hide the project `AGENTS.md`, a `pre_llm_call` hook repeats the rules on every turn, and the one plugin prompt section (at most 4,000 characters) already carries the team instructions. Frontmatter is dropped, so a rule with `paths:` applies everywhere there, led by an `Applies to files matching: ` line. Codex runs the hook again after a compaction or a clear, and adds nothing when it resumes a session, which already holds the rules. A subagent Codex spawns gets them through the `SubagentStart` hook. The public Codex runs only trusted hooks. teamai trusts the hooks it writes automatically; if automatic trust is disabled or fails, approve them in `/hooks` to receive the project rules. The culture, shared-instructions and recall blocks follow the same split. In user scope they go to that same `AGENTS.md`, and your own content outside the markers is kept. In a project the session-start hook adds them with the rules, and `pull` leaves the project `AGENTS.md` unchanged. @@ -2276,7 +2276,7 @@ Team hooks still come from the team's `hooks/hooks.yaml`: edit that source in th - **Scopes.** OpenCode's user config lives under `~/.config/opencode/` while its project config lives under `/.opencode/` — a different prefix from every other tool. teamai writes to the correct one per `--scope`, and only ever touches OpenCode files when OpenCode is actually installed for that scope (it never creates `~/.config/opencode/` for a non-user). Hooks are the one exception — they are always user-scoped, for the reason described below. - **Skills** land in `.opencode/skills/` (project) or `~/.config/opencode/skills/` (user). OpenCode also reads `.claude/skills` natively, but teamai writes the OpenCode path too so an OpenCode-only user still gets them. - **Subagents** are rendered into OpenCode's own `agents/*.md` format: frontmatter carries `description` + `mode: subagent` (plus `model` and any `tool_extras.opencode` fields such as `temperature`); the agent name comes from the filename. OpenCode does **not** read `.claude/agents`, so this native copy is required. -- **Rules** are copied into `.opencode/rules/` (or `~/.config/opencode/rules/`), but OpenCode does not auto-scan a rules directory — the files are inert until referenced. teamai therefore adds a `rules/*.md` glob to the `instructions` array in `opencode.json` and removes it again when the team's last rule goes away, editing only that one key and leaving your own `instructions` entries untouched. +- **Rules** are copied into `.opencode/rules/` (or `~/.config/opencode/rules/`), but OpenCode does not auto-scan a rules directory — the files are inert until referenced. teamai therefore adds globs to the `instructions` array in `opencode.json` and removes them again when the team's last rule goes away, editing only that one key and leaving your own `instructions` entries untouched. In a project that is `.opencode/rules/*.md` in the root `opencode.json`. In user scope it is the absolute `~/.config/opencode/rules/*.md` plus one glob per namespace directory a rule lands in (`~/.config/opencode/rules//*.md`): OpenCode resolves a relative entry from the session's working directory, and globs only the file name of an absolute one, so `**` never matches. A pull replaces the relative `rules/*.md` an earlier release wrote, which loaded the project's `rules/` instead, and drops a team namespace's glob once its rules no longer reach you; a glob you added for a directory of your own stays. `uninstall` removes them. - **Hooks** are delivered as an OpenCode *plugin*, not a settings-file entry — OpenCode has no `hooks` array; it auto-loads JS/TS plugins from **both** `~/.config/opencode/plugin/` and `/.opencode/plugin/`. A plugin present in both dirs is loaded twice and would dispatch every event twice, so teamai keeps exactly one copy: `teamai-hooks.ts` in the user dir, which covers every project. Any project-scope copy left by an earlier layout is deleted on the next sync. This matches the other tools, whose `settings.json` hooks also live in HOME and gate on the `cwd` handed to `hook-dispatch`. The plugin subscribes to OpenCode's own events and shelling out to the same `teamai hook-dispatch` entry point every other tool uses. The event mapping mirrors the Claude built-in set: `session.created` → session-start, `session.idle` → stop, `chat.message` → prompt-submit, `tool.execute.after` → post-tool-use. The plugin forwards the same STDIN payload other agents send (`cwd`, `session_id`, `tool_name`, `tool_input`, `prompt`, and on post-tool-use the tool's output and status), and maps OpenCode's lowercase tool ids (`skill`, `todowrite`) back to the PascalCase matchers the handler registry expects. OpenCode cannot inject a hook's stdout back into the session, so hooks run purely for their side effects (status report / sync / update). Note that OpenCode *awaits* its named hooks (`chat.message`, `tool.execute.after`), so those dispatches briefly wait on the `teamai` subprocess before the agent continues; the errors are always swallowed so a hook can never fail the session. Server-pushed agent hooks (`teamai-agent-.ts`) install into the same user plugin dir. Upvote **adoption** runs for OpenCode from the recall log, not a transcript: the plugin's `shell.env` hook sets `TEAMAI_AGENT_SESSION_ID` in the bash tool's environment, so a `teamai recall` run there joins the session its hooks carry, and a `task` call links the subagent's child session to its parent, so a doc the parent opens after a subagent's recall is upvoted. The opt-in LLM-judge needs a transcript, which `session.idle` does not carry, so it does not run for OpenCode, and the "adopted team knowledge" summary is never shown, as hook stdout is discarded. - **MCP** servers live under the `mcp` key of the shared `opencode.json` (see the MCP section above). @@ -2378,7 +2378,7 @@ Besides the provider, clone, config and hook checks, `doctor` verifies what reac `Rules delivered to ` and `Agents delivered to ` do the same for the other two per-tool resources, and both ask the handler where an item lands rather than deriving a path: a rule's filename and content change per tool (`.md` verbatim, `.mdc` with derived `globs`/`alwaysApply`, `.instructions.md` with `applyTo`, Kiro's `.md` with `inclusion`/`fileMatchPattern`, Qoder's `.md` with `trigger`/`glob`), and an agent's destination comes from its render, with `targets:` deciding which tools are owed a copy at all. A delivered rule is compared with the bytes the handler renders for that tool, not merely read for the keys its tool needs: a `.mdc` whose `globs` no longer match the team rule's `paths:` applies to the wrong files while carrying a perfectly legal `alwaysApply`, and that reads here as `delivered from an older copy` — the same label as a body that drifted, because both landed successfully and are still wrong. An agent is compared with the bytes its render produces, so a copy left behind by an older spec — a plain pull skips a scope whose team repo has not changed, so it can sit there indefinitely — is reported as `delivered from an older spec` rather than passing as present. `Every team agent reaches a tool` names an agent that renders for no installed tool — usually a spec that does not parse, or a `targets:` list naming only tools you do not have. These two are `doctor`-only: they read every rule per tool and parse every agent, which would spend the budget the checks at the end of a pull run under. -Three tools do not read a rules directory, so a per-file check cannot speak for them and each gets one of its own. `Team rules are active in opencode` checks that `opencode.json` still lists the glob the pull owns under `instructions`: OpenCode does not auto-scan `.opencode/rules`, so without it every delivered `.md` is inert while the per-file check keeps passing. `Team rules are inlined in Hermes SOUL.md` compares the teamai-managed block of `SOUL.md` with what the team rules inline to, since Hermes reads standing instructions from that one file rather than from a directory — a deleted block, or one left on an older rule set, is a tool reading the wrong rules with nothing on disk to show for it. In user scope, `Team rules are inlined in Codex AGENTS.md` compares the team-rules block of the tool's `AGENTS.md` with what the team rules inline to, and fails when an `AGENTS.override.md` beside it shadows the file. In a project, `Project rules and instructions reach whole through its session hooks` fails when the teamai `SessionStart` or `SubagentStart` entry in that tool's `hooks.json` is missing or does not set `additionalContextLimit: 0`, without which Codex keeps only the start and end of a large set. Codex has no `Rules delivered to ` check. +Three tools do not read a rules directory, so a per-file check cannot speak for them and each gets one of its own. `Team rules are active in opencode` checks that `opencode.json` lists every glob the pull owns under `instructions`, and no stale one such as the relative `rules/*.md` an earlier release wrote: OpenCode does not auto-scan `.opencode/rules`, so without it every delivered `.md` is inert while the per-file check keeps passing. In user scope, `Team rules are inlined in Hermes SOUL.md` compares the teamai-managed block of `SOUL.md` with what the team rules inline to, since Hermes reads standing instructions from that one file rather than from a directory — a deleted block, or one left on an older rule set, is a tool reading the wrong rules with nothing on disk to show for it. In user scope, `Team rules are inlined in Codex AGENTS.md` compares the team-rules block of the tool's `AGENTS.md` with what the team rules inline to, and fails when an `AGENTS.override.md` beside it shadows the file. In a project, `Project rules and instructions reach whole through its session hooks` fails when the teamai `SessionStart` or `SubagentStart` entry in that tool's `hooks.json` is missing or does not set `additionalContextLimit: 0`, without which Codex keeps only the start and end of a large set. Codex has no `Rules delivered to ` check. `MCP servers delivered to ` compares each server the team's `mcp.yaml` resolves for that tool against the entry in the tool's own config, and names any the reconcile skipped with its reason. The comparison is the entry, not the name: reconciliation leaves an entry teamai does not own alone, so a server of your own under a team name holds the key while the team's definition never arrives, and a stale copy is just as undelivered. Both are reported as `not the team's definition`, and only `teamai pull --force` replaces an entry teamai did not write. An unresolved `${VAR}` is reported here with the variable's name, which is otherwise said once during a pull and never again. A declared secret with no value is not a failure: doctor prints it as a note (`notes` in `--json`) with the command that sets it, and the exit code stays as it would be without it; a note also says when an entry kept for it may hold an old value, and when a key is declared as a secret and also set in `env.yaml`. An `mcp.yaml` that does not parse is not a team without MCP: it is reported as `Team MCP servers can be read` with the parse error, since it injects nothing into any tool and every run after the first is silent about it. Team hooks and team model profiles that cannot be resolved (a file that does not parse, a name defined twice in one file, or one name in two active namespaces) fail `Team hooks can be resolved` and `Team model profiles can be resolved` with the reason pull logs once; `teamai status` points here when it counts them as 0. `Env variables injected in shell profile` no longer stops at finding the marker comment: it checks that `env/env.yaml` parses and declares its variables under the `variables:` key (a plain `KEY: value` mapping parses as none, while an explicit `variables: []` is a configuration with nothing to deliver and fails nothing), that each one reached `env.sh` with the value `env.yaml` declares, or your value for this team (one set with `--from-env` is not written there) — a key left over from an older value exports it to every shell and MCP server until the next pull, and the comparison reads `env.sh` back through the generator's own inverse, so a multiline value quoted across several lines is matched rather than called stale — and that this scope's injected block (the one sourcing its own `env.sh`, since a profile can also carry another scope's) would actually load it — an unquoted Windows path degrades to something a POSIX shell cannot read, so `source` never runs and nothing says so. `No stale env blocks left behind` is a separate check: which file `pull` prefers has changed over time (Windows Git Bash's login shell reads `.bash_profile`/`.bash_login`/`.profile`, never `.bashrc`), and a pull only ever adds a block, never migrates an old one away, so a dead block from an earlier install or platform change can sit in another candidate file indefinitely. It names every such file (checking `.zshrc`, `.bashrc`, `.bash_profile`, `.bash_login` and `.profile`, current and legacy spellings alike) and points at `teamai uninstall` to remove them — separately from delivery, so a working env block never reads as broken just because an old one is still lying around. diff --git a/docs/usage-guide.zh-CN.md b/docs/usage-guide.zh-CN.md index f89089f42..49fbd3b04 100644 --- a/docs/usage-guide.zh-CN.md +++ b/docs/usage-guide.zh-CN.md @@ -923,7 +923,7 @@ teamai push > 管理员可在 `teamai.yaml` 中设置强制规则(`sharing.rules.enforced`),成员不可删除。 -大多数工具在自己的 rules 目录中为每条 rule 得到一个文件。Codex、`codex-internal` 和 `tcodex` 不读取 rules 目录(`.codex/rules/` 存放的是 Codex 自己的 `*.rules` 命令策略文件),因此 `pull` 不为它们写任何 rule 文件。user scope 下,团队 rule 写入该工具自己的 `AGENTS.md`(`~/.codex/AGENTS.md`、`~/.codex-internal/AGENTS.md`、`~/.tcodex/AGENTS.md`;`toolRoots` 条目可改变其位置)中的 `` 区块,只有该工具读取这个文件。在项目中,改由它们的 session-start hook 把项目的团队 rule 加入每个会话:项目 `AGENTS.md` 属于项目维护者,其他拥有自己 rules 格式的工具也会读取它。Hermes 的 `SOUL.md` 区块得到同样的内容。frontmatter 会被去掉,所以带 `paths:` 的 rule 在这里对所有文件生效,并以一行 `Applies to files matching: ` 开头。Codex 在压缩上下文或 clear 之后会再次运行该 hook;恢复会话时不添加任何内容,因为会话中已包含这些 rule。Codex 启动的子 agent 通过 `SubagentStart` hook 获得它们。公开版 Codex 只运行已信任的 hook。teamai 会自动信任它写入的 hooks;如果自动信任被禁用或失败,请在 `/hooks` 中批准它们以获得项目的 rule。 +大多数工具在自己的 rules 目录中为每条 rule 得到一个文件。Codex、`codex-internal` 和 `tcodex` 不读取 rules 目录(`.codex/rules/` 存放的是 Codex 自己的 `*.rules` 命令策略文件),因此 `pull` 不为它们写任何 rule 文件。user scope 下,团队 rule 写入该工具自己的 `AGENTS.md`(`~/.codex/AGENTS.md`、`~/.codex-internal/AGENTS.md`、`~/.tcodex/AGENTS.md`;`toolRoots` 条目可改变其位置)中的 `` 区块,只有该工具读取这个文件。在项目中,改由它们的 session-start hook 把项目的团队 rule 加入每个会话:项目 `AGENTS.md` 属于项目维护者,其他拥有自己 rules 格式的工具也会读取它。Hermes 的 `SOUL.md` 区块得到同样的内容,且只由 user scope 的 pull 写入:`SOUL.md` 是全局文件,项目 pull 会让它保持 user scope pull 写入时的样子。Hermes 不会得到项目 rule,在项目中 `init` 和 `doctor` 会说明原因:`.hermes.md` 会遮蔽项目 `AGENTS.md`,`pre_llm_call` hook 会在每一轮重复添加 rule,而唯一的插件提示段落(最多 4,000 个字符)已用于团队指令。frontmatter 会被去掉,所以带 `paths:` 的 rule 在这里对所有文件生效,并以一行 `Applies to files matching: ` 开头。Codex 在压缩上下文或 clear 之后会再次运行该 hook;恢复会话时不添加任何内容,因为会话中已包含这些 rule。Codex 启动的子 agent 通过 `SubagentStart` hook 获得它们。公开版 Codex 只运行已信任的 hook。teamai 会自动信任它写入的 hooks;如果自动信任被禁用或失败,请在 `/hooks` 中批准它们以获得项目的 rule。 culture、共享指令和 recall 区块采用同样的划分。user scope 下它们写入同一个 `AGENTS.md`,标记之外你自己的内容保持不变。在项目中,session-start hook 把它们与 rule 一起加入会话,`pull` 不改动项目 `AGENTS.md`。 @@ -2119,7 +2119,7 @@ GitHub Copilot CLI 已支持其官方自定义指令、Rules、Skills、自定 - **作用域。** OpenCode 的用户配置在 `~/.config/opencode/` 下,项目配置在 `/.opencode/` 下——前缀与其他所有工具都不同。teamai 会按 `--scope` 写入正确的位置,且仅在该作用域确实安装了 OpenCode 时才碰它的文件(绝不会为未使用 OpenCode 的用户创建 `~/.config/opencode/`)。Hooks 是唯一的例外——始终写在用户级,原因见下。 - **Skills** 落在 `.opencode/skills/`(项目)或 `~/.config/opencode/skills/`(用户)。OpenCode 也原生读取 `.claude/skills`,但 teamai 仍会写 OpenCode 路径,好让只用 OpenCode 的用户也能拿到。 - **Subagents** 会被渲染成 OpenCode 自己的 `agents/*.md` 格式:frontmatter 带 `description` + `mode: subagent`(以及 `model` 和 `tool_extras.opencode` 中的字段,如 `temperature`);agent 名取自文件名。OpenCode **不**读取 `.claude/agents`,因此这份原生副本是必需的。 -- **Rules** 会被复制到 `.opencode/rules/`(或 `~/.config/opencode/rules/`),但 OpenCode 不会自动扫描 rules 目录——文件在被引用前是惰性的。因此 teamai 会往 `opencode.json` 的 `instructions` 数组里加一条 `rules/*.md` glob,并在团队最后一条 rule 消失时再把它移除,且只编辑这一个键、不动你自己的 `instructions` 条目。 +- **Rules** 会被复制到 `.opencode/rules/`(或 `~/.config/opencode/rules/`),但 OpenCode 不会自动扫描 rules 目录——文件在被引用前是惰性的。因此 teamai 会往 `opencode.json` 的 `instructions` 数组里加入 glob,并在团队最后一条 rule 消失时再把它们移除,且只编辑这一个键、不动你自己的 `instructions` 条目。在项目中是根目录 `opencode.json` 里的 `.opencode/rules/*.md`。user scope 下是绝对路径 `~/.config/opencode/rules/*.md`,外加 rule 所落入的每个 namespace 目录各一条(`~/.config/opencode/rules//*.md`):OpenCode 从会话的工作目录解析相对条目,对绝对条目只对文件名做 glob,因此 `**` 永远不会匹配。pull 会替换旧版本写入的相对 `rules/*.md`(它加载的是项目的 `rules/`),并在某个团队 namespace 的 rule 不再送达你时移除它的 glob;你为自己的目录添加的 glob 会保留。`uninstall` 会移除这些 glob。 - **Hooks** 以 OpenCode *plugin* 形式交付,而非配置文件条目——OpenCode 没有 `hooks` 数组,它会**同时**加载 `~/.config/opencode/plugin/` 和 `/.opencode/plugin/` 下的 JS/TS 插件。两个目录都有插件时会被加载两次,每个事件也就派发两次,因此 teamai 只保留一份:写在用户目录的 `teamai-hooks.ts`,覆盖所有项目;早期布局残留的项目级副本会在下次同步时被删除。这与其他工具一致——它们的 `settings.json` hooks 同样放在 HOME,靠传给 `hook-dispatch` 的 `cwd` 做作用域判断。插件订阅 OpenCode 自己的事件,并 shell 到其他所有工具共用的 `teamai hook-dispatch` 入口。事件映射对齐 Claude 内置集合:`session.created` → session-start、`session.idle` → stop、`chat.message` → prompt-submit、`tool.execute.after` → post-tool-use。插件会转发与其他工具一致的 STDIN 负载(`cwd`、`session_id`、`tool_name`、`tool_input`、`prompt`,post-tool-use 时还有工具输出和状态),并把 OpenCode 的小写工具 id(`skill`、`todowrite`)映射回 handler 注册表期望的 PascalCase matcher。OpenCode 无法把 hook 的 stdout 回注到会话,因此 hooks 只为副作用运行(状态上报 / 同步 / 更新)。注意 OpenCode 会 **await** 它的具名 hook(`chat.message`、`tool.execute.after`),所以这两个事件的派发会短暂等待 `teamai` 子进程后 agent 才继续;错误始终被吞掉,hook 永远不会让会话失败。服务端下发的 agent hook(`teamai-agent-.ts`)同样装在这个用户级 plugin 目录下。upvote **采纳(adoption)**在 OpenCode 上基于 recall 日志运行,不依赖 transcript:插件的 `shell.env` hook 会在 bash 工具的环境中设置 `TEAMAI_AGENT_SESSION_ID`,因此在其中运行的 `teamai recall` 会归入其 hooks 携带的同一会话;`task` 调用会把子代理的子会话关联到父会话,因此子代理 recall 之后父会话打开的文档会被 upvote。可选的 LLM-judge 需要 transcript,而 `session.idle` 不携带,所以它在 OpenCode 上不运行;hook 的 stdout 会被丢弃,因此"本次会话采纳的团队知识"摘要也不会显示。 - **MCP** server 位于共享 `opencode.json` 的 `mcp` 键下(详见上文 MCP 章节)。 @@ -2221,7 +2221,7 @@ teamai remove rules --force # 跳过确认,用于脚本和 CI `Rules delivered to ` 与 `Agents delivered to ` 对另外两类按工具下发的资源做同样的事,并且都向 handler 询问落点,而不是自行拼路径:rule 的文件名和内容因工具而异(`.md` 原样、`.mdc` 带派生的 `globs`/`alwaysApply`、`.instructions.md` 带 `applyTo`、Kiro 的 `.md` 带 `inclusion`/`fileMatchPattern`、Qoder 的 `.md` 带 `trigger`/`glob`),agent 的落点来自渲染结果,且由 `targets:` 决定哪些工具应当收到。已送达的 rule 会与 handler 为该工具渲染出的字节逐一比对,而不只是检查该工具所需的键是否存在:`globs` 与团队 rule 的 `paths:` 不再一致的 `.mdc`,即使 `alwaysApply` 取值合法,也会作用到错误的文件上;这里会报告为 `delivered from an older copy`——正文漂移的副本同样如此,因为两者都写入成功,却都是错的。agent 会与渲染结果逐字节比对:旧版 spec 留下的副本(普通 pull 会跳过团队仓库未变化的 scope,它可能一直留在那里)报告为 `delivered from an older spec`,而不是当作已送达。`Every team agent reaches a tool` 会指出在任何已安装工具上都无法渲染的 agent,通常是 spec 解析失败,或 `targets:` 只列了本机没有的工具。这两项仅在 `doctor` 中运行:它们会按工具读取每条 rule、解析每个 agent,放进 pull 结束时的检查会耗尽其时间预算。 -有三个工具并不读取 rules 目录,按文件比对的检查无法代表它们,因此各自单列一项。`Team rules are active in opencode` 检查 `opencode.json` 的 `instructions` 中是否仍列着 teamai 所拥有的那条 glob:OpenCode 不会自动扫描 `.opencode/rules`,缺了它,已送达的每个 `.md` 都不会生效,而按文件比对的检查依旧通过。`Team rules are inlined in Hermes SOUL.md` 把 `SOUL.md` 中 teamai 管理的代码块与团队 rule 内联后的内容比对——Hermes 的常驻指令来自这一个文件而非某个目录,因此代码块被删除或停留在旧版规则集上,都意味着该工具读到的是错误的规则,而磁盘上看不出任何异常。user scope 下,`Team rules are inlined in Codex AGENTS.md` 把该工具 `AGENTS.md` 中的 team-rules 区块与团队 rule 内联后的内容比对,旁边有 `AGENTS.override.md` 遮蔽该文件时也会失败。在项目中,当该工具 `hooks.json` 中的 teamai `SessionStart` 或 `SubagentStart` 条目缺失或没有设置 `additionalContextLimit: 0` 时,`Project rules and instructions reach whole through its session hooks` 会失败:没有它,Codex 对较大的内容只保留开头和结尾。Codex 没有 `Rules delivered to ` 检查。 +有三个工具并不读取 rules 目录,按文件比对的检查无法代表它们,因此各自单列一项。`Team rules are active in opencode` 检查 `opencode.json` 的 `instructions` 中是否列着 teamai 所拥有的每条 glob,且没有过时的条目(如旧版本写入的相对 `rules/*.md`):OpenCode 不会自动扫描 `.opencode/rules`,缺了它,已送达的每个 `.md` 都不会生效,而按文件比对的检查依旧通过。user scope 下,`Team rules are inlined in Hermes SOUL.md` 把 `SOUL.md` 中 teamai 管理的代码块与团队 rule 内联后的内容比对——Hermes 的常驻指令来自这一个文件而非某个目录,因此代码块被删除或停留在旧版规则集上,都意味着该工具读到的是错误的规则,而磁盘上看不出任何异常。user scope 下,`Team rules are inlined in Codex AGENTS.md` 把该工具 `AGENTS.md` 中的 team-rules 区块与团队 rule 内联后的内容比对,旁边有 `AGENTS.override.md` 遮蔽该文件时也会失败。在项目中,当该工具 `hooks.json` 中的 teamai `SessionStart` 或 `SubagentStart` 条目缺失或没有设置 `additionalContextLimit: 0` 时,`Project rules and instructions reach whole through its session hooks` 会失败:没有它,Codex 对较大的内容只保留开头和结尾。Codex 没有 `Rules delivered to ` 检查。 `MCP servers delivered to ` 将团队 `mcp.yaml` 为该工具解析出的每个 server 与该工具自己配置文件中的条目逐一比对,并列出 reconcile 跳过的 server 及原因。比对的是条目内容而非名字:reconcile 不会覆盖不属于 teamai 的条目,因此你自己写的同名 server 会占住这个名字,团队的定义从未真正送达;过期的旧副本同样等于没送达。两者都报告为 `not the team's definition`,而覆盖非 teamai 写入的条目只有 `teamai pull --force` 能做到。未解析的 `${VAR}` 会在这里连同变量名一起报告——否则它只在 pull 时出现一次,之后再无提示。没有值的已声明密钥不算失败:doctor 把它作为备注打印(`--json` 中的 `notes`),并附上设置它的命令,退出码与没有它时相同;备注还会说明为它保留的条目可能含有旧值,以及某个 key 既声明为密钥、又在 `env.yaml` 中设置的情况。无法解析的 `mcp.yaml` 并不等于团队没有 MCP:它会作为 `Team MCP servers can be read` 连同解析错误一起报告,因为这种文件不会向任何工具注入内容,而且除第一次之外的每次运行都对此保持沉默。无法解析的团队 hooks 与团队模型配置(文件无法解析、同一文件内重复的名字,或两个活动 namespace 中的同名条目)会让 `Team hooks can be resolved` 与 `Team model profiles can be resolved` 失败,并给出 pull 只记录一次的原因;`teamai status` 把它们计为 0 时会指向这里。`Env variables injected in shell profile` 不再只查标记注释:它会检查 `env/env.yaml` 能否解析、以及是否在 `variables:` 键下声明了变量(写成普通的 `KEY: value` 映射等于没有声明;而显式写成 `variables: []` 属于没有内容要下发的配置,不会判为失败)、每个变量是否以 `env.yaml` 声明的值(或你为该团队设置的值;用 `--from-env` 设置的不会写入)写进了 `env.sh`(残留的旧值会一直被导出到每个 shell 和 MCP server,直到下次 pull;比对时会用生成器自身的逆运算读回 `env.sh`,因此跨多行引用的多行值能够正确匹配,而不会被误判为过期),以及本作用域注入的代码块(即 source 本作用域 `env.sh` 的那一块,因为同一个 profile 里还可能有其他作用域的代码块)是否真的能加载它——未加引号的 Windows 路径在 POSIX shell 中会被转义破坏,`source` 从不执行,而且没有任何提示。`No stale env blocks left behind` 是独立的一项检查:pull 优先选用哪个文件会随时间变化(Windows 上 Git Bash 的登录 shell 读取的是 `.bash_profile`/`.bash_login`/`.profile`,从不读取 `.bashrc`),而 pull 只会新增代码块,从不迁移旧的,因此早期安装或平台变化留下的失效代码块可能一直留在另一个候选文件里。它会列出每一个这样的文件(检查 `.zshrc`、`.bashrc`、`.bash_profile`、`.bash_login` 和 `.profile`,新旧写法都算),并指向 `teamai uninstall` 来清除它们——这与投递检查分开进行,因此不会因为还留着一个旧副本,就让一个正常工作的 env 代码块被判成故障。 diff --git a/skill-data/setup/references/uninstall.md b/skill-data/setup/references/uninstall.md index 64932c6ae..066031368 100644 --- a/skill-data/setup/references/uninstall.md +++ b/skill-data/setup/references/uninstall.md @@ -59,6 +59,9 @@ and give it your team repo URL."* ## Notes +- For OpenCode, uninstall also removes the rules globs teamai added to + `instructions` in `opencode.json`, including the relative `rules/*.md` an + earlier release wrote in user scope. The user's own entries stay. - Uninstall cleans legacy Codex rule copies at the recorded `toolRoots` location, including publishers' bare local filenames. It keeps edited copies. For a rule the team has diff --git a/src/__tests__/doctor-rules-delivery.test.ts b/src/__tests__/doctor-rules-delivery.test.ts index 1a73416dc..140a5f57d 100644 --- a/src/__tests__/doctor-rules-delivery.test.ts +++ b/src/__tests__/doctor-rules-delivery.test.ts @@ -21,7 +21,7 @@ vi.mock('../utils/logger.js', () => ({ import crypto from 'node:crypto'; import { loadLocalConfig, loadStateForScope, loadTeamConfig } from '../config.js'; -import { buildChecks, resolveDoctorContext, type Check } from '../doctor.js'; +import { buildChecks, doctor, resolveDoctorContext, type Check, type DoctorReport } from '../doctor.js'; import { checkoutKey } from '../pull.js'; import { StateSchema, TeamaiConfigSchema, type LocalConfig, type TeamaiConfig } from '../types.js'; @@ -277,11 +277,43 @@ describe('doctor — rules delivered on disk', () => { it('passes when opencode.json lists the rules glob beside the user\'s own', async () => { await installOpencode(); - await writeOpencodeConfig({ instructions: ['CONVENTIONS.md', 'rules/*.md'] }); + await writeOpencodeConfig({ instructions: ['CONVENTIONS.md', `${path.join(homeDir, OPENCODE_RULES)}/*.md`] }); expect(await (await namedCheck('Team rules are active in opencode'))!.check()).toBe(true); }); + it('fails on the relative rules/*.md an earlier release wrote, which loads the project\'s rules (#946)', async () => { + await installOpencode(); + await writeOpencodeConfig({ instructions: ['rules/*.md', `${path.join(homeDir, OPENCODE_RULES)}/*.md`] }); + + const active = await namedCheck('Team rules are active in opencode'); + expect(await active!.check()).toBe(false); + expect(active!.fix).toContain('`rules/*.md`'); + expect(active!.fix).toContain('session'); + }); + + it('does not flag a glob the member added for a directory that is no team namespace (#946)', async () => { + await installOpencode(); + const root = path.join(homeDir, OPENCODE_RULES); + await writeOpencodeConfig({ instructions: [`${root}/*.md`, `${root}/mine/*.md`] }); + + expect(await (await namedCheck('Team rules are active in opencode'))!.check()).toBe(true); + }); + + it('fails until the directory of a namespaced rule has its own glob (#946)', async () => { + await installOpencode(); + await writeTeamRule('fe/style'); + const root = path.join(homeDir, OPENCODE_RULES); + await writeOpencodeConfig({ instructions: [`${root}/*.md`] }); + + const active = await namedCheck('Team rules are active in opencode'); + expect(await active!.check()).toBe(false); + expect(active!.fix).toContain(`\`${root}/fe/*.md\``); + + await writeOpencodeConfig({ instructions: [`${root}/*.md`, `${root}/fe/*.md`] }); + expect(await (await namedCheck('Team rules are active in opencode'))!.check()).toBe(true); + }); + it('fails when opencode.json cannot be parsed, which is when the pull skipped it', async () => { await installOpencode(); await writeOpencodeConfig('{ not json'); @@ -355,6 +387,37 @@ describe('doctor — rules delivered on disk', () => { expect(await namedCheck('Team rules are inlined in Hermes SOUL.md')).toBeUndefined(); }); + describe('Hermes in project scope (#946)', () => { + beforeEach(async () => { + const hermesHome = path.join(tempDir, 'hermes'); + await fse.ensureDir(hermesHome); + vi.stubEnv('HERMES_HOME', hermesHome); + // The block a user-scope pull wrote, which holds the user rules, not this project's. + await fse.writeFile(path.join(hermesHome, 'SOUL.md'), '\nUser rule\n\n'); + Object.assign(localConfig, { scope: 'project', projectRoot: path.join(tempDir, 'project') }); + }); + + it('does not compare SOUL.md with the project rules, which only a user-scope pull writes there', async () => { + expect(await namedCheck('Team rules are inlined in Hermes SOUL.md')).toBeUndefined(); + }); + + it('notes that Hermes gets no project rules, and why', async () => { + const spy = vi.spyOn(console, 'log').mockImplementation(() => {}); + let report: DoctorReport; + try { + await doctor({ json: true }); + report = JSON.parse(String(spy.mock.calls.at(-1)?.[0])) as DoctorReport; + } finally { + spy.mockRestore(); + } + const note = (report.notes ?? []).find((line) => line.startsWith('Hermes gets no project rules')); + expect(note).toBeDefined(); + expect(note).toContain('.hermes.md'); + expect(note).toContain('pre_llm_call'); + expect(note).toContain('4,000'); + }); + }); + describe('Codex AGENTS.md, user scope (#938)', () => { const CODEX = 'Team rules are inlined in Codex AGENTS.md'; // What pull writes for the two team rules, markers included. diff --git a/src/__tests__/e2e/doctor-delivery-cli.test.ts b/src/__tests__/e2e/doctor-delivery-cli.test.ts index a913cb8dd..3bc0aa867 100644 --- a/src/__tests__/e2e/doctor-delivery-cli.test.ts +++ b/src/__tests__/e2e/doctor-delivery-cli.test.ts @@ -25,6 +25,8 @@ interface DoctorReport { ok: boolean; checks: CheckResult[] } describe('teamai doctor delivery checks (e2e)', () => { let sandbox: string; let home: string; + // Absolute: OpenCode resolves a relative entry from the session's cwd (#946). + const opencodeUserGlob = (): string => `${path.join(home, '.config', 'opencode', 'rules').split(path.sep).join('/')}/*.md`; let repo: string; function runDoctor(): DoctorReport { @@ -173,7 +175,7 @@ describe('teamai doctor delivery checks (e2e)', () => { write(path.join(home, '.codebuddy/agents/reviewer.md'), CLAUDE_AGENT_MD); write(path.join(home, '.config/opencode/rules/coding-style.md'), 'Coding style body\n'); // The glob the pull adds; without it every .md above is inert. - write(path.join(home, '.config', 'opencode', 'opencode.json'), JSON.stringify({ instructions: ['rules/*.md'] })); + write(path.join(home, '.config', 'opencode', 'opencode.json'), JSON.stringify({ instructions: [opencodeUserGlob()] })); // The entry teamai renders for claude, placeholder resolved — the check // compares the value, so a hand-shaped entry of the same name is not it. write(path.join(home, '.claude.json'), JSON.stringify({ @@ -275,7 +277,7 @@ describe('teamai doctor delivery checks (e2e)', () => { expect(active.ok).toBe(false); expect(active.fix).toContain('inert'); - write(path.join(home, '.config', 'opencode', 'opencode.json'), JSON.stringify({ instructions: ['rules/*.md'] })); + write(path.join(home, '.config', 'opencode', 'opencode.json'), JSON.stringify({ instructions: [opencodeUserGlob()] })); }); it('passes a multiline env value the shell quotes across several lines', () => { diff --git a/src/__tests__/opencode-config.test.ts b/src/__tests__/opencode-config.test.ts index 00f5a29b1..ab44bf23d 100644 --- a/src/__tests__/opencode-config.test.ts +++ b/src/__tests__/opencode-config.test.ts @@ -7,7 +7,7 @@ vi.mock('../utils/logger.js', () => ({ log: { info: vi.fn(), success: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn(), dim: vi.fn() }, })); -import { reconcileOpencodeInstructions, opencodeRulesGlob } from '../resources/opencode-config.js'; +import { reconcileOpencodeInstructions, reconcileOpencodeInstructionSet, opencodeRuleGlobs, opencodeRulesGlob } from '../resources/opencode-config.js'; describe('opencodeRulesGlob', () => { it('project scope: config at root, rules under .opencode/rules', () => { @@ -21,6 +21,54 @@ describe('opencodeRulesGlob', () => { }); }); +describe('opencodeRuleGlobs (#946)', () => { + const config = '/home/u/.config/opencode/opencode.json'; + const rules = '/home/u/.config/opencode/rules'; + + it('user scope: the absolute root glob plus one per namespace directory, root first', () => { + const { globs } = opencodeRuleGlobs('user', config, rules, [`${rules}/fe`, rules, `${rules}/be/api`, `${rules}/fe`], []); + expect(globs).toEqual([`${rules}/*.md`, `${rules}/be/api/*.md`, `${rules}/fe/*.md`]); + }); + + it('user scope: owns its globs, every team namespace\'s and the old relative one, not the member\'s', () => { + const { owns } = opencodeRuleGlobs('user', config, rules, [], [`${rules}/fe`]); + expect(owns(`${rules}/*.md`)).toBe(true); + expect(owns(`${rules}/fe/*.md`)).toBe(true); + expect(owns('rules/*.md')).toBe(true); + expect(owns(`${rules}/mine/*.md`)).toBe(false); + expect(owns('CONVENTIONS.md')).toBe(false); + }); + + it('project scope: the one relative glob', () => { + const { globs, owns } = opencodeRuleGlobs('project', '/repo/opencode.json', '/repo/.opencode/rules', ['/repo/.opencode/rules/fe'], []); + expect(globs).toEqual(['.opencode/rules/*.md']); + expect(owns('.opencode/rules/*.md')).toBe(true); + }); +}); + +describe('reconcileOpencodeInstructionSet (#946)', () => { + let tmpDir: string; + let configFile: string; + + beforeEach(async () => { + tmpDir = await fse.mkdtemp(path.join(os.tmpdir(), 'teamai-oc-set-')); + configFile = path.join(tmpDir, 'opencode.json'); + }); + + afterEach(async () => { + await fse.remove(tmpDir); + }); + + it('replaces owned entries with the desired ones in place of nothing else, and is idempotent', async () => { + await fse.writeJson(configFile, { model: 'm', instructions: ['A.md', 'old/*.md', { x: 1 }, 'keep.md'] }); + const owns = (entry: string): boolean => entry.endsWith('/*.md'); + + expect(await reconcileOpencodeInstructionSet(configFile, ['/abs/*.md', '/abs/ns/*.md'], owns)).toBe(true); + expect(await fse.readJson(configFile)).toEqual({ model: 'm', instructions: ['A.md', { x: 1 }, 'keep.md', '/abs/*.md', '/abs/ns/*.md'] }); + expect(await reconcileOpencodeInstructionSet(configFile, ['/abs/*.md', '/abs/ns/*.md'], owns)).toBe(false); + }); +}); + describe('reconcileOpencodeInstructions', () => { let tmpDir: string; let configFile: string; diff --git a/src/__tests__/rules.test.ts b/src/__tests__/rules.test.ts index 12acfdebb..a005bbf7b 100644 --- a/src/__tests__/rules.test.ts +++ b/src/__tests__/rules.test.ts @@ -1060,22 +1060,72 @@ describe('RulesHandler.pullAllRules — OpenCode instructions activation', () => // File landed under the user-scope OpenCode rules dir. expect(await fse.pathExists(path.join(ocRules(), 'team-rule.md'))).toBe(true); - // opencode.json now references the teamai glob (user scope → 'rules/*.md'). + // An absolute glob: OpenCode resolves a relative entry from the session + // cwd, so `rules/*.md` would load the project's rules instead (#946). const doc = await fse.readJson(ocConfig()); - expect(doc.instructions).toContain('rules/*.md'); + expect(doc.instructions).toEqual([`${ocRules()}/*.md`]); + }); + + // OpenCode globs only the basename of an absolute entry, so `**` never + // matches: each directory a namespaced rule lands in gets its own glob. + it('adds one glob per namespace directory a rule lands in (#946)', async () => { + const teamRules = path.join(localConfig.repo.localPath, 'rules'); + await fse.writeFile(path.join(teamRules, 'root-rule.md'), 'root'); + await fse.ensureDir(path.join(teamRules, 'fe')); + await fse.writeFile(path.join(teamRules, 'fe', 'style.md'), 'fe style'); + await fse.writeFile(path.join(teamRules, 'fe', 'tests.md'), 'fe tests'); + + await handler.pullAllRules(teamConfig, localConfig); + + expect(await fse.pathExists(path.join(ocRules(), 'fe', 'style.md'))).toBe(true); + expect((await fse.readJson(ocConfig())).instructions).toEqual([`${ocRules()}/*.md`, `${ocRules()}/fe/*.md`]); + }); + + it('replaces the relative rules/*.md an earlier release wrote, keeping the member\'s own entries (#946)', async () => { + await fse.writeFile(path.join(localConfig.repo.localPath, 'rules', 'team-rule.md'), 'team content'); + await fse.ensureDir(path.dirname(ocConfig())); + await fse.writeJson(ocConfig(), { model: 'mine', instructions: ['CONVENTIONS.md', 'rules/*.md'] }); + + await handler.pullAllRules(teamConfig, localConfig); + + expect(await fse.readJson(ocConfig())).toEqual({ model: 'mine', instructions: ['CONVENTIONS.md', `${ocRules()}/*.md`] }); + }); + + it('keeps a glob the member added for a directory of their own under the rules root (#946)', async () => { + await fse.writeFile(path.join(localConfig.repo.localPath, 'rules', 'team-rule.md'), 'team content'); + await fse.ensureDir(path.dirname(ocConfig())); + await fse.writeJson(ocConfig(), { instructions: [`${ocRules()}/mine/*.md`] }); + + await handler.pullAllRules(teamConfig, localConfig); + + expect((await fse.readJson(ocConfig())).instructions).toEqual([`${ocRules()}/mine/*.md`, `${ocRules()}/*.md`]); + }); + + it('drops the glob of a namespace this member no longer receives (#946)', async () => { + const teamRules = path.join(localConfig.repo.localPath, 'rules'); + await fse.writeFile(path.join(teamRules, 'root-rule.md'), 'root'); + await fse.ensureDir(path.join(teamRules, 'fe')); + await fse.writeFile(path.join(teamRules, 'fe', 'style.md'), 'fe style'); + await handler.pullAllRules(teamConfig, localConfig); + + // `fe/` stays in the team repo; this member's selection leaves it out. + const selected = (await handler.scanTeamForPull(teamConfig, localConfig)).filter((rule) => rule.name === 'root-rule'); + await handler.pullAllRules(teamConfig, localConfig, selected); + + expect((await fse.readJson(ocConfig())).instructions).toEqual([`${ocRules()}/*.md`]); }); it('removes the instructions glob when the team has no rules left', async () => { // First: one rule → glob present. await fse.writeFile(path.join(localConfig.repo.localPath, 'rules', 'r.md'), 'x'); await handler.pullAllRules(teamConfig, localConfig); - expect((await fse.readJson(ocConfig())).instructions).toContain('rules/*.md'); + expect((await fse.readJson(ocConfig())).instructions).toContain(`${ocRules()}/*.md`); // Then: remove the team rule and re-pull → glob gone. await fse.remove(path.join(localConfig.repo.localPath, 'rules', 'r.md')); await handler.pullAllRules(teamConfig, localConfig); const doc = await fse.readJson(ocConfig()); - expect(doc.instructions ?? []).not.toContain('rules/*.md'); + expect(doc.instructions ?? []).not.toContain(`${ocRules()}/*.md`); }); it('does not create opencode.json when OpenCode is not installed', async () => { @@ -1617,4 +1667,29 @@ describe('RulesHandler.pullAllRules — Hermes SOUL.md (#938)', () => { expect(soul).toContain('Applies to files matching: src/**/*.ts\nUse strict types.'); expect(soul).not.toContain('paths:'); }); + + // Hermes has no project rules channel: SOUL.md is global, so only a + // user-scope pull may write its block (#946). + it('leaves the SOUL.md block as the user-scope pull wrote it after a project pull, with or without project rules', async () => { + await fse.writeFile(path.join(localConfig.repo.localPath, 'rules', 'user-rule.md'), 'USER RULE\n'); + await new RulesHandler().pullAllRules(teamConfig, localConfig); + const soulPath = path.join(hermesHome, 'SOUL.md'); + const afterUserPull = await fse.readFile(soulPath, 'utf-8'); + expect(afterUserPull).toContain('USER RULE'); + + const projectRoot = path.join(tmpDir, 'project'); + const projectRepo = path.join(tmpDir, 'project-team-repo'); + await fse.ensureDir(path.join(projectRepo, 'rules')); + await fse.writeFile(path.join(projectRepo, 'rules', 'project-rule.md'), 'PROJECT RULE\n'); + const projectConfig = { + ...localConfig, scope: 'project', projectRoot, repo: { localPath: projectRepo, remote: 'r' }, + } as unknown as LocalConfig; + + await new RulesHandler().pullAllRules(teamConfig, projectConfig); + expect(await fse.readFile(soulPath, 'utf-8')).toBe(afterUserPull); + + await fse.remove(path.join(projectRepo, 'rules', 'project-rule.md')); + await new RulesHandler().pullAllRules(teamConfig, projectConfig); + expect(await fse.readFile(soulPath, 'utf-8')).toBe(afterUserPull); + }); }); diff --git a/src/__tests__/uninstall.test.ts b/src/__tests__/uninstall.test.ts index 017ecb1ad..42ab0896f 100644 --- a/src/__tests__/uninstall.test.ts +++ b/src/__tests__/uninstall.test.ts @@ -1943,6 +1943,41 @@ describe('uninstall', () => { if (scenario === 'other tool') expect((await loadStateForScope(localConfig)).opencodeContextEntries).toEqual([]); }); + it('removes the OpenCode rules globs teamai owns from the user opencode.json, keeping the member\'s entries (#946)', async () => { + const homeDir = path.join(tmpDir, 'oc-home'); + const repoPath = path.join(tmpDir, 'oc-team-repo'); + await fse.ensureDir(path.join(repoPath, 'rules', 'fe')); + await fse.writeFile(path.join(repoPath, 'rules', 'team-rule.md'), '# Team Rule'); + await fse.writeFile(path.join(repoPath, 'rules', 'fe', 'style.md'), '# FE'); + const rulesDir = path.join(homeDir, '.config', 'opencode', 'rules'); + await fse.ensureDir(path.join(rulesDir, 'fe')); + await fse.writeFile(path.join(rulesDir, 'team-rule.md'), '# Team Rule'); + await fse.writeFile(path.join(rulesDir, 'fe', 'style.md'), '# FE'); + const configFile = path.join(homeDir, '.config', 'opencode', 'opencode.json'); + // `mine/` is no team namespace: its glob is the member's own entry. + await fse.writeJson(configFile, { + model: 'mine', + instructions: ['CONVENTIONS.md', `${rulesDir}/*.md`, `${rulesDir}/fe/*.md`, `${rulesDir}/mine/*.md`, 'rules/*.md'], + }); + + vi.stubEnv('HOME', homeDir); + vi.stubEnv('SHELL', '/bin/zsh'); + const teamConfig = makeTeamConfig({ + toolPaths: { + opencode: { + skills: '.opencode/skills', rules: '.opencode/rules', + mcp: '.config/opencode/opencode.json', mcpProject: 'opencode.json', + userScope: { skills: '.config/opencode/skills', rules: '.config/opencode/rules' }, + }, + }, + }); + mockAutoDetectInit.mockResolvedValue({ localConfig: makeLocalConfig(homeDir, repoPath), teamConfig }); + + await uninstall({ force: true }); + + expect(await fse.readJson(configFile)).toEqual({ model: 'mine', instructions: ['CONVENTIONS.md', `${rulesDir}/mine/*.md`] }); + }); + // A relocated Claude Code root (toolRoots) moves the HOME hook file, but the // legacy copy was written by a CLI that knew nothing about it — // so the two targets must be looked for at different paths. @@ -2722,6 +2757,27 @@ describe('uninstall', () => { expect(await fse.pathExists(stub)).toBe(false); }); + it('a project-scope uninstall leaves the SOUL.md rules block, which only a user-scope pull writes (#946)', async () => { + const projectRoot = path.join(tmpDir, 'hermes-project'); + const homeDir = path.join(tmpDir, 'home'); + const repoPath = path.join(projectRoot, '.teamai', 'team-repo'); + await fse.ensureDir(path.join(repoPath, 'rules')); + vi.stubEnv('HOME', homeDir); + vi.stubEnv('SHELL', '/bin/zsh'); + const hermesHome = path.join(tmpDir, 'hermes'); + vi.stubEnv('HERMES_HOME', hermesHome); + const soul = path.join(hermesHome, 'SOUL.md'); + const userBlock = `my own soul\n\n${TEAMAI_RULES_START}\nUser rule\n${TEAMAI_RULES_END}\n`; + await fse.outputFile(soul, userBlock); + + const teamConfig = makeTeamConfig(); + teamConfig.toolPaths.hermes = { skills: '.hermes/skills' }; + mockAutoDetectInit.mockResolvedValue({ localConfig: makeLocalConfig(projectRoot, repoPath, { scope: 'project', projectRoot }), teamConfig }); + await uninstall({ force: true }); + + expect(await fse.readFile(soul, 'utf-8')).toBe(userBlock); + }); + it('removes the stub Codex kept in the shared .agents/skills root, and nothing else there', async () => { const { homeDir, repoPath } = await setupFixture(tmpDir); vi.stubEnv('HOME', homeDir); diff --git a/src/doctor-delivery.ts b/src/doctor-delivery.ts index b9e7a6712..ee0e1f862 100644 --- a/src/doctor-delivery.ts +++ b/src/doctor-delivery.ts @@ -337,29 +337,51 @@ async function buildRulesActivationChecks(ctx: DoctorContext, items: ResourceIte const handler = new RulesHandler(); const checks: Check[] = []; - const opencode = await handler.opencodeInstructionsTarget(teamConfig, localConfig); + const opencode = await handler.opencodeInstructionsTarget(teamConfig, localConfig, items); if (opencode !== null) { const { readOpencodeInstructionList } = await import('./resources/opencode-config.js'); const instructions = await readOpencodeInstructionList(opencode.configFile); - const active = instructions !== null && instructions.includes(opencode.glob); + const missing = opencode.globs.filter((glob) => !instructions?.includes(glob)); + const stale = (instructions ?? []).filter((entry): entry is string => + typeof entry === 'string' && opencode.owns(entry) && !opencode.globs.includes(entry)); + const relativeStale = stale.filter((entry) => !path.isAbsolute(entry)); + const namespaceStale = stale.filter((entry) => path.isAbsolute(entry)); + const quoted = (entries: string[]): string => entries.map((entry) => `\`${entry}\``).join(', '); + const rerun = 'Run `teamai pull --force`: a plain pull skips a scope whose team repo has not changed, ' + + 'so it cannot restore this.'; checks.push({ name: 'Team rules are active in opencode', source: 'local', - check: async () => active, + check: async () => instructions !== null && missing.length === 0 && stale.length === 0, fix: instructions === null ? `${opencode.configFile} could not be read as a JSON object, so the pull left it alone ` - + `and never added \`${opencode.glob}\` to \`instructions\`. Fix the file, then run ` + + `and never added ${quoted(opencode.globs)} to \`instructions\`. Fix the file, then run ` + '`teamai pull --force`.' - : `${opencode.configFile} does not list \`${opencode.glob}\` under \`instructions\`. ` - + 'OpenCode does not scan a rules directory, so every team rule delivered there is ' - + 'inert until this glob references it. Run `teamai pull --force`: a plain pull skips ' - + 'a scope whose team repo has not changed, so it cannot restore this.', + : [ + ...(missing.length > 0 + ? [`${opencode.configFile} does not list ${quoted(missing)} under \`instructions\`. ` + + 'OpenCode does not scan a rules directory, so every team rule delivered there is ' + + 'inert until a glob references it.'] + : []), + ...(relativeStale.length > 0 + ? [`${opencode.configFile} still lists ${quoted(relativeStale)}, which teamai no longer writes. ` + + 'OpenCode resolves a relative entry from the session\'s working directory, so it loads ' + + 'that directory\'s rules instead of the team rules.'] + : []), + ...(namespaceStale.length > 0 + ? [`${opencode.configFile} still lists ${quoted(namespaceStale)}, for a namespace whose rules ` + + 'no longer reach this scope, so OpenCode loads whatever copy is left there.'] + : []), + rerun, + ].join(' '), }); } const { getHermesHome } = await import('./hermes-home.js'); const hermesHome = getHermesHome(); - if (!isAgentExcluded(localConfig, 'hermes') && await pathExists(hermesHome)) { + // SOUL.md is global and only a user-scope pull writes it; in a project, + // `ruleChannelNotes` says why Hermes gets no project rules (#946). + if (localConfig.scope === 'user' && !isAgentExcluded(localConfig, 'hermes') && await pathExists(hermesHome)) { const { getHermesSoulPath, readSoulRules } = await import('./hermes-config.js'); const expected = await inlinedRulesText(items); const delivered = await readSoulRules(); diff --git a/src/doctor.ts b/src/doctor.ts index b8b90dfab..b3eb05331 100644 --- a/src/doctor.ts +++ b/src/doctor.ts @@ -685,12 +685,14 @@ export async function doctor(options: DoctorOptions): Promise { // Info, not checks: which namespace item or entry replaces which root one // (#707), a model alias an agent uses from a namespace not active here, and // how each alias agent's model resolved in each tool (#830). + const { ruleChannelNotes } = await import('./resources/rules.js'); const notes = [ ...await buildNamespaceNotes(ctx), ...await entryNamespaceNotes(ctx), ...await aliasNamespaceNotes(ctx), ...await agentModelNotes(ctx), ...(await envAdvisories(localConfig, ctx.teamConfig, ctx.teamEnv)).map(describeEnvAdvisory), + ...await ruleChannelNotes(localConfig), ...codexTrust.notes, ]; diff --git a/src/init.ts b/src/init.ts index 045b95ff3..6975c9123 100644 --- a/src/init.ts +++ b/src/init.ts @@ -781,7 +781,8 @@ export async function initHttp( /** * Install the hooks for a fresh init. When the team hooks do not resolve, the * built-in hooks are still installed; say that the team hooks were not, so the - * success line that follows does not claim them. + * success line that follows does not claim them. Then name each installed tool + * that gets no rules in this scope, and why (#946). */ async function reconcileHooksForInit( teamConfig: TeamaiConfig, @@ -809,6 +810,10 @@ async function reconcileHooksForInit( } catch (e) { log.debug(`Team instruction check skipped: ${(e as Error).message}`); } + // A tool with no rules channel in this scope is told so here, not left to + // look delivered (#946). + const { ruleChannelNotes } = await import('./resources/rules.js'); + for (const note of await ruleChannelNotes(localConfig)) log.info(note); } /** diff --git a/src/resources/opencode-config.ts b/src/resources/opencode-config.ts index 3a8809908..318c5682e 100644 --- a/src/resources/opencode-config.ts +++ b/src/resources/opencode-config.ts @@ -7,23 +7,24 @@ import { log } from '../utils/logger.js'; // Unlike every other tool teamai targets, OpenCode does not auto-scan a rules // directory. Rule .md files copied into `.opencode/rules/` are inert until they // are referenced from the `instructions` array in `opencode.json`. This module -// maintains exactly one teamai-managed glob in that array by key-level surgery: -// it reads the JSON, adds or removes only our glob, and writes every other -// top-level key (including `mcp`, which the MCP reconcile engine owns) back -// untouched. It never rewrites the user's own `instructions` entries. +// maintains the teamai-managed globs in that array by key-level surgery: it +// reads the JSON, adds or removes only entries teamai owns, and writes every +// other top-level key (including `mcp`, which the MCP reconcile engine owns) +// back untouched. It never rewrites the user's own `instructions` entries. // // The same opencode.json is shared with the MCP `mcp` key, so both writers must // be surgical — a regenerate-from-scratch here would clobber injected servers. /** - * The glob OpenCode should use to load teamai-managed rules, expressed relative - * to the directory that holds opencode.json. + * The rules glob relative to the directory that holds opencode.json. * - * OpenCode resolves relative `instructions` paths against the config file's own - * directory. In project scope, opencode.json sits at the repo root and rules at - * `/.opencode/rules`, giving `.opencode/rules/*.md` (the same shape as the - * documented `.cursor/rules/*.md` example). In user scope, both live under - * `~/.config/opencode/`, giving a clean `rules/*.md`. + * OpenCode resolves a relative `instructions` entry from the session's working + * directory, not from the config file. In project scope opencode.json sits at + * the repo root and rules at `/.opencode/rules`, giving + * `.opencode/rules/*.md`, which resolves the same from the root. In user scope + * this gives `rules/*.md`, the entry earlier releases wrote and which loaded the + * project's `rules/` instead; `opencodeRuleGlobs` uses it only to reclaim that + * entry (#946). */ export function opencodeRulesGlob(configFileAbs: string, rulesDirAbs: string): string { const rel = path.relative(path.dirname(configFileAbs), rulesDirAbs); @@ -32,32 +33,74 @@ export function opencodeRulesGlob(configFileAbs: string, rulesDirAbs: string): s return `${relPosix}/*.md`; } +/** The `instructions` globs that load teamai's rules, and which entries teamai owns. */ +export interface OpencodeRuleGlobs { + /** The globs that should be listed while the team has rules here. */ + globs: string[]; + /** Whether an `instructions` entry is one teamai wrote for rules, now or in an earlier release. */ + owns: (entry: string) => boolean; +} + /** - * Ensure `opencode.json` references (or stops referencing) the teamai rules glob. + * The rules globs for one scope's opencode.json. + * + * Project scope keeps the one relative glob from the root opencode.json. + * + * In user scope a relative entry resolves from the session cwd, not from the + * config file, so the old `rules/*.md` loaded the project's `rules/` instead + * of the user rules (#946). The globs are absolute, and since OpenCode globs + * only the basename of an absolute entry (`**` never matches), each directory + * a rule lands in gets its own: the rules root plus one per namespace. + * teamai owns the root glob, the glob of each directory a team rule can land + * in, and the old relative one. A glob for any other directory is the + * member's own. + * + * @param ruleDirsAbs The directories the delivered rules land in. + * @param teamDirsAbs The directories any team rule can land in, delivered here or not. + */ +export function opencodeRuleGlobs( + scope: 'user' | 'project', + configFileAbs: string, + rulesDirAbs: string, + ruleDirsAbs: readonly string[], + teamDirsAbs: readonly string[], +): OpencodeRuleGlobs { + const relative = opencodeRulesGlob(configFileAbs, rulesDirAbs); + if (scope === 'project') return { globs: [relative], owns: (entry) => entry === relative }; + + const posix = (p: string): string => p.split(path.sep).join('/'); + const root = posix(rulesDirAbs); + const under = (dirs: readonly string[]): string[] => + [...new Set(dirs.map(posix))].filter((dir) => dir.startsWith(`${root}/`)).sort(); + const globs = [root, ...under(ruleDirsAbs)].map((dir) => `${dir}/*.md`); + const owned = new Set([relative, ...globs, ...under(teamDirsAbs).map((dir) => `${dir}/*.md`)]); + return { globs, owns: (entry) => owned.has(entry) }; +} + +/** + * Make the entries teamai owns in `instructions` exactly `desired`: add the + * missing ones at the end, remove the owned ones not desired, and leave every + * other entry where it is. * - * @param configFileAbs Absolute path to the opencode.json to edit. - * @param glob The instructions glob to add/remove (see opencodeRulesGlob). - * @param present true = the glob should be in `instructions`; false = removed. * @returns true if the file was written. * - * When `present` is true and the file does not exist, it is created with just the - * `instructions` array — teamai owns nothing else in it. When `present` is false - * and the file does not exist, nothing happens. A file that exists but cannot be - * parsed as a JSON object is left strictly alone (it may hold config we do not - * understand), and the function returns false. + * A missing file is created with just `desired`, or left missing when nothing + * is desired. A file that exists but cannot be parsed as a JSON object is left + * strictly alone (it may hold config we do not understand), and the function + * returns false. */ -export async function reconcileOpencodeInstructions( +export async function reconcileOpencodeInstructionSet( configFileAbs: string, - glob: string, - present: boolean, + desired: readonly string[], + owns: (entry: string) => boolean, purpose = 'rules activation', ): Promise { const exists = await pathExists(configFileAbs); if (!exists) { - if (!present) return false; - await writeJsonAtomic(configFileAbs, { instructions: [glob] }); - log.debug(`Created ${configFileAbs} with teamai rules instructions glob`); + if (desired.length === 0) return false; + await writeJsonAtomic(configFileAbs, { instructions: [...desired] }); + log.debug(`Created ${configFileAbs} with teamai ${purpose} entries`); return true; } @@ -86,14 +129,9 @@ export async function reconcileOpencodeInstructions( // (OpenCode may treat instruction order as precedence, so reordering the // user's entries on every pull would silently change their config.) const original = Array.isArray(data.instructions) ? [...(data.instructions as unknown[])] : []; - const has = original.includes(glob); - - if (present && has) return false; - if (!present && !has) return false; - - const next = present - ? [...original, glob] // append our glob without touching existing order - : original.filter((g) => g !== glob); // remove only our glob, everything else stays put + const kept = original.filter((entry) => typeof entry !== 'string' || !owns(entry) || desired.includes(entry)); + const next = [...kept, ...desired.filter((entry) => !kept.includes(entry))]; + if (next.length === original.length && next.every((entry, i) => entry === original[i])) return false; // Key-level surgery: drop `instructions` entirely when it would be empty, // otherwise write the reconciled array back. @@ -104,10 +142,33 @@ export async function reconcileOpencodeInstructions( } await writeJsonAtomic(configFileAbs, data); - log.debug(`${present ? 'Added' : 'Removed'} teamai ${purpose} entry in ${configFileAbs}`); + log.debug(`Reconciled teamai ${purpose} entries in ${configFileAbs}`); return true; } +/** + * Ensure `opencode.json` references (or stops referencing) one teamai entry. + * + * @param configFileAbs Absolute path to the opencode.json to edit. + * @param glob The instructions glob to add/remove (see opencodeRulesGlob). + * @param present true = the glob should be in `instructions`; false = removed. + * @returns true if the file was written. + * + * When `present` is true and the file does not exist, it is created with just the + * `instructions` array — teamai owns nothing else in it. When `present` is false + * and the file does not exist, nothing happens. A file that exists but cannot be + * parsed as a JSON object is left strictly alone (it may hold config we do not + * understand), and the function returns false. + */ +export async function reconcileOpencodeInstructions( + configFileAbs: string, + glob: string, + present: boolean, + purpose = 'rules activation', +): Promise { + return reconcileOpencodeInstructionSet(configFileAbs, present ? [glob] : [], (entry) => entry === glob, purpose); +} + // ─── OpenCode team instructions (#945) ─────────────────────── /** diff --git a/src/resources/rules.ts b/src/resources/rules.ts index 6bd5cd533..46baee815 100644 --- a/src/resources/rules.ts +++ b/src/resources/rules.ts @@ -8,6 +8,7 @@ import { TEAMAI_RULES_START, TEAMAI_RULES_END, TEAMAI_TEAM_RULES_START, TEAMAI_T import { EXCLUDED_RULE_NAMES, isDeployedRecallRule, TEAMAI_CONTEXT_RULE_NAME } from '../builtin-rules.js'; import { splitFrontmatter } from '../utils/frontmatter.js'; import { rulePaths } from './team-rule.js'; +import type { OpencodeRuleGlobs } from './opencode-config.js'; import { assertWithinRoot } from '../utils/path-safety.js'; import { loadStateForScope } from '../config.js'; import { placedResourcePath } from '../push-namespaces.js'; @@ -532,8 +533,10 @@ export class RulesHandler extends ResourceHandler { // Hermes: inline all team rules into a teamai-managed block in SOUL.md // (user-level standing instructions). Only when Hermes is actually - // installed — never create ~/.hermes for users who don't use it. - if (!isAgentExcluded(localConfig, 'hermes')) { + // installed — never create ~/.hermes for users who don't use it. SOUL.md + // is global, so only a user-scope pull writes it; Hermes gets no project + // rules (see `ruleChannelNotes`, #946). + if (localConfig.scope === 'user' && !isAgentExcluded(localConfig, 'hermes')) { const { getHermesHome } = await import('../hermes-home.js'); if (await pathExists(getHermesHome())) { const { upsertSoulRules } = await import('../hermes-config.js'); @@ -547,7 +550,7 @@ export class RulesHandler extends ResourceHandler { // until referenced from `instructions` in opencode.json. Activate (or, when // there are no team rules, deactivate) that glob. Runs before the empty-set // early return so removing the last rule also removes the glob. - await this.activateOpencodeInstructions(teamConfig, localConfig, rules.length > 0); + await this.activateOpencodeInstructions(teamConfig, localConfig, rules); // Empty set = no team rule reaches this directory right now. We deliberately do // NOT run the aggressive stale-file cleanup below in that case, because it would @@ -842,41 +845,44 @@ export class RulesHandler extends ResourceHandler { } /** - * Add or remove the teamai rules glob in OpenCode's opencode.json `instructions` - * array, so copied rule files are actually loaded. No-op for any tool other than - * opencode, when opencode is disabled, or when opencode is not installed (we - * never create an opencode.json for a user who doesn't use OpenCode). + * Make the teamai rules globs in OpenCode's opencode.json `instructions` + * match the rules delivered, so copied rule files are actually loaded, and + * remove them all when no rule is. No-op when opencode is disabled or not + * installed (we never create an opencode.json for a user who doesn't use + * OpenCode). */ private async activateOpencodeInstructions( teamConfig: TeamaiConfig, localConfig: LocalConfig, - present: boolean, + rules: readonly ResourceItem[], ): Promise { - const target = await this.opencodeInstructionsTarget(teamConfig, localConfig); + const target = await this.opencodeInstructionsTarget(teamConfig, localConfig, rules); if (target === null) return; - const { reconcileOpencodeInstructions } = await import('./opencode-config.js'); + const { reconcileOpencodeInstructionSet } = await import('./opencode-config.js'); try { - await reconcileOpencodeInstructions(target.configFile, target.glob, present); + await reconcileOpencodeInstructionSet(target.configFile, rules.length > 0 ? target.globs : [], target.owns); } catch (e) { log.warn(`Failed to update OpenCode instructions in ${target.configFile}: ${(e as Error).message}`); } } /** - * The opencode.json this scope activates rules through, and the one glob - * teamai owns inside it. Null when OpenCode receives no rules here: - * excluded, not installed, or configured without a rules or config path. + * The opencode.json this scope activates rules through, the globs that load + * `rules` from it, and which `instructions` entries teamai owns there. Null + * when OpenCode receives no rules here: excluded, not installed, or + * configured without a rules or config path. * * Read-only, and public for the same reason `deliveryTargets` is: OpenCode * does not auto-scan its rules directory, so a `.md` sitting there is inert - * until this glob references it. A check that derived the path a second time + * until a glob references it. A check that derived the path a second time * could look at a different file than the pull writes (#624). */ async opencodeInstructionsTarget( teamConfig: TeamaiConfig, localConfig: LocalConfig, - ): Promise<{ configFile: string; glob: string } | null> { + rules: readonly ResourceItem[], + ): Promise<({ configFile: string } & OpencodeRuleGlobs) | null> { if (isAgentExcluded(localConfig, 'opencode')) return null; const paths = scopedToolPaths(teamConfig, localConfig)['opencode']; if (!paths?.rules) return null; @@ -891,8 +897,20 @@ export class RulesHandler extends ResourceHandler { if (!configRel) return null; const configFile = path.join(baseDir, configRel); - const { opencodeRulesGlob } = await import('./opencode-config.js'); - return { configFile, glob: opencodeRulesGlob(configFile, path.join(baseDir, paths.rules)) }; + const rulesDir = path.join(baseDir, paths.rules); + // The directories pull writes the rules to, from the same seam it uses. + const ruleDirs: string[] = []; + for (const rule of rules) { + for (const { tool, dest } of await this.deliveryTargets(teamConfig, localConfig, rule)) { + if (tool === 'opencode') ruleDirs.push(path.dirname(dest)); + } + } + // Every directory a team rule can land in, so a namespace this member no + // longer receives still has its glob reclaimed. + const teamDirs = (await this.scanTeamForPull(teamConfig, localConfig)) + .map((rule) => path.dirname(path.join(rulesDir, `${rule.name}.md`))); + const { opencodeRuleGlobs } = await import('./opencode-config.js'); + return { configFile, ...opencodeRuleGlobs(localConfig.scope, configFile, rulesDir, ruleDirs, teamDirs) }; } /** @@ -1041,3 +1059,25 @@ export async function teamRulesContext(teamConfig: TeamaiConfig, localConfig: Lo const text = await inlinedRulesText(items); return text === '' ? null : `Team rules (from teamai):\n\n${text}`; } + +/** + * Why an installed tool gets no rules in this scope, for init and doctor to + * print as notes rather than failures (#946). Hermes reads its rules from the + * global SOUL.md, which only a user-scope pull writes. + */ +export async function ruleChannelNotes(localConfig: LocalConfig): Promise { + const notes: string[] = []; + if (localConfig.scope === 'project' && !isAgentExcluded(localConfig, 'hermes')) { + const { getHermesHome } = await import('../hermes-home.js'); + if (await pathExists(getHermesHome())) { + notes.push( + 'Hermes gets no project rules: it reads team rules only from the global SOUL.md, which a ' + + 'user-scope pull writes (`teamai init --scope user`). A project channel would cost more than ' + + 'it gives: .hermes.md would hide the project AGENTS.md, a pre_llm_call hook repeats the rules ' + + 'on every turn, and the plugin prompt section (at most 4,000 characters) already carries ' + + 'the team instructions.', + ); + } + } + return notes; +} diff --git a/src/uninstall.ts b/src/uninstall.ts index 8364a3e4b..b0938055c 100644 --- a/src/uninstall.ts +++ b/src/uninstall.ts @@ -115,6 +115,8 @@ interface RemovalPlan { ruleFiles: string[]; /** Copies in a tool's legacy rules directory the member edited: never removed, only named. */ keptRuleFiles: string[]; + /** The rules globs teamai owns in OpenCode's opencode.json `instructions` (#946). */ + opencodeOwnedGlobs: OpencodeRuleGlobEntries | null; /** Built-in agent .md files deployed by the CLI (e.g. teamai-recall). */ agentFiles: string[]; /** teamai-managed MCP servers from managed-mcp.json (`tool/server` or `tool:project/server`). */ @@ -178,9 +180,16 @@ interface ToolResources { skillDirs: SkillDirEntry[]; ruleFiles: string[]; keptRuleFiles: string[]; + opencodeOwnedGlobs: OpencodeRuleGlobEntries | null; agentFiles: string[]; } +/** The `instructions` entries teamai owns in one opencode.json. */ +interface OpencodeRuleGlobEntries { + configFile: string; + entries: string[]; +} + function hasToolResources(r: ToolResources): boolean { return ( r.hookFiles.length > 0 || @@ -194,6 +203,7 @@ function hasToolResources(r: ToolResources): boolean { r.opencodeInstructions.length > 0 || r.skillDirs.length > 0 || r.ruleFiles.length > 0 || + r.opencodeOwnedGlobs !== null || r.agentFiles.length > 0 ); } @@ -359,7 +369,7 @@ async function discoverToolResources( ): Promise { const res: ToolResources = { hookFiles: [], openclawHookDirs: [], opencodeHookScopes: [], ompHookFile: null, piHookFiles: [], dshHookFile: null, - claudeMdFiles: [], retiredInstructionFiles: [], opencodeInstructions: [], keptGlobal: [], skillDirs: [], ruleFiles: [], keptRuleFiles: [], agentFiles: [], + claudeMdFiles: [], retiredInstructionFiles: [], opencodeInstructions: [], keptGlobal: [], skillDirs: [], ruleFiles: [], keptRuleFiles: [], opencodeOwnedGlobs: null, agentFiles: [], }; // (a) Hooks — settings.json / hooks.json @@ -732,6 +742,18 @@ async function buildRemovalPlan( res.keptRuleFiles.push(...edited); } + // (d) continued: OpenCode loads its rules through globs in opencode.json, + // which would point at nothing once the copies go (#946). + const opencodeTarget = opencodeRes + ? await new RulesHandler().opencodeInstructionsTarget(teamConfig, localConfig, []) + : null; + if (opencodeRes && opencodeTarget) { + const { readOpencodeInstructionList } = await import('./resources/opencode-config.js'); + const entries = ((await readOpencodeInstructionList(opencodeTarget.configFile)) ?? []) + .filter((entry): entry is string => typeof entry === 'string' && opencodeTarget.owns(entry)); + if (entries.length > 0) opencodeRes.opencodeOwnedGlobs = { configFile: opencodeTarget.configFile, entries }; + } + // A tool only still "uses" a shared resource (AGENTS.md, .teamai/) if it is // actually enabled and installed. Several tools default to the same shared // path — e.g. Hermes/WorkBuddy default to the same project AGENTS.md as Pi — @@ -784,6 +806,7 @@ async function buildRemovalPlan( skillDirs: [], ruleFiles: [], keptRuleFiles: [], + opencodeOwnedGlobs: null, agentFiles: [], mcpServers: [], shellProfiles: [], @@ -851,6 +874,7 @@ async function buildRemovalPlan( plan.skillDirs.push(...res.skillDirs); plan.ruleFiles.push(...res.ruleFiles); plan.keptRuleFiles.push(...res.keptRuleFiles); + if (res.opencodeOwnedGlobs) plan.opencodeOwnedGlobs = res.opencodeOwnedGlobs; plan.agentFiles.push(...res.agentFiles); } @@ -956,6 +980,7 @@ function isPlanEmpty(plan: RemovalPlan): boolean { plan.opencodeInstructions.length === 0 && plan.skillDirs.length === 0 && plan.ruleFiles.length === 0 && + plan.opencodeOwnedGlobs === null && plan.agentFiles.length === 0 && plan.mcpServers.length === 0 && plan.shellProfiles.length === 0 && @@ -1052,6 +1077,11 @@ function printSummary(plan: RemovalPlan, agentFilter?: string): void { console.log(''); } + if (plan.opencodeOwnedGlobs) { + console.log(` OpenCode rules globs (${plan.opencodeOwnedGlobs.entries.length}) in ${plan.opencodeOwnedGlobs.configFile}`); + console.log(''); + } + if (plan.agentFiles.length > 0) { console.log(` Agents (${plan.agentFiles.length} files):`); for (const agentFile of plan.agentFiles) { @@ -1323,6 +1353,17 @@ async function executeRemoval(plan: RemovalPlan): Promise 0) { log.success(`Removed ${plan.ruleFiles.length} rule files`); } + if (plan.opencodeOwnedGlobs) { + const { configFile, entries } = plan.opencodeOwnedGlobs; + try { + const { reconcileOpencodeInstructionSet } = await import('./resources/opencode-config.js'); + if (await reconcileOpencodeInstructionSet(configFile, [], (entry) => entries.includes(entry))) { + log.success(`Removed ${entries.length} OpenCode rules globs from ${configFile}`); + } + } catch (e) { + log.warn(`Failed to remove the OpenCode rules globs from ${configFile}: ${(e as Error).message}`); + } + } // (d2) Remove built-in agent files (e.g. teamai-recall) for (const agentFile of plan.agentFiles) { @@ -1379,7 +1420,7 @@ async function executeRemoval(plan: RemovalPlan): Promise` uninstall never touches ~/.hermes. No-op safe. if (plan.hermesCleanup) { @@ -1387,7 +1428,8 @@ async function executeRemoval(plan: RemovalPlan): Promise Date: Fri, 2 Oct 2026 01:34:48 +0200 Subject: [PATCH 04/20] fix(opencode): load namespaced project rules from its own config (#946) --- CHANGELOG.md | 1 + docs/usage-guide.md | 4 +- docs/usage-guide.zh-CN.md | 4 +- skill-data/setup/references/uninstall.md | 6 +- src/__tests__/doctor-rules-delivery.test.ts | 20 ++++ src/__tests__/opencode-config.test.ts | 26 +++-- .../pull-rule-format-upgrade.test.ts | 81 +++++++++++++++ src/__tests__/rules.test.ts | 53 ++++++++++ src/__tests__/uninstall.test.ts | 75 ++++++++++++++ src/doctor-delivery.ts | 9 +- src/pull.ts | 8 ++ src/resources/opencode-config.ts | 99 +++++++++++++------ src/resources/rules.ts | 46 ++++++--- src/uninstall.ts | 41 +++++--- 14 files changed, 397 insertions(+), 76 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cba5babbc..32c6e5cdb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -50,6 +50,7 @@ All notable changes to this project will be documented in this file. See [standa - teamai's OpenClaw hook runs. Its handler read an `event` field OpenClaw never sends and subscribed to `session:start`, which OpenClaw does not emit; its `HOOK.md` name was not valid YAML; and OpenClaw loads a workspace hook only once `openclaw.json` enables its entry, which teamai never wrote. The handler now keys on the event's `type` and `action`, runs `session-start` on `command:new`, `command:reset`, `session:auto-reset` and `gateway:startup` and `prompt-submit` on `message:received`, and passes the event's workspace as the hook's `cwd`. init, pull and `hooks inject` add `hooks.internal.entries.teamai-status-report.enabled`, unless that would narrow OpenClaw's open-ended hook discovery or you switched it off, in which case they warn and name `openclaw hooks enable teamai-status-report` (earlier versions set `hooks.internal.enabled` when `OPENCLAW_STATE_DIR` was set, so those machines see this warning until the command is run); `hooks remove` and uninstall take the entry out. The workspace and config follow `OPENCLAW_PROFILE`, `OPENCLAW_CONFIG_PATH` and `OPENCLAW_WORKSPACE_DIR` as OpenClaw does, a server-pushed `SessionStart` or `UserPromptSubmit` agent hook subscribes to the events OpenClaw emits and gets its own entry (`teamai-agent-`) so the allowlist keeps it, and `teamai doctor` fails `OpenClaw hook enabled` while the entry is missing or off (for [#946](https://github.com/Tencent/teamai-cli/issues/946)). - A project-scope `teamai pull` no longer rewrites Hermes' global `SOUL.md` rules block, and a project with no rules no longer erases it: only a user-scope pull writes it, `doctor` checks it only in user scope, and a project-scope `uninstall` leaves it. Hermes gets no project rules; in a project `init` and `doctor` say why (for [#946](https://github.com/Tencent/teamai-cli/issues/946)). - OpenCode loads the user-scope team rules again. The user `opencode.json` listed `rules/*.md`, which OpenCode resolves from the session's working directory, so it loaded the project's `rules/` instead. Pull now lists the absolute `~/.config/opencode/rules/*.md` plus one glob per namespace directory a rule lands in, replaces the old relative glob, and drops a namespace's glob once its rules no longer reach you, keeping a glob you added for a directory of your own; `doctor` checks every glob and flags a stale one, and `uninstall` removes them (for [#946](https://github.com/Tencent/teamai-cli/issues/946)). +- OpenCode loads namespaced project rules. The root `opencode.json` listed `.opencode/rules/*.md`, which matched no rule in a namespace directory. Pull now lists `.opencode/rules/**/*.md` in `.opencode/opencode.json`, beside the team instructions entry, and removes the old glob from the root `opencode.json`, leaving its other keys. The first pull after upgrading moves the globs in both scopes even when the team repo has not moved; `doctor` checks `.opencode/opencode.json`, and `uninstall` removes the glob from both files, deleting a `.opencode/opencode.json` left empty (for [#946](https://github.com/Tencent/teamai-cli/issues/946)). - Team rules reach Codex, `codex-internal` and `tcodex`. Pull used to copy them to `.codex/rules/.md`, which Codex does not read, and `teamai doctor` reported them delivered. In user scope they now go into a team-rules block of the tool's own `AGENTS.md` (`~/.codex/AGENTS.md`), beside the culture, shared-instructions and recall blocks. In a project the session-start hook adds the project's rules and those blocks to each session, and to a spawned subagent through a new `SubagentStart` entry, with `additionalContextLimit: 0` so Codex keeps them whole; pull leaves the project `AGENTS.md`, which other tools read too, unchanged. Pull removes the `.md` copies earlier pulls left in the Codex rules directory at its recorded `toolRoots` location, including a publisher's bare local copy, keeping an edited one with a warning. Pull and uninstall keep a removed rule's legacy copy unless it matches its recorded delivery hash; a missing record proves nothing about local edits. A path-scoped rule is rendered without frontmatter after an `Applies to files matching: ` line, in Hermes' `SOUL.md` too. `teamai doctor` checks the user-scope block and, in a project, both hook entries' limit. The public Codex asks once to approve the changed teamai hooks (for [#938](https://github.com/Tencent/teamai-cli/issues/938)). - `teamai pull` names the skills it removes because they are no longer delivered here, in one line, instead of a `debug` line calling them excluded and a summary that says `No resources to sync`. Picking a role or project, or an admin adding `manifest/projects.yaml`, takes root skills away, since the root `skills/` is then the tag catalog; the line then says that `teamai tags subscribe ` brings one back. A namespace skill removed because its namespace is no longer active is named too. The usage guide and the multi-project design no longer say a member with no project still gets `common` or every root item (for [#911](https://github.com/Tencent/teamai-cli/issues/911)). - `teamai pull` keeps the `teamai tags subscribe ` recovery line when the skill directory it removes is byte-identical to an inactive namespace copy: that copy made the namespace cleanup phase remove the directory first, and the hint was lost, because pull inferred whether a root skill had left from which phase did the removing. The hint now follows the repo — it appears exactly when the team repo holds the removed skill at the root, the copy a tag delivers — so a namespace-only skill removed on deactivation is still named without the hint. The usage guide no longer says a member with no role gets no skills at all, in English or Chinese: root skills still arrive through a tag (review of [#917](https://github.com/Tencent/teamai-cli/pull/917)). diff --git a/docs/usage-guide.md b/docs/usage-guide.md index 780d9f45e..fb0cd49bf 100644 --- a/docs/usage-guide.md +++ b/docs/usage-guide.md @@ -2276,7 +2276,7 @@ Team hooks still come from the team's `hooks/hooks.yaml`: edit that source in th - **Scopes.** OpenCode's user config lives under `~/.config/opencode/` while its project config lives under `/.opencode/` — a different prefix from every other tool. teamai writes to the correct one per `--scope`, and only ever touches OpenCode files when OpenCode is actually installed for that scope (it never creates `~/.config/opencode/` for a non-user). Hooks are the one exception — they are always user-scoped, for the reason described below. - **Skills** land in `.opencode/skills/` (project) or `~/.config/opencode/skills/` (user). OpenCode also reads `.claude/skills` natively, but teamai writes the OpenCode path too so an OpenCode-only user still gets them. - **Subagents** are rendered into OpenCode's own `agents/*.md` format: frontmatter carries `description` + `mode: subagent` (plus `model` and any `tool_extras.opencode` fields such as `temperature`); the agent name comes from the filename. OpenCode does **not** read `.claude/agents`, so this native copy is required. -- **Rules** are copied into `.opencode/rules/` (or `~/.config/opencode/rules/`), but OpenCode does not auto-scan a rules directory — the files are inert until referenced. teamai therefore adds globs to the `instructions` array in `opencode.json` and removes them again when the team's last rule goes away, editing only that one key and leaving your own `instructions` entries untouched. In a project that is `.opencode/rules/*.md` in the root `opencode.json`. In user scope it is the absolute `~/.config/opencode/rules/*.md` plus one glob per namespace directory a rule lands in (`~/.config/opencode/rules//*.md`): OpenCode resolves a relative entry from the session's working directory, and globs only the file name of an absolute one, so `**` never matches. A pull replaces the relative `rules/*.md` an earlier release wrote, which loaded the project's `rules/` instead, and drops a team namespace's glob once its rules no longer reach you; a glob you added for a directory of your own stays. `uninstall` removes them. +- **Rules** are copied into `.opencode/rules/` (or `~/.config/opencode/rules/`), but OpenCode does not auto-scan a rules directory — the files are inert until referenced. teamai therefore adds globs to the `instructions` array in `opencode.json` and removes them again when the team's last rule goes away, editing only that one key and leaving your own `instructions` entries untouched. In a project that is `.opencode/rules/**/*.md` in `.opencode/opencode.json`, beside the team instructions entry; OpenCode globs a relative entry from the session's working directory and each parent up to the worktree, so it loads the namespaced rules from anywhere in the project. A pull removes the `.opencode/rules/*.md` an earlier release wrote to the root `opencode.json`, which loaded no namespaced rule, and leaves that file's other keys alone. In user scope it is the absolute `~/.config/opencode/rules/*.md` plus one glob per namespace directory a rule lands in (`~/.config/opencode/rules//*.md`): OpenCode resolves a relative entry from the session's working directory, and globs only the file name of an absolute one, so `**` never matches. A pull replaces the relative `rules/*.md` an earlier release wrote, which loaded the project's `rules/` instead, and drops a team namespace's glob once its rules no longer reach you; a glob you added for a directory of your own stays. OpenCode ignores `paths:`: it applies every rule it loads to every file. `uninstall` removes the globs, and deletes a `.opencode/opencode.json` left with nothing else in it. - **Hooks** are delivered as an OpenCode *plugin*, not a settings-file entry — OpenCode has no `hooks` array; it auto-loads JS/TS plugins from **both** `~/.config/opencode/plugin/` and `/.opencode/plugin/`. A plugin present in both dirs is loaded twice and would dispatch every event twice, so teamai keeps exactly one copy: `teamai-hooks.ts` in the user dir, which covers every project. Any project-scope copy left by an earlier layout is deleted on the next sync. This matches the other tools, whose `settings.json` hooks also live in HOME and gate on the `cwd` handed to `hook-dispatch`. The plugin subscribes to OpenCode's own events and shelling out to the same `teamai hook-dispatch` entry point every other tool uses. The event mapping mirrors the Claude built-in set: `session.created` → session-start, `session.idle` → stop, `chat.message` → prompt-submit, `tool.execute.after` → post-tool-use. The plugin forwards the same STDIN payload other agents send (`cwd`, `session_id`, `tool_name`, `tool_input`, `prompt`, and on post-tool-use the tool's output and status), and maps OpenCode's lowercase tool ids (`skill`, `todowrite`) back to the PascalCase matchers the handler registry expects. OpenCode cannot inject a hook's stdout back into the session, so hooks run purely for their side effects (status report / sync / update). Note that OpenCode *awaits* its named hooks (`chat.message`, `tool.execute.after`), so those dispatches briefly wait on the `teamai` subprocess before the agent continues; the errors are always swallowed so a hook can never fail the session. Server-pushed agent hooks (`teamai-agent-.ts`) install into the same user plugin dir. Upvote **adoption** runs for OpenCode from the recall log, not a transcript: the plugin's `shell.env` hook sets `TEAMAI_AGENT_SESSION_ID` in the bash tool's environment, so a `teamai recall` run there joins the session its hooks carry, and a `task` call links the subagent's child session to its parent, so a doc the parent opens after a subagent's recall is upvoted. The opt-in LLM-judge needs a transcript, which `session.idle` does not carry, so it does not run for OpenCode, and the "adopted team knowledge" summary is never shown, as hook stdout is discarded. - **MCP** servers live under the `mcp` key of the shared `opencode.json` (see the MCP section above). @@ -2378,7 +2378,7 @@ Besides the provider, clone, config and hook checks, `doctor` verifies what reac `Rules delivered to ` and `Agents delivered to ` do the same for the other two per-tool resources, and both ask the handler where an item lands rather than deriving a path: a rule's filename and content change per tool (`.md` verbatim, `.mdc` with derived `globs`/`alwaysApply`, `.instructions.md` with `applyTo`, Kiro's `.md` with `inclusion`/`fileMatchPattern`, Qoder's `.md` with `trigger`/`glob`), and an agent's destination comes from its render, with `targets:` deciding which tools are owed a copy at all. A delivered rule is compared with the bytes the handler renders for that tool, not merely read for the keys its tool needs: a `.mdc` whose `globs` no longer match the team rule's `paths:` applies to the wrong files while carrying a perfectly legal `alwaysApply`, and that reads here as `delivered from an older copy` — the same label as a body that drifted, because both landed successfully and are still wrong. An agent is compared with the bytes its render produces, so a copy left behind by an older spec — a plain pull skips a scope whose team repo has not changed, so it can sit there indefinitely — is reported as `delivered from an older spec` rather than passing as present. `Every team agent reaches a tool` names an agent that renders for no installed tool — usually a spec that does not parse, or a `targets:` list naming only tools you do not have. These two are `doctor`-only: they read every rule per tool and parse every agent, which would spend the budget the checks at the end of a pull run under. -Three tools do not read a rules directory, so a per-file check cannot speak for them and each gets one of its own. `Team rules are active in opencode` checks that `opencode.json` lists every glob the pull owns under `instructions`, and no stale one such as the relative `rules/*.md` an earlier release wrote: OpenCode does not auto-scan `.opencode/rules`, so without it every delivered `.md` is inert while the per-file check keeps passing. In user scope, `Team rules are inlined in Hermes SOUL.md` compares the teamai-managed block of `SOUL.md` with what the team rules inline to, since Hermes reads standing instructions from that one file rather than from a directory — a deleted block, or one left on an older rule set, is a tool reading the wrong rules with nothing on disk to show for it. In user scope, `Team rules are inlined in Codex AGENTS.md` compares the team-rules block of the tool's `AGENTS.md` with what the team rules inline to, and fails when an `AGENTS.override.md` beside it shadows the file. In a project, `Project rules and instructions reach whole through its session hooks` fails when the teamai `SessionStart` or `SubagentStart` entry in that tool's `hooks.json` is missing or does not set `additionalContextLimit: 0`, without which Codex keeps only the start and end of a large set. Codex has no `Rules delivered to ` check. +Three tools do not read a rules directory, so a per-file check cannot speak for them and each gets one of its own. `Team rules are active in opencode` checks that `opencode.json` (`.opencode/opencode.json` in a project) lists every glob the pull owns under `instructions`, and no stale one such as the relative `rules/*.md` an earlier release wrote: OpenCode does not auto-scan `.opencode/rules`, so without it every delivered `.md` is inert while the per-file check keeps passing. In user scope, `Team rules are inlined in Hermes SOUL.md` compares the teamai-managed block of `SOUL.md` with what the team rules inline to, since Hermes reads standing instructions from that one file rather than from a directory — a deleted block, or one left on an older rule set, is a tool reading the wrong rules with nothing on disk to show for it. In user scope, `Team rules are inlined in Codex AGENTS.md` compares the team-rules block of the tool's `AGENTS.md` with what the team rules inline to, and fails when an `AGENTS.override.md` beside it shadows the file. In a project, `Project rules and instructions reach whole through its session hooks` fails when the teamai `SessionStart` or `SubagentStart` entry in that tool's `hooks.json` is missing or does not set `additionalContextLimit: 0`, without which Codex keeps only the start and end of a large set. Codex has no `Rules delivered to ` check. `MCP servers delivered to ` compares each server the team's `mcp.yaml` resolves for that tool against the entry in the tool's own config, and names any the reconcile skipped with its reason. The comparison is the entry, not the name: reconciliation leaves an entry teamai does not own alone, so a server of your own under a team name holds the key while the team's definition never arrives, and a stale copy is just as undelivered. Both are reported as `not the team's definition`, and only `teamai pull --force` replaces an entry teamai did not write. An unresolved `${VAR}` is reported here with the variable's name, which is otherwise said once during a pull and never again. A declared secret with no value is not a failure: doctor prints it as a note (`notes` in `--json`) with the command that sets it, and the exit code stays as it would be without it; a note also says when an entry kept for it may hold an old value, and when a key is declared as a secret and also set in `env.yaml`. An `mcp.yaml` that does not parse is not a team without MCP: it is reported as `Team MCP servers can be read` with the parse error, since it injects nothing into any tool and every run after the first is silent about it. Team hooks and team model profiles that cannot be resolved (a file that does not parse, a name defined twice in one file, or one name in two active namespaces) fail `Team hooks can be resolved` and `Team model profiles can be resolved` with the reason pull logs once; `teamai status` points here when it counts them as 0. `Env variables injected in shell profile` no longer stops at finding the marker comment: it checks that `env/env.yaml` parses and declares its variables under the `variables:` key (a plain `KEY: value` mapping parses as none, while an explicit `variables: []` is a configuration with nothing to deliver and fails nothing), that each one reached `env.sh` with the value `env.yaml` declares, or your value for this team (one set with `--from-env` is not written there) — a key left over from an older value exports it to every shell and MCP server until the next pull, and the comparison reads `env.sh` back through the generator's own inverse, so a multiline value quoted across several lines is matched rather than called stale — and that this scope's injected block (the one sourcing its own `env.sh`, since a profile can also carry another scope's) would actually load it — an unquoted Windows path degrades to something a POSIX shell cannot read, so `source` never runs and nothing says so. `No stale env blocks left behind` is a separate check: which file `pull` prefers has changed over time (Windows Git Bash's login shell reads `.bash_profile`/`.bash_login`/`.profile`, never `.bashrc`), and a pull only ever adds a block, never migrates an old one away, so a dead block from an earlier install or platform change can sit in another candidate file indefinitely. It names every such file (checking `.zshrc`, `.bashrc`, `.bash_profile`, `.bash_login` and `.profile`, current and legacy spellings alike) and points at `teamai uninstall` to remove them — separately from delivery, so a working env block never reads as broken just because an old one is still lying around. diff --git a/docs/usage-guide.zh-CN.md b/docs/usage-guide.zh-CN.md index 49fbd3b04..2151ad3c8 100644 --- a/docs/usage-guide.zh-CN.md +++ b/docs/usage-guide.zh-CN.md @@ -2119,7 +2119,7 @@ GitHub Copilot CLI 已支持其官方自定义指令、Rules、Skills、自定 - **作用域。** OpenCode 的用户配置在 `~/.config/opencode/` 下,项目配置在 `/.opencode/` 下——前缀与其他所有工具都不同。teamai 会按 `--scope` 写入正确的位置,且仅在该作用域确实安装了 OpenCode 时才碰它的文件(绝不会为未使用 OpenCode 的用户创建 `~/.config/opencode/`)。Hooks 是唯一的例外——始终写在用户级,原因见下。 - **Skills** 落在 `.opencode/skills/`(项目)或 `~/.config/opencode/skills/`(用户)。OpenCode 也原生读取 `.claude/skills`,但 teamai 仍会写 OpenCode 路径,好让只用 OpenCode 的用户也能拿到。 - **Subagents** 会被渲染成 OpenCode 自己的 `agents/*.md` 格式:frontmatter 带 `description` + `mode: subagent`(以及 `model` 和 `tool_extras.opencode` 中的字段,如 `temperature`);agent 名取自文件名。OpenCode **不**读取 `.claude/agents`,因此这份原生副本是必需的。 -- **Rules** 会被复制到 `.opencode/rules/`(或 `~/.config/opencode/rules/`),但 OpenCode 不会自动扫描 rules 目录——文件在被引用前是惰性的。因此 teamai 会往 `opencode.json` 的 `instructions` 数组里加入 glob,并在团队最后一条 rule 消失时再把它们移除,且只编辑这一个键、不动你自己的 `instructions` 条目。在项目中是根目录 `opencode.json` 里的 `.opencode/rules/*.md`。user scope 下是绝对路径 `~/.config/opencode/rules/*.md`,外加 rule 所落入的每个 namespace 目录各一条(`~/.config/opencode/rules//*.md`):OpenCode 从会话的工作目录解析相对条目,对绝对条目只对文件名做 glob,因此 `**` 永远不会匹配。pull 会替换旧版本写入的相对 `rules/*.md`(它加载的是项目的 `rules/`),并在某个团队 namespace 的 rule 不再送达你时移除它的 glob;你为自己的目录添加的 glob 会保留。`uninstall` 会移除这些 glob。 +- **Rules** 会被复制到 `.opencode/rules/`(或 `~/.config/opencode/rules/`),但 OpenCode 不会自动扫描 rules 目录——文件在被引用前是惰性的。因此 teamai 会往 `opencode.json` 的 `instructions` 数组里加入 glob,并在团队最后一条 rule 消失时再把它们移除,且只编辑这一个键、不动你自己的 `instructions` 条目。在项目中是 `.opencode/opencode.json` 里的 `.opencode/rules/**/*.md`,与团队 instructions 条目并列;OpenCode 会从会话的工作目录及其直到 worktree 的每一级父目录对相对条目做 glob,因此在项目任意位置都能加载 namespace 下的 rule。pull 会移除旧版本写入根目录 `opencode.json` 的 `.opencode/rules/*.md`(它加载不到任何 namespace 下的 rule),且不动该文件的其他键。user scope 下是绝对路径 `~/.config/opencode/rules/*.md`,外加 rule 所落入的每个 namespace 目录各一条(`~/.config/opencode/rules//*.md`):OpenCode 从会话的工作目录解析相对条目,对绝对条目只对文件名做 glob,因此 `**` 永远不会匹配。pull 会替换旧版本写入的相对 `rules/*.md`(它加载的是项目的 `rules/`),并在某个团队 namespace 的 rule 不再送达你时移除它的 glob;你为自己的目录添加的 glob 会保留。OpenCode 会忽略 `paths:`:它加载的每条 rule 都对所有文件生效。`uninstall` 会移除这些 glob,并删除除此之外已无其他内容的 `.opencode/opencode.json`。 - **Hooks** 以 OpenCode *plugin* 形式交付,而非配置文件条目——OpenCode 没有 `hooks` 数组,它会**同时**加载 `~/.config/opencode/plugin/` 和 `/.opencode/plugin/` 下的 JS/TS 插件。两个目录都有插件时会被加载两次,每个事件也就派发两次,因此 teamai 只保留一份:写在用户目录的 `teamai-hooks.ts`,覆盖所有项目;早期布局残留的项目级副本会在下次同步时被删除。这与其他工具一致——它们的 `settings.json` hooks 同样放在 HOME,靠传给 `hook-dispatch` 的 `cwd` 做作用域判断。插件订阅 OpenCode 自己的事件,并 shell 到其他所有工具共用的 `teamai hook-dispatch` 入口。事件映射对齐 Claude 内置集合:`session.created` → session-start、`session.idle` → stop、`chat.message` → prompt-submit、`tool.execute.after` → post-tool-use。插件会转发与其他工具一致的 STDIN 负载(`cwd`、`session_id`、`tool_name`、`tool_input`、`prompt`,post-tool-use 时还有工具输出和状态),并把 OpenCode 的小写工具 id(`skill`、`todowrite`)映射回 handler 注册表期望的 PascalCase matcher。OpenCode 无法把 hook 的 stdout 回注到会话,因此 hooks 只为副作用运行(状态上报 / 同步 / 更新)。注意 OpenCode 会 **await** 它的具名 hook(`chat.message`、`tool.execute.after`),所以这两个事件的派发会短暂等待 `teamai` 子进程后 agent 才继续;错误始终被吞掉,hook 永远不会让会话失败。服务端下发的 agent hook(`teamai-agent-.ts`)同样装在这个用户级 plugin 目录下。upvote **采纳(adoption)**在 OpenCode 上基于 recall 日志运行,不依赖 transcript:插件的 `shell.env` hook 会在 bash 工具的环境中设置 `TEAMAI_AGENT_SESSION_ID`,因此在其中运行的 `teamai recall` 会归入其 hooks 携带的同一会话;`task` 调用会把子代理的子会话关联到父会话,因此子代理 recall 之后父会话打开的文档会被 upvote。可选的 LLM-judge 需要 transcript,而 `session.idle` 不携带,所以它在 OpenCode 上不运行;hook 的 stdout 会被丢弃,因此"本次会话采纳的团队知识"摘要也不会显示。 - **MCP** server 位于共享 `opencode.json` 的 `mcp` 键下(详见上文 MCP 章节)。 @@ -2221,7 +2221,7 @@ teamai remove rules --force # 跳过确认,用于脚本和 CI `Rules delivered to ` 与 `Agents delivered to ` 对另外两类按工具下发的资源做同样的事,并且都向 handler 询问落点,而不是自行拼路径:rule 的文件名和内容因工具而异(`.md` 原样、`.mdc` 带派生的 `globs`/`alwaysApply`、`.instructions.md` 带 `applyTo`、Kiro 的 `.md` 带 `inclusion`/`fileMatchPattern`、Qoder 的 `.md` 带 `trigger`/`glob`),agent 的落点来自渲染结果,且由 `targets:` 决定哪些工具应当收到。已送达的 rule 会与 handler 为该工具渲染出的字节逐一比对,而不只是检查该工具所需的键是否存在:`globs` 与团队 rule 的 `paths:` 不再一致的 `.mdc`,即使 `alwaysApply` 取值合法,也会作用到错误的文件上;这里会报告为 `delivered from an older copy`——正文漂移的副本同样如此,因为两者都写入成功,却都是错的。agent 会与渲染结果逐字节比对:旧版 spec 留下的副本(普通 pull 会跳过团队仓库未变化的 scope,它可能一直留在那里)报告为 `delivered from an older spec`,而不是当作已送达。`Every team agent reaches a tool` 会指出在任何已安装工具上都无法渲染的 agent,通常是 spec 解析失败,或 `targets:` 只列了本机没有的工具。这两项仅在 `doctor` 中运行:它们会按工具读取每条 rule、解析每个 agent,放进 pull 结束时的检查会耗尽其时间预算。 -有三个工具并不读取 rules 目录,按文件比对的检查无法代表它们,因此各自单列一项。`Team rules are active in opencode` 检查 `opencode.json` 的 `instructions` 中是否列着 teamai 所拥有的每条 glob,且没有过时的条目(如旧版本写入的相对 `rules/*.md`):OpenCode 不会自动扫描 `.opencode/rules`,缺了它,已送达的每个 `.md` 都不会生效,而按文件比对的检查依旧通过。user scope 下,`Team rules are inlined in Hermes SOUL.md` 把 `SOUL.md` 中 teamai 管理的代码块与团队 rule 内联后的内容比对——Hermes 的常驻指令来自这一个文件而非某个目录,因此代码块被删除或停留在旧版规则集上,都意味着该工具读到的是错误的规则,而磁盘上看不出任何异常。user scope 下,`Team rules are inlined in Codex AGENTS.md` 把该工具 `AGENTS.md` 中的 team-rules 区块与团队 rule 内联后的内容比对,旁边有 `AGENTS.override.md` 遮蔽该文件时也会失败。在项目中,当该工具 `hooks.json` 中的 teamai `SessionStart` 或 `SubagentStart` 条目缺失或没有设置 `additionalContextLimit: 0` 时,`Project rules and instructions reach whole through its session hooks` 会失败:没有它,Codex 对较大的内容只保留开头和结尾。Codex 没有 `Rules delivered to ` 检查。 +有三个工具并不读取 rules 目录,按文件比对的检查无法代表它们,因此各自单列一项。`Team rules are active in opencode` 检查 `opencode.json`(项目中为 `.opencode/opencode.json`)的 `instructions` 中是否列着 teamai 所拥有的每条 glob,且没有过时的条目(如旧版本写入的相对 `rules/*.md`):OpenCode 不会自动扫描 `.opencode/rules`,缺了它,已送达的每个 `.md` 都不会生效,而按文件比对的检查依旧通过。user scope 下,`Team rules are inlined in Hermes SOUL.md` 把 `SOUL.md` 中 teamai 管理的代码块与团队 rule 内联后的内容比对——Hermes 的常驻指令来自这一个文件而非某个目录,因此代码块被删除或停留在旧版规则集上,都意味着该工具读到的是错误的规则,而磁盘上看不出任何异常。user scope 下,`Team rules are inlined in Codex AGENTS.md` 把该工具 `AGENTS.md` 中的 team-rules 区块与团队 rule 内联后的内容比对,旁边有 `AGENTS.override.md` 遮蔽该文件时也会失败。在项目中,当该工具 `hooks.json` 中的 teamai `SessionStart` 或 `SubagentStart` 条目缺失或没有设置 `additionalContextLimit: 0` 时,`Project rules and instructions reach whole through its session hooks` 会失败:没有它,Codex 对较大的内容只保留开头和结尾。Codex 没有 `Rules delivered to ` 检查。 `MCP servers delivered to ` 将团队 `mcp.yaml` 为该工具解析出的每个 server 与该工具自己配置文件中的条目逐一比对,并列出 reconcile 跳过的 server 及原因。比对的是条目内容而非名字:reconcile 不会覆盖不属于 teamai 的条目,因此你自己写的同名 server 会占住这个名字,团队的定义从未真正送达;过期的旧副本同样等于没送达。两者都报告为 `not the team's definition`,而覆盖非 teamai 写入的条目只有 `teamai pull --force` 能做到。未解析的 `${VAR}` 会在这里连同变量名一起报告——否则它只在 pull 时出现一次,之后再无提示。没有值的已声明密钥不算失败:doctor 把它作为备注打印(`--json` 中的 `notes`),并附上设置它的命令,退出码与没有它时相同;备注还会说明为它保留的条目可能含有旧值,以及某个 key 既声明为密钥、又在 `env.yaml` 中设置的情况。无法解析的 `mcp.yaml` 并不等于团队没有 MCP:它会作为 `Team MCP servers can be read` 连同解析错误一起报告,因为这种文件不会向任何工具注入内容,而且除第一次之外的每次运行都对此保持沉默。无法解析的团队 hooks 与团队模型配置(文件无法解析、同一文件内重复的名字,或两个活动 namespace 中的同名条目)会让 `Team hooks can be resolved` 与 `Team model profiles can be resolved` 失败,并给出 pull 只记录一次的原因;`teamai status` 把它们计为 0 时会指向这里。`Env variables injected in shell profile` 不再只查标记注释:它会检查 `env/env.yaml` 能否解析、以及是否在 `variables:` 键下声明了变量(写成普通的 `KEY: value` 映射等于没有声明;而显式写成 `variables: []` 属于没有内容要下发的配置,不会判为失败)、每个变量是否以 `env.yaml` 声明的值(或你为该团队设置的值;用 `--from-env` 设置的不会写入)写进了 `env.sh`(残留的旧值会一直被导出到每个 shell 和 MCP server,直到下次 pull;比对时会用生成器自身的逆运算读回 `env.sh`,因此跨多行引用的多行值能够正确匹配,而不会被误判为过期),以及本作用域注入的代码块(即 source 本作用域 `env.sh` 的那一块,因为同一个 profile 里还可能有其他作用域的代码块)是否真的能加载它——未加引号的 Windows 路径在 POSIX shell 中会被转义破坏,`source` 从不执行,而且没有任何提示。`No stale env blocks left behind` 是独立的一项检查:pull 优先选用哪个文件会随时间变化(Windows 上 Git Bash 的登录 shell 读取的是 `.bash_profile`/`.bash_login`/`.profile`,从不读取 `.bashrc`),而 pull 只会新增代码块,从不迁移旧的,因此早期安装或平台变化留下的失效代码块可能一直留在另一个候选文件里。它会列出每一个这样的文件(检查 `.zshrc`、`.bashrc`、`.bash_profile`、`.bash_login` 和 `.profile`,新旧写法都算),并指向 `teamai uninstall` 来清除它们——这与投递检查分开进行,因此不会因为还留着一个旧副本,就让一个正常工作的 env 代码块被判成故障。 diff --git a/skill-data/setup/references/uninstall.md b/skill-data/setup/references/uninstall.md index 066031368..8bcf17306 100644 --- a/skill-data/setup/references/uninstall.md +++ b/skill-data/setup/references/uninstall.md @@ -61,7 +61,11 @@ and give it your team repo URL."* - For OpenCode, uninstall also removes the rules globs teamai added to `instructions` in `opencode.json`, including the relative `rules/*.md` an - earlier release wrote in user scope. The user's own entries stay. + earlier release wrote in user scope. In a project it removes + `.opencode/rules/**/*.md` from `.opencode/opencode.json` and the + `.opencode/rules/*.md` an earlier release wrote to the root `opencode.json`, + and deletes `.opencode/opencode.json` when nothing else is left in it. + The user's own entries stay. - Uninstall cleans legacy Codex rule copies at the recorded `toolRoots` location, including publishers' bare local filenames. It keeps edited copies. For a rule the team has diff --git a/src/__tests__/doctor-rules-delivery.test.ts b/src/__tests__/doctor-rules-delivery.test.ts index 140a5f57d..4245c9f9e 100644 --- a/src/__tests__/doctor-rules-delivery.test.ts +++ b/src/__tests__/doctor-rules-delivery.test.ts @@ -321,6 +321,26 @@ describe('doctor — rules delivered on disk', () => { const active = await namedCheck('Team rules are active in opencode'); expect(await active!.check()).toBe(false); expect(active!.fix).toContain('could not be read'); + expect(active!.fix).toContain('Fix the file, then run `teamai pull`.'); + }); + + it('checks .opencode/opencode.json in a project, not the root opencode.json (#946)', async () => { + await installOpencode(); + const projectRoot = path.join(tempDir, 'project'); + await fse.ensureDir(path.join(projectRoot, '.opencode', 'rules')); + Object.assign(localConfig, { scope: 'project', projectRoot }); + // Only the root file lists a glob, the one earlier releases wrote. + await fse.writeJson(path.join(projectRoot, 'opencode.json'), { instructions: ['.opencode/rules/*.md'] }); + + const active = await namedCheck('Team rules are active in opencode'); + expect(await active!.check()).toBe(false); + // The file is missing, not broken: a plain pull writes it. + expect(active!.fix).toContain(`${path.join(projectRoot, '.opencode', 'opencode.json')} does not list \`.opencode/rules/**/*.md\``); + expect(active!.fix).not.toContain('could not be read'); + expect(active!.fix).toContain('Run `teamai pull`.'); + + await fse.writeJson(path.join(projectRoot, '.opencode', 'opencode.json'), { instructions: ['.opencode/rules/**/*.md'] }); + expect(await (await namedCheck('Team rules are active in opencode'))!.check()).toBe(true); }); it('emits no opencode activation check while opencode is not installed here', async () => { diff --git a/src/__tests__/opencode-config.test.ts b/src/__tests__/opencode-config.test.ts index ab44bf23d..f162f319e 100644 --- a/src/__tests__/opencode-config.test.ts +++ b/src/__tests__/opencode-config.test.ts @@ -7,7 +7,7 @@ vi.mock('../utils/logger.js', () => ({ log: { info: vi.fn(), success: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn(), dim: vi.fn() }, })); -import { reconcileOpencodeInstructions, reconcileOpencodeInstructionSet, opencodeRuleGlobs, opencodeRulesGlob } from '../resources/opencode-config.js'; +import { reconcileOpencodeInstructions, reconcileOpencodeInstructionSet, opencodeProjectRuleGlobs, opencodeRuleGlobs, opencodeRulesGlob } from '../resources/opencode-config.js'; describe('opencodeRulesGlob', () => { it('project scope: config at root, rules under .opencode/rules', () => { @@ -26,23 +26,35 @@ describe('opencodeRuleGlobs (#946)', () => { const rules = '/home/u/.config/opencode/rules'; it('user scope: the absolute root glob plus one per namespace directory, root first', () => { - const { globs } = opencodeRuleGlobs('user', config, rules, [`${rules}/fe`, rules, `${rules}/be/api`, `${rules}/fe`], []); + const { globs } = opencodeRuleGlobs(config, rules, [`${rules}/fe`, rules, `${rules}/be/api`, `${rules}/fe`], []); expect(globs).toEqual([`${rules}/*.md`, `${rules}/be/api/*.md`, `${rules}/fe/*.md`]); }); it('user scope: owns its globs, every team namespace\'s and the old relative one, not the member\'s', () => { - const { owns } = opencodeRuleGlobs('user', config, rules, [], [`${rules}/fe`]); + const { owns } = opencodeRuleGlobs(config, rules, [], [`${rules}/fe`]); expect(owns(`${rules}/*.md`)).toBe(true); expect(owns(`${rules}/fe/*.md`)).toBe(true); expect(owns('rules/*.md')).toBe(true); expect(owns(`${rules}/mine/*.md`)).toBe(false); expect(owns('CONVENTIONS.md')).toBe(false); }); +}); - it('project scope: the one relative glob', () => { - const { globs, owns } = opencodeRuleGlobs('project', '/repo/opencode.json', '/repo/.opencode/rules', ['/repo/.opencode/rules/fe'], []); - expect(globs).toEqual(['.opencode/rules/*.md']); - expect(owns('.opencode/rules/*.md')).toBe(true); +describe('opencodeProjectRuleGlobs (#946)', () => { + it('one recursive glob from the project root, in .opencode/opencode.json', () => { + const { configFile, globs, owns } = opencodeProjectRuleGlobs('/repo', '/repo/.opencode/rules', '/repo/opencode.json'); + expect(configFile).toBe(path.join('/repo', '.opencode', 'opencode.json')); + expect(globs).toEqual(['.opencode/rules/**/*.md']); + expect(owns('.opencode/rules/**/*.md')).toBe(true); + expect(owns('.opencode/rules/*.md')).toBe(false); + }); + + it('retires the glob earlier releases wrote to the root opencode.json', () => { + const { retired } = opencodeProjectRuleGlobs('/repo', '/repo/.opencode/rules', '/repo/opencode.json'); + expect(retired?.configFile).toBe('/repo/opencode.json'); + expect(retired?.owns('.opencode/rules/*.md')).toBe(true); + expect(retired?.owns('.opencode/rules/**/*.md')).toBe(false); + expect(opencodeProjectRuleGlobs('/repo', '/repo/.opencode/rules', null).retired).toBeNull(); }); }); diff --git a/src/__tests__/pull-rule-format-upgrade.test.ts b/src/__tests__/pull-rule-format-upgrade.test.ts index 39a14496c..151532600 100644 --- a/src/__tests__/pull-rule-format-upgrade.test.ts +++ b/src/__tests__/pull-rule-format-upgrade.test.ts @@ -130,3 +130,84 @@ describe('a pull at an unchanged team revision after the rule formats change (#9 expect(warnings.filter((message) => message.includes(`Kept ${qoderCopy()}`))).toHaveLength(1); }); }); + +/** + * A CLI upgrade that moves OpenCode's rules globs must reach a machine whose + * team revision has not moved, or OpenCode keeps loading through the old + * entry until the team next changes (#946). + */ +describe('a pull at an unchanged team revision after the OpenCode rules globs move (#946)', () => { + let tmpDir: string; + let homeDir: string; + let projectRoot: string; + let saved: State; + + beforeEach(async () => { + tmpDir = await fse.mkdtemp(path.join(os.tmpdir(), 'teamai-oc-glob-upgrade-')); + homeDir = path.join(tmpDir, 'home'); + projectRoot = path.join(tmpDir, 'project'); + await fse.ensureDir(path.join(homeDir, '.config', 'opencode')); + await fse.ensureDir(path.join(projectRoot, '.opencode')); + vi.stubEnv('HOME', homeDir); + saved = {} as State; + vi.mocked(saveStateForScope).mockImplementation(async (state) => { + saved = structuredClone(state); + }); + vi.mocked(loadStateForScope).mockImplementation(async () => structuredClone(saved) as never); + vi.mocked(loadTeamConfig).mockResolvedValue( + TeamaiConfigSchema.parse({ team: 'test', repo: 'https://example.invalid/x/team.git' }), + ); + }); + + afterEach(async () => { + vi.mocked(saveStateForScope).mockReset(); + vi.mocked(loadStateForScope).mockImplementation(async () => ({}) as never); + vi.unstubAllEnvs(); + await fse.remove(tmpDir); + }); + + /** Pull once at revision abc1234, then put back what an older CLI left there. */ + async function pullThenDowngrade(scope: 'user' | 'project', configFile: string, old: unknown): Promise { + const repoPath = path.join(tmpDir, 'team-repo'); + await fse.outputFile(path.join(repoPath, 'rules', 'team-rule.md'), 'Use named exports.\n'); + vi.mocked(loadLocalConfigForScope).mockResolvedValue({ + repo: { localPath: repoPath, remote: 'https://example.invalid/x/team.git' }, + username: 'u', + updatePolicy: 'auto', + additionalRoles: [], + scope, + projectRoot: scope === 'project' ? projectRoot : undefined, + enabledAgents: ['opencode'], + } as LocalConfig); + await pull({}); + await fse.outputJson(configFile, old); + vi.mocked(log.success).mockClear(); + } + + const alreadySynced = (): boolean => vi.mocked(log.success).mock.calls + .some(([message]) => String(message).includes('Already synced at abc1234')); + + it('user scope: replaces the old relative glob with the absolute one', async () => { + const config = path.join(homeDir, '.config', 'opencode', 'opencode.json'); + await pullThenDowngrade('user', config, { model: 'mine', instructions: ['rules/*.md'] }); + + await pull({}); + + expect(alreadySynced()).toBe(true); + const rules = path.join(homeDir, '.config', 'opencode', 'rules'); + expect(await fse.readJson(config)).toEqual({ model: 'mine', instructions: [`${rules}/*.md`] }); + }); + + it('project scope: moves the glob from the root opencode.json to .opencode/opencode.json', async () => { + const root = path.join(projectRoot, 'opencode.json'); + const dot = path.join(projectRoot, '.opencode', 'opencode.json'); + await pullThenDowngrade('project', root, { theme: 'dark', instructions: ['.opencode/rules/*.md'] }); + await fse.outputJson(dot, {}); + + await pull({}); + + expect(alreadySynced()).toBe(true); + expect(await fse.readJson(dot)).toEqual({ instructions: ['.opencode/rules/**/*.md'] }); + expect(await fse.readJson(root)).toEqual({ theme: 'dark' }); + }); +}); diff --git a/src/__tests__/rules.test.ts b/src/__tests__/rules.test.ts index a005bbf7b..765376ff1 100644 --- a/src/__tests__/rules.test.ts +++ b/src/__tests__/rules.test.ts @@ -1136,6 +1136,59 @@ describe('RulesHandler.pullAllRules — OpenCode instructions activation', () => await handler.pullAllRules(teamConfig, localConfig); expect(await fse.pathExists(ocConfig())).toBe(false); }); + + describe('project scope (#946)', () => { + let projectRoot: string; + let projectConfig: LocalConfig; + const rootConfig = () => path.join(projectRoot, 'opencode.json'); + const dotConfig = () => path.join(projectRoot, '.opencode', 'opencode.json'); + + beforeEach(async () => { + projectRoot = path.join(tmpDir, 'project'); + await fse.ensureDir(path.join(projectRoot, '.opencode')); + projectConfig = { ...localConfig, scope: 'project', projectRoot } as LocalConfig; + const teamRules = path.join(localConfig.repo.localPath, 'rules'); + await fse.writeFile(path.join(teamRules, 'root-rule.md'), 'root'); + await fse.ensureDir(path.join(teamRules, 'fe')); + await fse.writeFile(path.join(teamRules, 'fe', 'style.md'), 'fe style'); + }); + + // A relative entry resolves from the session cwd up to the worktree, so + // one recursive glob from the project root loads the namespaced rules too. + it('registers .opencode/rules/**/*.md in .opencode/opencode.json, not in the root opencode.json', async () => { + await handler.pullAllRules(teamConfig, projectConfig); + + expect(await fse.pathExists(path.join(projectRoot, '.opencode', 'rules', 'fe', 'style.md'))).toBe(true); + expect(await fse.readJson(dotConfig())).toEqual({ instructions: ['.opencode/rules/**/*.md'] }); + expect(await fse.pathExists(rootConfig())).toBe(false); + }); + + it('reclaims the glob an earlier release wrote to the root opencode.json, leaving its other keys', async () => { + await fse.writeJson(rootConfig(), { mcp: { x: { type: 'local' } }, instructions: ['docs/style.md', '.opencode/rules/*.md'] }); + + await handler.pullAllRules(teamConfig, projectConfig); + + expect(await fse.readJson(rootConfig())).toEqual({ mcp: { x: { type: 'local' } }, instructions: ['docs/style.md'] }); + expect((await fse.readJson(dotConfig())).instructions).toEqual(['.opencode/rules/**/*.md']); + }); + + it('keeps the root glob while .opencode/opencode.json cannot be parsed, so the rules stay registered', async () => { + await fse.writeJson(rootConfig(), { instructions: ['.opencode/rules/*.md'] }); + await fse.writeFile(dotConfig(), '{ // a comment\n}\n'); + + await handler.pullAllRules(teamConfig, projectConfig); + + expect(await fse.readFile(dotConfig(), 'utf8')).toBe('{ // a comment\n}\n'); + expect(await fse.readJson(rootConfig())).toEqual({ instructions: ['.opencode/rules/*.md'] }); + }); + + it('removes the glob from .opencode/opencode.json when the team has no rules left', async () => { + await handler.pullAllRules(teamConfig, projectConfig); + await handler.pullAllRules(teamConfig, projectConfig, []); + + expect((await fse.readJson(dotConfig())).instructions).toBeUndefined(); + }); + }); }); describe('RulesHandler — Cursor-compatible .mdc handling', () => { diff --git a/src/__tests__/uninstall.test.ts b/src/__tests__/uninstall.test.ts index 42ab0896f..c030bd4c7 100644 --- a/src/__tests__/uninstall.test.ts +++ b/src/__tests__/uninstall.test.ts @@ -1978,6 +1978,81 @@ describe('uninstall', () => { expect(await fse.readJson(configFile)).toEqual({ model: 'mine', instructions: ['CONVENTIONS.md', `${rulesDir}/mine/*.md`] }); }); + it('removes the project rules glob from .opencode/opencode.json and the old one from the root opencode.json (#946)', async () => { + const homeDir = path.join(tmpDir, 'oc-home'); + const repoPath = path.join(tmpDir, 'oc-team-repo'); + const projectRoot = path.join(tmpDir, 'oc-project'); + await fse.ensureDir(path.join(repoPath, 'rules')); + await fse.writeFile(path.join(repoPath, 'rules', 'team-rule.md'), '# Team Rule'); + await fse.ensureDir(path.join(projectRoot, '.opencode', 'rules')); + await fse.writeFile(path.join(projectRoot, '.opencode', 'rules', 'team-rule.md'), '# Team Rule'); + const dotConfig = path.join(projectRoot, '.opencode', 'opencode.json'); + const rootConfig = path.join(projectRoot, 'opencode.json'); + await fse.writeJson(dotConfig, { instructions: ['docs/style.md', '.opencode/rules/**/*.md'] }); + await fse.writeJson(rootConfig, { theme: 'dark', instructions: ['.opencode/rules/*.md'] }); + + vi.stubEnv('HOME', homeDir); + vi.stubEnv('SHELL', '/bin/zsh'); + const teamConfig = makeTeamConfig({ + toolPaths: { + opencode: { + skills: '.opencode/skills', rules: '.opencode/rules', + mcp: '.config/opencode/opencode.json', mcpProject: 'opencode.json', + userScope: { skills: '.config/opencode/skills', rules: '.config/opencode/rules' }, + }, + }, + }); + const localConfig = makeLocalConfig(homeDir, repoPath, { + scope: 'project', + projectRoot, + repo: { localPath: repoPath, remote: '', kind: 'self', businessRepoRoot: projectRoot }, + }); + mockAutoDetectInit.mockResolvedValue({ localConfig, teamConfig }); + + await uninstall({ force: true, agent: 'opencode' }); + + expect(await fse.readJson(dotConfig)).toEqual({ instructions: ['docs/style.md'] }); + expect(await fse.readJson(rootConfig)).toEqual({ theme: 'dark' }); + }); + + it('deletes the .opencode/opencode.json the rules glob alone filled, and keeps the root opencode.json (#946)', async () => { + const homeDir = path.join(tmpDir, 'oc-home'); + const repoPath = path.join(tmpDir, 'oc-team-repo'); + const projectRoot = path.join(tmpDir, 'oc-project'); + await fse.ensureDir(path.join(repoPath, 'rules')); + await fse.writeFile(path.join(repoPath, 'rules', 'team-rule.md'), '# Team Rule'); + await fse.ensureDir(path.join(projectRoot, '.opencode', 'rules')); + await fse.writeFile(path.join(projectRoot, '.opencode', 'rules', 'team-rule.md'), '# Team Rule'); + const dotConfig = path.join(projectRoot, '.opencode', 'opencode.json'); + const rootConfig = path.join(projectRoot, 'opencode.json'); + // OpenCode adds `$schema` to a config it loads. + await fse.writeJson(dotConfig, { $schema: 'https://opencode.ai/config.json', instructions: ['.opencode/rules/**/*.md'] }); + await fse.writeJson(rootConfig, { instructions: ['.opencode/rules/*.md'] }); + + vi.stubEnv('HOME', homeDir); + vi.stubEnv('SHELL', '/bin/zsh'); + const teamConfig = makeTeamConfig({ + toolPaths: { + opencode: { + skills: '.opencode/skills', rules: '.opencode/rules', + mcp: '.config/opencode/opencode.json', mcpProject: 'opencode.json', + userScope: { skills: '.config/opencode/skills', rules: '.config/opencode/rules' }, + }, + }, + }); + const localConfig = makeLocalConfig(homeDir, repoPath, { + scope: 'project', + projectRoot, + repo: { localPath: repoPath, remote: '', kind: 'self', businessRepoRoot: projectRoot }, + }); + mockAutoDetectInit.mockResolvedValue({ localConfig, teamConfig }); + + await uninstall({ force: true, agent: 'opencode' }); + + expect(await fse.pathExists(dotConfig)).toBe(false); + expect(await fse.readJson(rootConfig)).toEqual({}); + }); + // A relocated Claude Code root (toolRoots) moves the HOME hook file, but the // legacy copy was written by a CLI that knew nothing about it — // so the two targets must be looked for at different paths. diff --git a/src/doctor-delivery.ts b/src/doctor-delivery.ts index ee0e1f862..3dd2d4e9c 100644 --- a/src/doctor-delivery.ts +++ b/src/doctor-delivery.ts @@ -340,15 +340,16 @@ async function buildRulesActivationChecks(ctx: DoctorContext, items: ResourceIte const opencode = await handler.opencodeInstructionsTarget(teamConfig, localConfig, items); if (opencode !== null) { const { readOpencodeInstructionList } = await import('./resources/opencode-config.js'); - const instructions = await readOpencodeInstructionList(opencode.configFile); + // A missing file just lists nothing yet; null is one the pull cannot parse. + const instructions = await pathExists(opencode.configFile) ? await readOpencodeInstructionList(opencode.configFile) : []; const missing = opencode.globs.filter((glob) => !instructions?.includes(glob)); const stale = (instructions ?? []).filter((entry): entry is string => typeof entry === 'string' && opencode.owns(entry) && !opencode.globs.includes(entry)); const relativeStale = stale.filter((entry) => !path.isAbsolute(entry)); const namespaceStale = stale.filter((entry) => path.isAbsolute(entry)); const quoted = (entries: string[]): string => entries.map((entry) => `\`${entry}\``).join(', '); - const rerun = 'Run `teamai pull --force`: a plain pull skips a scope whose team repo has not changed, ' - + 'so it cannot restore this.'; + // Pull sets these globs even when the team repo has not moved (#946). + const rerun = 'Run `teamai pull`.'; checks.push({ name: 'Team rules are active in opencode', source: 'local', @@ -356,7 +357,7 @@ async function buildRulesActivationChecks(ctx: DoctorContext, items: ResourceIte fix: instructions === null ? `${opencode.configFile} could not be read as a JSON object, so the pull left it alone ` + `and never added ${quoted(opencode.globs)} to \`instructions\`. Fix the file, then run ` - + '`teamai pull --force`.' + + '`teamai pull`.' : [ ...(missing.length > 0 ? [`${opencode.configFile} does not list ${quoted(missing)} under \`instructions\`. ` diff --git a/src/pull.ts b/src/pull.ts index d5515cbd6..40885788b 100644 --- a/src/pull.ts +++ b/src/pull.ts @@ -1355,6 +1355,14 @@ async function pullForScope( } catch (error) { log.warn(`[${scopeLabel}] Codex's team rules were not updated: ${(error as Error).message}. Run \`teamai pull --force\` to retry.`); } + // Same reason: a CLI that moves OpenCode's rules globs writes them + // to their new config file and reclaims the old ones (#946). + try { + const { items } = await resolveDesiredRules(freshConfig, localConfig, roleContext); + await (getHandler('rules') as RulesHandler).activateOpencodeInstructions(freshConfig, localConfig, items); + } catch (error) { + log.warn(`[${scopeLabel}] OpenCode's rules globs were not updated: ${(error as Error).message}. Run \`teamai pull --force\` to retry.`); + } // Same reason: a CLI that gives a tool its own rules format must // re-render the copies an older one wrote verbatim (#946). await rerenderOutdatedRules(freshConfig, localConfig, roleContext, scopeLabel); diff --git a/src/resources/opencode-config.ts b/src/resources/opencode-config.ts index 318c5682e..06ae69393 100644 --- a/src/resources/opencode-config.ts +++ b/src/resources/opencode-config.ts @@ -1,5 +1,5 @@ import path from 'node:path'; -import { readFileSafe, writeJsonAtomic, pathExists } from '../utils/fs.js'; +import { readFileSafe, writeJsonAtomic, pathExists, remove } from '../utils/fs.js'; import { log } from '../utils/logger.js'; // ─── OpenCode config activation ────────────────────────────── @@ -19,18 +19,18 @@ import { log } from '../utils/logger.js'; * The rules glob relative to the directory that holds opencode.json. * * OpenCode resolves a relative `instructions` entry from the session's working - * directory, not from the config file. In project scope opencode.json sits at - * the repo root and rules at `/.opencode/rules`, giving - * `.opencode/rules/*.md`, which resolves the same from the root. In user scope - * this gives `rules/*.md`, the entry earlier releases wrote and which loaded the - * project's `rules/` instead; `opencodeRuleGlobs` uses it only to reclaim that - * entry (#946). + * directory, not from the config file. This gives the entry earlier releases + * wrote: `.opencode/rules/*.md` in a project's root opencode.json, which loaded + * no namespaced rule, and `rules/*.md` in the user one, which loaded the + * project's `rules/` instead. It is used only to reclaim those entries (#946). */ export function opencodeRulesGlob(configFileAbs: string, rulesDirAbs: string): string { - const rel = path.relative(path.dirname(configFileAbs), rulesDirAbs); - // Always use forward slashes: opencode.json globs are POSIX-style. - const relPosix = rel.split(path.sep).join('/'); - return `${relPosix}/*.md`; + return `${posix(path.relative(path.dirname(configFileAbs), rulesDirAbs))}/*.md`; +} + +/** Forward slashes: opencode.json entries are POSIX-style. */ +function posix(p: string): string { + return p.split(path.sep).join('/'); } /** The `instructions` globs that load teamai's rules, and which entries teamai owns. */ @@ -42,33 +42,69 @@ export interface OpencodeRuleGlobs { } /** - * The rules globs for one scope's opencode.json. + * The opencode.json teamai registers its project entries in. The root + * opencode.json stays the project's (#945, #946). + */ +export function opencodeProjectConfig(projectRoot: string): string { + return path.join(projectRoot, '.opencode', 'opencode.json'); +} + +/** Where one scope registers the rules globs, and where an earlier release did. */ +export interface OpencodeRulesTarget extends OpencodeRuleGlobs { + /** The opencode.json `globs` are registered in. */ + configFile: string; + /** Another opencode.json and the entries earlier releases wrote there: removed, never written. */ + retired: { configFile: string; owns: (entry: string) => boolean } | null; +} + +/** + * The rules glob for a project, registered in `.opencode/opencode.json`. + * + * OpenCode resolves a relative `instructions` entry from the session's working + * directory, globbing it in that directory and each parent up to the worktree, + * whichever config file lists it. So the entry is relative to the project + * root, and one recursive glob loads the namespaced rules as well (#946). + * Earlier releases wrote `.opencode/rules/*.md` to the root opencode.json, + * which loaded no namespaced rule; that entry is reclaimed. * - * Project scope keeps the one relative glob from the root opencode.json. + * @param rootConfigAbs The root opencode.json, or null when the team config names none. + */ +export function opencodeProjectRuleGlobs( + projectRoot: string, + rulesDirAbs: string, + rootConfigAbs: string | null, +): OpencodeRulesTarget { + const glob = `${posix(path.relative(projectRoot, rulesDirAbs))}/**/*.md`; + const old = rootConfigAbs === null ? null : opencodeRulesGlob(rootConfigAbs, rulesDirAbs); + return { + configFile: opencodeProjectConfig(projectRoot), + globs: [glob], + owns: (entry) => entry === glob, + retired: rootConfigAbs === null ? null : { configFile: rootConfigAbs, owns: (entry) => entry === old }, + }; +} + +/** + * The user rules globs for `~/.config/opencode/opencode.json`. * - * In user scope a relative entry resolves from the session cwd, not from the - * config file, so the old `rules/*.md` loaded the project's `rules/` instead - * of the user rules (#946). The globs are absolute, and since OpenCode globs - * only the basename of an absolute entry (`**` never matches), each directory - * a rule lands in gets its own: the rules root plus one per namespace. - * teamai owns the root glob, the glob of each directory a team rule can land - * in, and the old relative one. A glob for any other directory is the - * member's own. + * A relative entry resolves from the session cwd, not from the config file, + * so the old `rules/*.md` loaded the project's `rules/` instead of the user + * rules (#946). The globs are absolute, and since OpenCode globs only the + * basename of an absolute entry (`**` never matches), each directory a rule + * lands in gets its own: the rules root plus one per namespace. teamai owns + * the root glob, the glob of each directory a team rule can land in, and the + * old relative one. A glob for any other directory is the member's own. * * @param ruleDirsAbs The directories the delivered rules land in. * @param teamDirsAbs The directories any team rule can land in, delivered here or not. */ export function opencodeRuleGlobs( - scope: 'user' | 'project', configFileAbs: string, rulesDirAbs: string, ruleDirsAbs: readonly string[], teamDirsAbs: readonly string[], ): OpencodeRuleGlobs { const relative = opencodeRulesGlob(configFileAbs, rulesDirAbs); - if (scope === 'project') return { globs: [relative], owns: (entry) => entry === relative }; - - const posix = (p: string): string => p.split(path.sep).join('/'); const root = posix(rulesDirAbs); const under = (dirs: readonly string[]): string[] => [...new Set(dirs.map(posix))].filter((dir) => dir.startsWith(`${root}/`)).sort(); @@ -87,13 +123,15 @@ export function opencodeRuleGlobs( * A missing file is created with just `desired`, or left missing when nothing * is desired. A file that exists but cannot be parsed as a JSON object is left * strictly alone (it may hold config we do not understand), and the function - * returns false. + * returns false. With `deleteIfEmpty`, a file left holding nothing but the + * `$schema` OpenCode adds is deleted: for a config file teamai creates. */ export async function reconcileOpencodeInstructionSet( configFileAbs: string, desired: readonly string[], owns: (entry: string) => boolean, purpose = 'rules activation', + { deleteIfEmpty = false }: { deleteIfEmpty?: boolean } = {}, ): Promise { const exists = await pathExists(configFileAbs); @@ -141,6 +179,11 @@ export async function reconcileOpencodeInstructionSet( data.instructions = next; } + if (deleteIfEmpty && Object.keys(data).every((key) => key === '$schema')) { + await remove(configFileAbs); + log.debug(`Removed ${configFileAbs}: it held only teamai ${purpose} entries`); + return true; + } await writeJsonAtomic(configFileAbs, data); log.debug(`Reconciled teamai ${purpose} entries in ${configFileAbs}`); return true; @@ -201,8 +244,8 @@ export function opencodeContextReference(contextFile: string, scope: 'user' | 'p return { config: path.join(path.dirname(contextFile), 'opencode.json'), entry: contextFile }; } return { - config: path.join(projectRoot, '.opencode', 'opencode.json'), - entry: path.relative(projectRoot, contextFile).split(path.sep).join('/'), + config: opencodeProjectConfig(projectRoot), + entry: posix(path.relative(projectRoot, contextFile)), }; } diff --git a/src/resources/rules.ts b/src/resources/rules.ts index 46baee815..61931bf01 100644 --- a/src/resources/rules.ts +++ b/src/resources/rules.ts @@ -8,7 +8,7 @@ import { TEAMAI_RULES_START, TEAMAI_RULES_END, TEAMAI_TEAM_RULES_START, TEAMAI_T import { EXCLUDED_RULE_NAMES, isDeployedRecallRule, TEAMAI_CONTEXT_RULE_NAME } from '../builtin-rules.js'; import { splitFrontmatter } from '../utils/frontmatter.js'; import { rulePaths } from './team-rule.js'; -import type { OpencodeRuleGlobs } from './opencode-config.js'; +import type { OpencodeRulesTarget } from './opencode-config.js'; import { assertWithinRoot } from '../utils/path-safety.js'; import { loadStateForScope } from '../config.js'; import { placedResourcePath } from '../push-namespaces.js'; @@ -849,9 +849,10 @@ export class RulesHandler extends ResourceHandler { * match the rules delivered, so copied rule files are actually loaded, and * remove them all when no rule is. No-op when opencode is disabled or not * installed (we never create an opencode.json for a user who doesn't use - * OpenCode). + * OpenCode). Public so the "Already synced" pull can move the globs a CLI + * upgrade relocates (#946). */ - private async activateOpencodeInstructions( + async activateOpencodeInstructions( teamConfig: TeamaiConfig, localConfig: LocalConfig, rules: readonly ResourceItem[], @@ -859,19 +860,31 @@ export class RulesHandler extends ResourceHandler { const target = await this.opencodeInstructionsTarget(teamConfig, localConfig, rules); if (target === null) return; - const { reconcileOpencodeInstructionSet } = await import('./opencode-config.js'); + const { readOpencodeInstructionList, reconcileOpencodeInstructionSet } = await import('./opencode-config.js'); try { await reconcileOpencodeInstructionSet(target.configFile, rules.length > 0 ? target.globs : [], target.owns); } catch (e) { log.warn(`Failed to update OpenCode instructions in ${target.configFile}: ${(e as Error).message}`); + return; + } + // The old glob goes only once the new one is listed (a config the pull + // cannot parse is left alone), so the rules are never left unregistered. + if (target.retired === null) return; + if (rules.length > 0 && await readOpencodeInstructionList(target.configFile) === null) return; + try { + await reconcileOpencodeInstructionSet(target.retired.configFile, [], target.retired.owns); + } catch (e) { + log.warn(`Failed to update OpenCode instructions in ${target.retired.configFile}: ${(e as Error).message}`); } } /** * The opencode.json this scope activates rules through, the globs that load - * `rules` from it, and which `instructions` entries teamai owns there. Null - * when OpenCode receives no rules here: excluded, not installed, or - * configured without a rules or config path. + * `rules` from it, and which `instructions` entries teamai owns there. In a + * project that is `.opencode/opencode.json`, and `retired` names the root + * opencode.json glob earlier releases wrote. Null when OpenCode receives no + * rules here: excluded, not installed, or configured without a rules or + * config path. * * Read-only, and public for the same reason `deliveryTargets` is: OpenCode * does not auto-scan its rules directory, so a `.md` sitting there is inert @@ -882,7 +895,7 @@ export class RulesHandler extends ResourceHandler { teamConfig: TeamaiConfig, localConfig: LocalConfig, rules: readonly ResourceItem[], - ): Promise<({ configFile: string } & OpencodeRuleGlobs) | null> { + ): Promise { if (isAgentExcluded(localConfig, 'opencode')) return null; const paths = scopedToolPaths(teamConfig, localConfig)['opencode']; if (!paths?.rules) return null; @@ -891,13 +904,15 @@ export class RulesHandler extends ResourceHandler { // Only touch opencode.json when OpenCode is actually installed for this scope. if (!await ResourceHandler.isToolInstalled(paths.rules, baseDir)) return null; - // The config file mirrors the MCP scope fields: /opencode.json in - // project scope, ~/.config/opencode/opencode.json in user scope. - const configRel = localConfig.scope === 'project' ? paths.mcpProject : paths.mcp; - if (!configRel) return null; - - const configFile = path.join(baseDir, configRel); const rulesDir = path.join(baseDir, paths.rules); + const { opencodeProjectRuleGlobs, opencodeRuleGlobs } = await import('./opencode-config.js'); + if (localConfig.scope === 'project') { + return opencodeProjectRuleGlobs(baseDir, rulesDir, paths.mcpProject ? path.join(baseDir, paths.mcpProject) : null); + } + + // The user config file mirrors the MCP field: ~/.config/opencode/opencode.json. + if (!paths.mcp) return null; + const configFile = path.join(baseDir, paths.mcp); // The directories pull writes the rules to, from the same seam it uses. const ruleDirs: string[] = []; for (const rule of rules) { @@ -909,8 +924,7 @@ export class RulesHandler extends ResourceHandler { // longer receives still has its glob reclaimed. const teamDirs = (await this.scanTeamForPull(teamConfig, localConfig)) .map((rule) => path.dirname(path.join(rulesDir, `${rule.name}.md`))); - const { opencodeRuleGlobs } = await import('./opencode-config.js'); - return { configFile, ...opencodeRuleGlobs(localConfig.scope, configFile, rulesDir, ruleDirs, teamDirs) }; + return { configFile, ...opencodeRuleGlobs(configFile, rulesDir, ruleDirs, teamDirs), retired: null }; } /** diff --git a/src/uninstall.ts b/src/uninstall.ts index b0938055c..e863454b4 100644 --- a/src/uninstall.ts +++ b/src/uninstall.ts @@ -115,8 +115,8 @@ interface RemovalPlan { ruleFiles: string[]; /** Copies in a tool's legacy rules directory the member edited: never removed, only named. */ keptRuleFiles: string[]; - /** The rules globs teamai owns in OpenCode's opencode.json `instructions` (#946). */ - opencodeOwnedGlobs: OpencodeRuleGlobEntries | null; + /** The rules globs teamai owns in OpenCode's opencode.json `instructions`, per file (#946). */ + opencodeOwnedGlobs: OpencodeRuleGlobEntries[]; /** Built-in agent .md files deployed by the CLI (e.g. teamai-recall). */ agentFiles: string[]; /** teamai-managed MCP servers from managed-mcp.json (`tool/server` or `tool:project/server`). */ @@ -180,7 +180,7 @@ interface ToolResources { skillDirs: SkillDirEntry[]; ruleFiles: string[]; keptRuleFiles: string[]; - opencodeOwnedGlobs: OpencodeRuleGlobEntries | null; + opencodeOwnedGlobs: OpencodeRuleGlobEntries[]; agentFiles: string[]; } @@ -188,6 +188,8 @@ interface ToolResources { interface OpencodeRuleGlobEntries { configFile: string; entries: string[]; + /** A file teamai creates (a project's `.opencode/opencode.json`): deleted once nothing else is left in it. */ + deleteIfEmpty: boolean; } function hasToolResources(r: ToolResources): boolean { @@ -203,7 +205,7 @@ function hasToolResources(r: ToolResources): boolean { r.opencodeInstructions.length > 0 || r.skillDirs.length > 0 || r.ruleFiles.length > 0 || - r.opencodeOwnedGlobs !== null || + r.opencodeOwnedGlobs.length > 0 || r.agentFiles.length > 0 ); } @@ -369,7 +371,7 @@ async function discoverToolResources( ): Promise { const res: ToolResources = { hookFiles: [], openclawHookDirs: [], opencodeHookScopes: [], ompHookFile: null, piHookFiles: [], dshHookFile: null, - claudeMdFiles: [], retiredInstructionFiles: [], opencodeInstructions: [], keptGlobal: [], skillDirs: [], ruleFiles: [], keptRuleFiles: [], opencodeOwnedGlobs: null, agentFiles: [], + claudeMdFiles: [], retiredInstructionFiles: [], opencodeInstructions: [], keptGlobal: [], skillDirs: [], ruleFiles: [], keptRuleFiles: [], opencodeOwnedGlobs: [], agentFiles: [], }; // (a) Hooks — settings.json / hooks.json @@ -749,9 +751,17 @@ async function buildRemovalPlan( : null; if (opencodeRes && opencodeTarget) { const { readOpencodeInstructionList } = await import('./resources/opencode-config.js'); - const entries = ((await readOpencodeInstructionList(opencodeTarget.configFile)) ?? []) - .filter((entry): entry is string => typeof entry === 'string' && opencodeTarget.owns(entry)); - if (entries.length > 0) opencodeRes.opencodeOwnedGlobs = { configFile: opencodeTarget.configFile, entries }; + // In a project, also the root opencode.json glob an earlier release wrote. + const { retired } = opencodeTarget; + const files = [ + { configFile: opencodeTarget.configFile, owns: opencodeTarget.owns, deleteIfEmpty: localConfig.scope === 'project' }, + ...(retired ? [{ ...retired, deleteIfEmpty: false }] : []), + ]; + for (const { configFile, owns, deleteIfEmpty } of files) { + const entries = ((await readOpencodeInstructionList(configFile)) ?? []) + .filter((entry): entry is string => typeof entry === 'string' && owns(entry)); + if (entries.length > 0) opencodeRes.opencodeOwnedGlobs.push({ configFile, entries, deleteIfEmpty }); + } } // A tool only still "uses" a shared resource (AGENTS.md, .teamai/) if it is @@ -806,7 +816,7 @@ async function buildRemovalPlan( skillDirs: [], ruleFiles: [], keptRuleFiles: [], - opencodeOwnedGlobs: null, + opencodeOwnedGlobs: [], agentFiles: [], mcpServers: [], shellProfiles: [], @@ -874,7 +884,7 @@ async function buildRemovalPlan( plan.skillDirs.push(...res.skillDirs); plan.ruleFiles.push(...res.ruleFiles); plan.keptRuleFiles.push(...res.keptRuleFiles); - if (res.opencodeOwnedGlobs) plan.opencodeOwnedGlobs = res.opencodeOwnedGlobs; + plan.opencodeOwnedGlobs.push(...res.opencodeOwnedGlobs); plan.agentFiles.push(...res.agentFiles); } @@ -980,7 +990,7 @@ function isPlanEmpty(plan: RemovalPlan): boolean { plan.opencodeInstructions.length === 0 && plan.skillDirs.length === 0 && plan.ruleFiles.length === 0 && - plan.opencodeOwnedGlobs === null && + plan.opencodeOwnedGlobs.length === 0 && plan.agentFiles.length === 0 && plan.mcpServers.length === 0 && plan.shellProfiles.length === 0 && @@ -1077,8 +1087,8 @@ function printSummary(plan: RemovalPlan, agentFilter?: string): void { console.log(''); } - if (plan.opencodeOwnedGlobs) { - console.log(` OpenCode rules globs (${plan.opencodeOwnedGlobs.entries.length}) in ${plan.opencodeOwnedGlobs.configFile}`); + for (const { configFile, entries } of plan.opencodeOwnedGlobs) { + console.log(` OpenCode rules globs (${entries.length}) in ${configFile}`); console.log(''); } @@ -1353,11 +1363,10 @@ async function executeRemoval(plan: RemovalPlan): Promise 0) { log.success(`Removed ${plan.ruleFiles.length} rule files`); } - if (plan.opencodeOwnedGlobs) { - const { configFile, entries } = plan.opencodeOwnedGlobs; + for (const { configFile, entries, deleteIfEmpty } of plan.opencodeOwnedGlobs) { try { const { reconcileOpencodeInstructionSet } = await import('./resources/opencode-config.js'); - if (await reconcileOpencodeInstructionSet(configFile, [], (entry) => entries.includes(entry))) { + if (await reconcileOpencodeInstructionSet(configFile, [], (entry) => entries.includes(entry), undefined, { deleteIfEmpty })) { log.success(`Removed ${entries.length} OpenCode rules globs from ${configFile}`); } } catch (e) { From 75af367d95114ed7a4cdf471513f049b0ea2afea Mon Sep 17 00:00:00 2001 From: Saul Moro Date: Fri, 2 Oct 2026 01:34:48 +0200 Subject: [PATCH 05/20] fix(rules): share CodeBuddy project rules with WorkBuddy (#946) --- CHANGELOG.md | 2 + docs/designs/data-directory-layout.md | 13 +- docs/usage-guide.md | 12 +- docs/usage-guide.zh-CN.md | 12 +- .../core/references/contribute-member.md | 2 +- skill-data/setup/references/uninstall.md | 6 +- .../codebuddy-workbuddy-rules.test.ts | 274 ++++++++++++++++++ src/__tests__/doctor-rules-delivery.test.ts | 43 +++ src/__tests__/e2e/doctor-delivery-cli.test.ts | 2 +- src/__tests__/helpers/rule-parsers.ts | 44 ++- src/__tests__/pre-push-sync.test.ts | 11 +- .../pull-workbuddy-rules-move.test.ts | 146 ++++++++++ src/__tests__/push-namespace-e2e.test.ts | 21 +- src/__tests__/rule-parsers.test.ts | 43 ++- src/__tests__/rule-render-contracts.test.ts | 25 ++ src/__tests__/uninstall.test.ts | 66 +++++ src/doctor-delivery.ts | 12 +- src/pull.ts | 32 +- src/resources/base.ts | 9 +- src/resources/codebuddy-rule.ts | 33 +++ src/resources/rule-format.ts | 103 ++++++- src/resources/rules.ts | 228 +++++++++++---- src/types.ts | 20 +- src/uninstall.ts | 35 ++- 24 files changed, 1054 insertions(+), 140 deletions(-) create mode 100644 src/__tests__/codebuddy-workbuddy-rules.test.ts create mode 100644 src/__tests__/pull-workbuddy-rules-move.test.ts create mode 100644 src/resources/codebuddy-rule.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 32c6e5cdb..6e74e5424 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -50,6 +50,8 @@ All notable changes to this project will be documented in this file. See [standa - teamai's OpenClaw hook runs. Its handler read an `event` field OpenClaw never sends and subscribed to `session:start`, which OpenClaw does not emit; its `HOOK.md` name was not valid YAML; and OpenClaw loads a workspace hook only once `openclaw.json` enables its entry, which teamai never wrote. The handler now keys on the event's `type` and `action`, runs `session-start` on `command:new`, `command:reset`, `session:auto-reset` and `gateway:startup` and `prompt-submit` on `message:received`, and passes the event's workspace as the hook's `cwd`. init, pull and `hooks inject` add `hooks.internal.entries.teamai-status-report.enabled`, unless that would narrow OpenClaw's open-ended hook discovery or you switched it off, in which case they warn and name `openclaw hooks enable teamai-status-report` (earlier versions set `hooks.internal.enabled` when `OPENCLAW_STATE_DIR` was set, so those machines see this warning until the command is run); `hooks remove` and uninstall take the entry out. The workspace and config follow `OPENCLAW_PROFILE`, `OPENCLAW_CONFIG_PATH` and `OPENCLAW_WORKSPACE_DIR` as OpenClaw does, a server-pushed `SessionStart` or `UserPromptSubmit` agent hook subscribes to the events OpenClaw emits and gets its own entry (`teamai-agent-`) so the allowlist keeps it, and `teamai doctor` fails `OpenClaw hook enabled` while the entry is missing or off (for [#946](https://github.com/Tencent/teamai-cli/issues/946)). - A project-scope `teamai pull` no longer rewrites Hermes' global `SOUL.md` rules block, and a project with no rules no longer erases it: only a user-scope pull writes it, `doctor` checks it only in user scope, and a project-scope `uninstall` leaves it. Hermes gets no project rules; in a project `init` and `doctor` say why (for [#946](https://github.com/Tencent/teamai-cli/issues/946)). - OpenCode loads the user-scope team rules again. The user `opencode.json` listed `rules/*.md`, which OpenCode resolves from the session's working directory, so it loaded the project's `rules/` instead. Pull now lists the absolute `~/.config/opencode/rules/*.md` plus one glob per namespace directory a rule lands in, replaces the old relative glob, and drops a namespace's glob once its rules no longer reach you, keeping a glob you added for a directory of your own; `doctor` checks every glob and flags a stale one, and `uninstall` removes them (for [#946](https://github.com/Tencent/teamai-cli/issues/946)). +- CodeBuddy and WorkBuddy now get each rule in CodeBuddy's own format: `alwaysApply: false` with `paths:` as a block list when scoped, `alwaysApply: true` when not. CodeBuddy's frontmatter parser reads lines, so the inline `paths: ["a", "b"]` of a verbatim copy reached it as globs with the brackets in them. WorkBuddy's project rules now go into `.codebuddy/rules`, which it reads, as one copy shared with CodeBuddy: removing or excluding one tool keeps it while the other is installed, and `doctor` checks it once for both. WorkBuddy's user rules stay in `~/.workbuddy/rules`. Qoder and Qoder CN, which both read a project's `.qoder/rules` in one render, share their copies the same way. A rule file in `.codebuddy/rules` with no matching team rule is the member's own: pull no longer deletes it, and push no longer offers it as a new team rule (for [#946](https://github.com/Tencent/teamai-cli/issues/946)). +- `teamai pull` reclaims the rule copies earlier releases left in a project's `.workbuddy/rules`, which WorkBuddy never read, and writes them to `.codebuddy/rules`, also when the team repo has not moved. It also reclaims the team rules WorkBuddy's one-time migration copied from `~/.codebuddy/rules` into `~/.workbuddy/rules`: an unedited one is re-rendered while still delivered and removed otherwise, and an edited one is kept and named. The reclaim of old rule copies now runs on every rules sync, and its warnings say why each tool does not read the copy (for [#946](https://github.com/Tencent/teamai-cli/issues/946)). - OpenCode loads namespaced project rules. The root `opencode.json` listed `.opencode/rules/*.md`, which matched no rule in a namespace directory. Pull now lists `.opencode/rules/**/*.md` in `.opencode/opencode.json`, beside the team instructions entry, and removes the old glob from the root `opencode.json`, leaving its other keys. The first pull after upgrading moves the globs in both scopes even when the team repo has not moved; `doctor` checks `.opencode/opencode.json`, and `uninstall` removes the glob from both files, deleting a `.opencode/opencode.json` left empty (for [#946](https://github.com/Tencent/teamai-cli/issues/946)). - Team rules reach Codex, `codex-internal` and `tcodex`. Pull used to copy them to `.codex/rules/.md`, which Codex does not read, and `teamai doctor` reported them delivered. In user scope they now go into a team-rules block of the tool's own `AGENTS.md` (`~/.codex/AGENTS.md`), beside the culture, shared-instructions and recall blocks. In a project the session-start hook adds the project's rules and those blocks to each session, and to a spawned subagent through a new `SubagentStart` entry, with `additionalContextLimit: 0` so Codex keeps them whole; pull leaves the project `AGENTS.md`, which other tools read too, unchanged. Pull removes the `.md` copies earlier pulls left in the Codex rules directory at its recorded `toolRoots` location, including a publisher's bare local copy, keeping an edited one with a warning. Pull and uninstall keep a removed rule's legacy copy unless it matches its recorded delivery hash; a missing record proves nothing about local edits. A path-scoped rule is rendered without frontmatter after an `Applies to files matching: ` line, in Hermes' `SOUL.md` too. `teamai doctor` checks the user-scope block and, in a project, both hook entries' limit. The public Codex asks once to approve the changed teamai hooks (for [#938](https://github.com/Tencent/teamai-cli/issues/938)). - `teamai pull` names the skills it removes because they are no longer delivered here, in one line, instead of a `debug` line calling them excluded and a summary that says `No resources to sync`. Picking a role or project, or an admin adding `manifest/projects.yaml`, takes root skills away, since the root `skills/` is then the tag catalog; the line then says that `teamai tags subscribe ` brings one back. A namespace skill removed because its namespace is no longer active is named too. The usage guide and the multi-project design no longer say a member with no project still gets `common` or every root item (for [#911](https://github.com/Tencent/teamai-cli/issues/911)). diff --git a/docs/designs/data-directory-layout.md b/docs/designs/data-directory-layout.md index 5ed005a74..4826b4343 100644 --- a/docs/designs/data-directory-layout.md +++ b/docs/designs/data-directory-layout.md @@ -177,11 +177,14 @@ older CLI's render, such as Claude extras in a Qoder copy). Without a is left alone. Rules get the same treatment for a render change (#946): when a CLI upgrade -gives a tool its own rules format (Kiro, Qoder), the fast path rewrites each -rule copy that still has the bytes `delivered` records but is not the current -render, records the new bytes, and leaves a copy without an entry alone. A -copy the member changed is kept and named when teamai would now deliver other -bytes there. +gives a tool its own rules format (Kiro, Qoder, CodeBuddy), the fast path +rewrites each rule copy that still has the bytes `delivered` records but is not +the current render, records the new bytes, and leaves a copy without an entry +alone. A copy the member changed is kept and named when teamai would now +deliver other bytes there. A destination that moved (WorkBuddy's project rules, +from `.workbuddy/rules` to `.codebuddy/rules`) has no entry at its new path, so +the fast path first reclaims the old copies (`LEGACY_RULE_DIRS`), writes the +new path where it is missing, and records the change. ### Why the main worktree, not `git-common-dir` (verified) diff --git a/docs/usage-guide.md b/docs/usage-guide.md index fb0cd49bf..3c665166b 100644 --- a/docs/usage-guide.md +++ b/docs/usage-guide.md @@ -2298,7 +2298,7 @@ Qoder is available as a built-in target. TeamAI deploys skills, rules, and subag Rules are written in the form Qoder Desktop writes, which Qoder CLI also reads: a rule with `paths:` gets `trigger: glob` and one unquoted `glob:` line of comma-separated globs, with each `{a,b}` alternation expanded into separate globs because the line is split on every comma; a rule without `paths` gets `trigger: always_on`. Qoder publishes no schema for this frontmatter; the form comes from Desktop's rule files in `alibaba/tron-one-agent`. On `push`, only the Markdown body flows back, and a rule file in `.qoder/rules/` with no matching team rule is yours: `pull` leaves it and `push` never offers it as a new team rule. Copies an older teamai wrote verbatim are rewritten on the next `pull` when they still hold what teamai delivered; one you edited is kept and named. -Qoder CN is a separate distribution that keeps its **user** directory at `~/.qoder-cn` instead of `~/.qoder`, so it is a separate built-in target (`qoder-cn`) rather than part of `qoder`. Only the user scope differs: user-scope resources go to `~/.qoder-cn/{skills,rules,agents}` and hooks/MCP to `~/.qoder-cn/settings.json`, while project-scope resources keep Qoder's `/.qoder/` layout. It reads the same Claude-compatible resource formats, so content is identical and only the user-scope root changes. Install both editions and TeamAI syncs each one to its own user directory; neither needs a symlink. +Qoder CN is a separate distribution that keeps its **user** directory at `~/.qoder-cn` instead of `~/.qoder`, so it is a separate built-in target (`qoder-cn`) rather than part of `qoder`. Only the user scope differs: user-scope resources go to `~/.qoder-cn/{skills,rules,agents}` and hooks/MCP to `~/.qoder-cn/settings.json`, while project-scope resources keep Qoder's `/.qoder/` layout. It reads the same Claude-compatible resource formats, so content is identical and only the user-scope root changes. Install both editions and TeamAI syncs each one to its own user directory; neither needs a symlink. In a project Qoder and Qoder CN both read `.qoder/rules/`, so they share one copy there: uninstalling one keeps it while the other is installed, and `doctor` checks it once, as `Rules delivered to qoder, qoder-cn`. ### Kiro @@ -2306,6 +2306,12 @@ Kiro is available as a built-in target. TeamAI deploys skills, rules, and subage Rules are steering files with Kiro's inclusion frontmatter, in `.kiro/steering/` and `~/.kiro/steering/`: a rule with `paths:` gets `inclusion: fileMatch` and `fileMatchPattern` as a list of its globs; a rule without `paths` gets `inclusion: always`. On `push`, only the Markdown body flows back, and a steering file with no matching team rule (such as Kiro's own `product.md`) is yours: `pull` leaves it and `push` never offers it as a new team rule. Copies an older teamai wrote verbatim are rewritten on the next `pull` when they still hold what teamai delivered; one you edited is kept and named. Kiro CLI loads every steering file whatever its `inclusion` ([kirodotdev/Kiro#7950](https://github.com/kirodotdev/Kiro/issues/7950)), so a scoped rule is always on there. +### CodeBuddy and WorkBuddy + +WorkBuddy runs CodeBuddy's engine, so both get rules in CodeBuddy's format: a rule with `paths:` gets `alwaysApply: false` and `paths:` as a YAML block list, one quoted glob per item; a rule without `paths` gets `alwaysApply: true`. CodeBuddy's frontmatter parser reads lines, not YAML, so the inline `paths: ["a", "b"]` a verbatim copy carried reached it as globs with the brackets in them. In a project both tools read `.codebuddy/rules/`, so that directory holds one copy of each rule for both: excluding or uninstalling one keeps the copies while the other is installed, and `doctor` checks the directory once, as `Rules delivered to codebuddy, workbuddy`. WorkBuddy counts as installed only where `.workbuddy/` exists. In user scope CodeBuddy reads `~/.codebuddy/rules/` and WorkBuddy `~/.workbuddy/rules/`. On `push`, only the Markdown body flows back, and a rule file there with no matching team rule is yours: `pull` leaves it and `push` never offers it as a new team rule. Copies an older teamai wrote verbatim are rewritten on the next `pull` when they still hold what teamai delivered; one you edited is kept and named. + +> Upgrading: earlier releases wrote WorkBuddy's project rules to `.workbuddy/rules/`, which WorkBuddy never read. The next `pull` removes the copies there that still hold what teamai delivered (the directory too, once empty) and writes the rules to `.codebuddy/rules/`, even when the team repo has not moved; a copy you edited is kept and named. WorkBuddy's one-time migration copied `~/.codebuddy/rules/` into `~/.workbuddy/rules/` (it leaves `~/.workbuddy/.migrated-from-codebuddy`), team copies included. A copied team rule that holds what teamai delivered (a render of the rule, or the bytes teamai recorded writing at `~/.codebuddy/rules/` under the same name) is rewritten in CodeBuddy's format while WorkBuddy still gets that rule, and removed otherwise; an edited one is kept and named, and your own rules there stay. + ### ZCode ZCode is available as a built-in target. Skills deploy to `.zcode/skills/` (ZCode also reads the central `~/.agents/skills/`, which the `agents` entry covers), and subagents deploy as Claude-style Markdown to `.zcode/agents/`. Hooks are merged into the shared `~/.zcode/cli/config.json`, preserving unrelated keys such as plugin state. Two ZCode specifics the writer handles for you: @@ -2376,7 +2382,7 @@ teamai remove rules --force # Skip the prompt, for scripts and CI Besides the provider, clone, config and hook checks, `doctor` verifies what reached your machine. ` is installed` fails when `enabledAgents` lists a tool that nothing would be delivered to, which is the case where a pull reports success and that tool receives nothing. It asks the same resolver the sync uses, so a tool that keeps its skills somewhere other than its tool root, as OpenClaw does with its workspace directory, is judged where the sync would actually write. It reports an installed tool as passing too, so `--json` carries one entry per enabled tool either way. The checks at the end of a pull cover the scope that pull resolved from the current directory; run `teamai doctor` in another scope to check that one. `Skills delivered to ` compares the skills your role namespaces, tag subscriptions and exclusions resolve to against what is on disk for each installed tool: it reports a skill that was never delivered separately from one that arrived unreadable — `SKILL.md` missing, its frontmatter unparseable, or its `name` not matching the directory, which keeps the agent from ever discovering it. `Team docs delivered` compares the docs you receive (a docs namespace you do not have active is left out) against `sharing.docs.localDir`, which has one destination rather than one per tool; each expected document has to be a file that can be read, so a directory or a dangling link sitting on the name counts as missing. It also reports extra non-hidden local files as stale, including when the team bundle is empty. Hidden local files are preserved and do not fail this check, and neither does a local copy of a team doc in a namespace you do not have active: pull removes it when it is unchanged and names it when you edited it. `doctor` also prints notes, which are information rather than failed checks. Each note names a namespace skill, agent, rule, shared-instructions file, env variable, hook, MCP server or team model profile that replaces a root one here (`rules: "style" from rules/checkout/style.md replaces rules/style.md`). When a namespace contributes env variables, hooks, MCP servers or team model profiles, a note also counts where that type's entries come from (`env: 3 received here (2 root, 1 checkout)`). Without roles or projects, the notes name each file the team repo defines more than once instead, and each env variable, hook or MCP server name repeated in its root file. -`Rules delivered to ` and `Agents delivered to ` do the same for the other two per-tool resources, and both ask the handler where an item lands rather than deriving a path: a rule's filename and content change per tool (`.md` verbatim, `.mdc` with derived `globs`/`alwaysApply`, `.instructions.md` with `applyTo`, Kiro's `.md` with `inclusion`/`fileMatchPattern`, Qoder's `.md` with `trigger`/`glob`), and an agent's destination comes from its render, with `targets:` deciding which tools are owed a copy at all. A delivered rule is compared with the bytes the handler renders for that tool, not merely read for the keys its tool needs: a `.mdc` whose `globs` no longer match the team rule's `paths:` applies to the wrong files while carrying a perfectly legal `alwaysApply`, and that reads here as `delivered from an older copy` — the same label as a body that drifted, because both landed successfully and are still wrong. An agent is compared with the bytes its render produces, so a copy left behind by an older spec — a plain pull skips a scope whose team repo has not changed, so it can sit there indefinitely — is reported as `delivered from an older spec` rather than passing as present. `Every team agent reaches a tool` names an agent that renders for no installed tool — usually a spec that does not parse, or a `targets:` list naming only tools you do not have. These two are `doctor`-only: they read every rule per tool and parse every agent, which would spend the budget the checks at the end of a pull run under. +`Rules delivered to ` and `Agents delivered to ` do the same for the other two per-tool resources, and both ask the handler where an item lands rather than deriving a path: a rule's filename and content change per tool (`.md` verbatim, `.mdc` with derived `globs`/`alwaysApply`, `.instructions.md` with `applyTo`, Kiro's `.md` with `inclusion`/`fileMatchPattern`, Qoder's `.md` with `trigger`/`glob`, CodeBuddy's `.md` with `alwaysApply`/`paths`; tools that read one copy, as CodeBuddy and WorkBuddy do in a project, get one check naming both), and an agent's destination comes from its render, with `targets:` deciding which tools are owed a copy at all. A delivered rule is compared with the bytes the handler renders for that tool, not merely read for the keys its tool needs: a `.mdc` whose `globs` no longer match the team rule's `paths:` applies to the wrong files while carrying a perfectly legal `alwaysApply`, and that reads here as `delivered from an older copy` — the same label as a body that drifted, because both landed successfully and are still wrong. An agent is compared with the bytes its render produces, so a copy left behind by an older spec — a plain pull skips a scope whose team repo has not changed, so it can sit there indefinitely — is reported as `delivered from an older spec` rather than passing as present. `Every team agent reaches a tool` names an agent that renders for no installed tool — usually a spec that does not parse, or a `targets:` list naming only tools you do not have. These two are `doctor`-only: they read every rule per tool and parse every agent, which would spend the budget the checks at the end of a pull run under. Three tools do not read a rules directory, so a per-file check cannot speak for them and each gets one of its own. `Team rules are active in opencode` checks that `opencode.json` (`.opencode/opencode.json` in a project) lists every glob the pull owns under `instructions`, and no stale one such as the relative `rules/*.md` an earlier release wrote: OpenCode does not auto-scan `.opencode/rules`, so without it every delivered `.md` is inert while the per-file check keeps passing. In user scope, `Team rules are inlined in Hermes SOUL.md` compares the teamai-managed block of `SOUL.md` with what the team rules inline to, since Hermes reads standing instructions from that one file rather than from a directory — a deleted block, or one left on an older rule set, is a tool reading the wrong rules with nothing on disk to show for it. In user scope, `Team rules are inlined in Codex AGENTS.md` compares the team-rules block of the tool's `AGENTS.md` with what the team rules inline to, and fails when an `AGENTS.override.md` beside it shadows the file. In a project, `Project rules and instructions reach whole through its session hooks` fails when the teamai `SessionStart` or `SubagentStart` entry in that tool's `hooks.json` is missing or does not set `additionalContextLimit: 0`, without which Codex keeps only the start and end of a large set. Codex has no `Rules delivered to ` check. @@ -2939,7 +2945,7 @@ What gets removed: - teamai hooks in AI tool settings - The teamai blocks (culture, shared instructions, recall, and Codex's team rules) in each tool's instruction file, and the files an earlier release wrote them to (your own content is preserved; a `teamai-context` file teamai wrote is removed whole, and OpenCode's `instructions` entry for it goes too when teamai added it, even when your own text keeps the file or the file is gone; an entry you listed yourself stays) - Team-synced skills, including OpenClaw workspace skills (your own skills are preserved) -- Team-synced rules, including the copies older releases left in `.codex/rules/`, also of rules the team has since removed. Cleanup follows the recorded `toolRoots` location and the publisher's local filenames. A copy there you edited is kept and named in a warning. A removed rule's copy is deleted only if it matches its recorded delivery hash; without that record, it is kept and named too. Codex's `*.rules` files are kept +- Team-synced rules, including the copies older releases left in `.codex/rules/` and a project's `.workbuddy/rules/`, also of rules the team has since removed. A copy in a project's `.codebuddy/rules/` stays while the other of CodeBuddy and WorkBuddy is still installed. Cleanup follows the recorded `toolRoots` location and the publisher's local filenames. A copy there you edited is kept and named in a warning. A removed rule's copy is deleted only if it matches its recorded delivery hash; without that record, it is kept and named too. Codex's `*.rules` files are kept - Team-synced custom agents and CLI built-in agents (your own agents are preserved) - The env block in your shell profile — every candidate file (`.zshrc`, `.bashrc`, `.bash_profile`, `.bash_login`, `.profile`) carrying a block that sources this scope's own `env.sh` is cleaned, not only the one file `pull` would choose today; a block sourcing a different scope's `env.sh` is left alone - In a project, teamai's git hook: the `hook.teamai-post-checkout` and `hook.teamai-post-merge` entries in the repository's git config, and the marked block in `.git/hooks/post-checkout` and `post-merge` (a script left with only its shebang, the one teamai created, is deleted). Other hooks are kept diff --git a/docs/usage-guide.zh-CN.md b/docs/usage-guide.zh-CN.md index 2151ad3c8..4d2f1d9d2 100644 --- a/docs/usage-guide.zh-CN.md +++ b/docs/usage-guide.zh-CN.md @@ -2141,7 +2141,7 @@ Qoder 已作为内置目标支持。TeamAI 会将 Skills、Rules 和 Subagents Rules 按 Qoder Desktop 写入的形式生成,Qoder CLI 也读取这种形式:带 `paths:` 的规则写成 `trigger: glob` 加一行不带引号、以逗号分隔的 `glob:`,由于该行会按每个逗号切分,`{a,b}` 形式的选择会展开为多个 glob;没有 `paths` 的规则写成 `trigger: always_on`。Qoder 未公开这种 frontmatter 的 schema,该形式取自 `alibaba/tron-one-agent` 中 Desktop 生成的规则文件。`push` 时只有 Markdown 正文回流;`.qoder/rules/` 中没有对应团队规则的文件属于你自己:`pull` 不会删除它,`push` 也不会把它当作新的团队规则提交。旧版 teamai 原样写入的副本,若仍是 teamai 写入的内容,下一次 `pull` 会改写为新格式;你修改过的副本会保留并给出提示。 -Qoder CN 是独立发行的版本,其**用户级**目录为 `~/.qoder-cn` 而非 `~/.qoder`,因此它作为独立的内置目标 `qoder-cn` 支持,而不是并入 `qoder`。两者仅用户作用域不同:用户级的资源写入 `~/.qoder-cn/{skills,rules,agents}`,Hooks 与 MCP 写入 `~/.qoder-cn/settings.json`;项目作用域则沿用 Qoder 的 `/.qoder/` 布局。两者读取相同的 Claude 兼容资源格式,因此下发内容一致,仅用户级根目录不同。同时安装两个版本时,TeamAI 会分别同步到各自的用户目录,无需再建软链接。 +Qoder CN 是独立发行的版本,其**用户级**目录为 `~/.qoder-cn` 而非 `~/.qoder`,因此它作为独立的内置目标 `qoder-cn` 支持,而不是并入 `qoder`。两者仅用户作用域不同:用户级的资源写入 `~/.qoder-cn/{skills,rules,agents}`,Hooks 与 MCP 写入 `~/.qoder-cn/settings.json`;项目作用域则沿用 Qoder 的 `/.qoder/` 布局。两者读取相同的 Claude 兼容资源格式,因此下发内容一致,仅用户级根目录不同。同时安装两个版本时,TeamAI 会分别同步到各自的用户目录,无需再建软链接。在项目中 Qoder 与 Qoder CN 都读取 `.qoder/rules/`,因此两者共用其中的一份副本:卸载其中一个时,只要另一个仍已安装,副本就会保留;`doctor` 也只检查一次,即 `Rules delivered to qoder, qoder-cn`。 ### Kiro @@ -2149,6 +2149,12 @@ Kiro 已作为内置目标支持。TeamAI 会将 Skills、Rules 和 Subagents Rules 以带 Kiro inclusion frontmatter 的 steering 文件写入 `.kiro/steering/` 与 `~/.kiro/steering/`:带 `paths:` 的规则写成 `inclusion: fileMatch`,并把其 glob 列表写入 `fileMatchPattern`;没有 `paths` 的规则写成 `inclusion: always`。`push` 时只有 Markdown 正文回流;没有对应团队规则的 steering 文件(例如 Kiro 自己生成的 `product.md`)属于你自己:`pull` 不会删除它,`push` 也不会把它当作新的团队规则提交。旧版 teamai 原样写入的副本,若仍是 teamai 写入的内容,下一次 `pull` 会改写为新格式;你修改过的副本会保留并给出提示。Kiro CLI 无论 `inclusion` 取值都会加载全部 steering 文件([kirodotdev/Kiro#7950](https://github.com/kirodotdev/Kiro/issues/7950)),因此在 CLI 中限定路径的规则也会始终生效。 +### CodeBuddy 与 WorkBuddy + +WorkBuddy 运行的是 CodeBuddy 的引擎,因此两者都按 CodeBuddy 的格式得到 rules:带 `paths:` 的规则写成 `alwaysApply: false`,并把 `paths:` 写成 YAML 块列表,每项一个带引号的 glob;没有 `paths` 的规则写成 `alwaysApply: true`。CodeBuddy 的 frontmatter 解析器按行读取而非按 YAML 解析,原样副本中的行内写法 `paths: ["a", "b"]` 会让它得到带方括号的 glob。在项目中两个工具都读取 `.codebuddy/rules/`,因此该目录为两者只保存每条规则的一份副本:排除或卸载其中一个工具时,只要另一个仍已安装,这些副本就会保留;`doctor` 也只检查该目录一次,即 `Rules delivered to codebuddy, workbuddy`。只有存在 `.workbuddy/` 时 WorkBuddy 才视为已安装。user scope 下 CodeBuddy 读取 `~/.codebuddy/rules/`,WorkBuddy 读取 `~/.workbuddy/rules/`。`push` 时只有 Markdown 正文回流;其中没有对应团队规则的文件属于你自己:`pull` 不会删除它,`push` 也不会把它当作新的团队规则提交。旧版 teamai 原样写入的副本,若仍是 teamai 写入的内容,下一次 `pull` 会改写为新格式;你修改过的副本会保留并给出提示。 + +> 升级说明:旧版本把 WorkBuddy 的项目 rules 写到 `.workbuddy/rules/`,而 WorkBuddy 从不读取该目录。下一次 `pull` 会删除其中仍是 teamai 投递内容的副本(目录清空后一并删除),并把 rules 写到 `.codebuddy/rules/`,即使团队仓库没有变化也是如此;你改过的副本会保留并点名。WorkBuddy 的一次性迁移把 `~/.codebuddy/rules/` 复制到了 `~/.workbuddy/rules/`(会留下 `~/.workbuddy/.migrated-from-codebuddy`),其中也包括团队规则的副本。复制过来的团队规则若仍是 teamai 投递的内容(该规则的某种渲染结果,或 teamai 记录的在 `~/.codebuddy/rules/` 下同名文件写入的字节),在 WorkBuddy 仍得到该规则时会改写为 CodeBuddy 格式,否则删除;改过的副本会保留并点名,你自己的规则保持不变。 + ### ZCode ZCode 已作为内置目标支持。Skills 下发到 `.zcode/skills/`(ZCode 同时会读取中央目录 `~/.agents/skills/`,该目录由 `agents` 条目覆盖),Subagents 以 Claude 风格 Markdown 下发到 `.zcode/agents/`。Hooks 会合并进共享的 `~/.zcode/cli/config.json`,并保留插件状态等无关键值。写入器为你处理了两个 ZCode 特有的细节: @@ -2219,7 +2225,7 @@ teamai remove rules --force # 跳过确认,用于脚本和 CI 除了托管平台、clone、配置和 hook 检查之外,`doctor` 还会验证落到本机上的内容。` is installed` 在 `enabledAgents` 列出了不会收到任何内容的工具时失败——这正是 pull 报告成功、而该工具什么都没收到的情况。它使用与同步相同的解析逻辑,因此像 OpenClaw 这样把 skills 放在 workspace 目录而非工具根目录的工具,会在同步真正写入的位置被判断。工具已安装时也会作为通过项报告,因此 `--json` 无论哪种情况都会为每个已启用工具给出一条记录。pull 结束时的检查只覆盖它从当前目录解析出的那个 scope;其他 scope 请在对应目录下运行 `teamai doctor`。`Skills delivered to ` 会把角色命名空间、标签订阅与排除规则解析出的 skill 集合,与每个已安装工具磁盘上的内容比对:从未送达的 skill 与送达但不可读的 skill 会分别报告——后者指 `SKILL.md` 缺失、frontmatter 无法解析,或其 `name` 与目录名不一致,导致 agent 永远发现不了它。`Team docs delivered` 将你应收到的文档(不含未激活的 docs namespace)与 `sharing.docs.localDir` 比对(它只有一个目标目录,而非每个工具一个);每个应有的文档都必须是可读取的文件,因此占用了该名字的目录或断链接也算缺失。它还会将本地多余的非隐藏文件报告为过期文档,即使团队文档已经删空也会检查;本地隐藏文件会保留,不会使检查失败,未激活 namespace 中团队文档的本地副本也不会:pull 会删除未修改的副本,并点名你修改过的副本。`doctor` 还会输出提示,它们只是信息,不是失败的检查。每条提示指出一个在本机替换了根目录条目的 namespace skill、agent、rule、共享指令文件、env 变量、hook、MCP server 或团队模型配置(`rules: "style" from rules/checkout/style.md replaces rules/style.md`)。当某个 namespace 提供了 env 变量、hook、MCP server 或团队模型配置时,还会有一条提示按来源统计该类型的条目(`env: 3 received here (2 root, 1 checkout)`)。未配置角色或项目时,提示改为列出团队仓库中重复定义的每个文件,以及在根文件中重复出现的每个 env 变量、hook 或 MCP server 名称。 -`Rules delivered to ` 与 `Agents delivered to ` 对另外两类按工具下发的资源做同样的事,并且都向 handler 询问落点,而不是自行拼路径:rule 的文件名和内容因工具而异(`.md` 原样、`.mdc` 带派生的 `globs`/`alwaysApply`、`.instructions.md` 带 `applyTo`、Kiro 的 `.md` 带 `inclusion`/`fileMatchPattern`、Qoder 的 `.md` 带 `trigger`/`glob`),agent 的落点来自渲染结果,且由 `targets:` 决定哪些工具应当收到。已送达的 rule 会与 handler 为该工具渲染出的字节逐一比对,而不只是检查该工具所需的键是否存在:`globs` 与团队 rule 的 `paths:` 不再一致的 `.mdc`,即使 `alwaysApply` 取值合法,也会作用到错误的文件上;这里会报告为 `delivered from an older copy`——正文漂移的副本同样如此,因为两者都写入成功,却都是错的。agent 会与渲染结果逐字节比对:旧版 spec 留下的副本(普通 pull 会跳过团队仓库未变化的 scope,它可能一直留在那里)报告为 `delivered from an older spec`,而不是当作已送达。`Every team agent reaches a tool` 会指出在任何已安装工具上都无法渲染的 agent,通常是 spec 解析失败,或 `targets:` 只列了本机没有的工具。这两项仅在 `doctor` 中运行:它们会按工具读取每条 rule、解析每个 agent,放进 pull 结束时的检查会耗尽其时间预算。 +`Rules delivered to ` 与 `Agents delivered to ` 对另外两类按工具下发的资源做同样的事,并且都向 handler 询问落点,而不是自行拼路径:rule 的文件名和内容因工具而异(`.md` 原样、`.mdc` 带派生的 `globs`/`alwaysApply`、`.instructions.md` 带 `applyTo`、Kiro 的 `.md` 带 `inclusion`/`fileMatchPattern`、Qoder 的 `.md` 带 `trigger`/`glob`、CodeBuddy 的 `.md` 带 `alwaysApply`/`paths`;共用一份副本的工具,例如项目中的 CodeBuddy 与 WorkBuddy,只有一项同时点名两者的检查),agent 的落点来自渲染结果,且由 `targets:` 决定哪些工具应当收到。已送达的 rule 会与 handler 为该工具渲染出的字节逐一比对,而不只是检查该工具所需的键是否存在:`globs` 与团队 rule 的 `paths:` 不再一致的 `.mdc`,即使 `alwaysApply` 取值合法,也会作用到错误的文件上;这里会报告为 `delivered from an older copy`——正文漂移的副本同样如此,因为两者都写入成功,却都是错的。agent 会与渲染结果逐字节比对:旧版 spec 留下的副本(普通 pull 会跳过团队仓库未变化的 scope,它可能一直留在那里)报告为 `delivered from an older spec`,而不是当作已送达。`Every team agent reaches a tool` 会指出在任何已安装工具上都无法渲染的 agent,通常是 spec 解析失败,或 `targets:` 只列了本机没有的工具。这两项仅在 `doctor` 中运行:它们会按工具读取每条 rule、解析每个 agent,放进 pull 结束时的检查会耗尽其时间预算。 有三个工具并不读取 rules 目录,按文件比对的检查无法代表它们,因此各自单列一项。`Team rules are active in opencode` 检查 `opencode.json`(项目中为 `.opencode/opencode.json`)的 `instructions` 中是否列着 teamai 所拥有的每条 glob,且没有过时的条目(如旧版本写入的相对 `rules/*.md`):OpenCode 不会自动扫描 `.opencode/rules`,缺了它,已送达的每个 `.md` 都不会生效,而按文件比对的检查依旧通过。user scope 下,`Team rules are inlined in Hermes SOUL.md` 把 `SOUL.md` 中 teamai 管理的代码块与团队 rule 内联后的内容比对——Hermes 的常驻指令来自这一个文件而非某个目录,因此代码块被删除或停留在旧版规则集上,都意味着该工具读到的是错误的规则,而磁盘上看不出任何异常。user scope 下,`Team rules are inlined in Codex AGENTS.md` 把该工具 `AGENTS.md` 中的 team-rules 区块与团队 rule 内联后的内容比对,旁边有 `AGENTS.override.md` 遮蔽该文件时也会失败。在项目中,当该工具 `hooks.json` 中的 teamai `SessionStart` 或 `SubagentStart` 条目缺失或没有设置 `additionalContextLimit: 0` 时,`Project rules and instructions reach whole through its session hooks` 会失败:没有它,Codex 对较大的内容只保留开头和结尾。Codex 没有 `Rules delivered to ` 检查。 @@ -2730,7 +2736,7 @@ teamai uninstall --agent claude - AI 工具 settings 中的 teamai hooks - 各工具指令文件中的 teamai 块(文化、共享指令、recall 以及 Codex 的团队规则),以及早期版本写过这些块的文件(保留用户自写内容;teamai 写入的 `teamai-context` 文件整体删除,若 OpenCode 中对应的 `instructions` 条目由 teamai 添加,则一并移除,即使你自写的内容让该文件保留下来,或该文件已不存在;你自己列入的条目予以保留) - 团队同步的 skills,包括 OpenClaw workspace skills(保留用户自建 skills) -- 团队同步的 rules,包括旧版本留在 `.codex/rules/` 中的副本,团队此后已删除的 rule 的副本也包括在内。清理使用记录的 `toolRoots` 位置和发布者本地的文件名。其中你改过的副本会保留,并在警告中点名。已删除 rule 的副本只有与记录的投递哈希一致时才会删除;没有该记录时也会保留并点名。Codex 的 `*.rules` 文件保留 +- 团队同步的 rules,包括旧版本留在 `.codex/rules/` 和项目 `.workbuddy/rules/` 中的副本,团队此后已删除的 rule 的副本也包括在内。项目 `.codebuddy/rules/` 中的副本,只要 CodeBuddy 与 WorkBuddy 中的另一个仍已安装就会保留。清理使用记录的 `toolRoots` 位置和发布者本地的文件名。其中你改过的副本会保留,并在警告中点名。已删除 rule 的副本只有与记录的投递哈希一致时才会删除;没有该记录时也会保留并点名。Codex 的 `*.rules` 文件保留 - 团队同步的自定义 agents 和 CLI 内置 agents(保留用户自建 agents) - Shell profile 中的 env 块——会清理每一个候选文件(`.zshrc`、`.bashrc`、`.bash_profile`、`.bash_login`、`.profile`)中、代码块指向本作用域自身 `env.sh` 的那些,而不仅仅是当前 `pull` 会选中的那一个;指向其他作用域 `env.sh` 的代码块不受影响 - 项目中 teamai 的 git hook:仓库 git 配置中的 `hook.teamai-post-checkout` 与 `hook.teamai-post-merge` 条目,以及 `.git/hooks/post-checkout` 与 `post-merge` 中带标记的代码块(移除后只剩 shebang 的脚本是 teamai 创建的,会被删除)。其他 hook 保留 diff --git a/skill-data/core/references/contribute-member.md b/skill-data/core/references/contribute-member.md index d0b1c0e95..303ba3120 100644 --- a/skill-data/core/references/contribute-member.md +++ b/skill-data/core/references/contribute-member.md @@ -119,7 +119,7 @@ The doc lands in the team's `learnings/` and appears for teammates on their next Before listing rules, `push` refreshes copies whose bodies still match a recorded sync revision. The header teamai generates for a tool's own rules format (Cursor and JoyCode `.mdc`, Copilot `applyTo`, Kiro `inclusion`, Qoder -`trigger`) does not count as a local edit: unedited old copies update in that +`trigger`, CodeBuddy and WorkBuddy `alwaysApply`) does not count as a local edit: unedited old copies update in that format, including under `COPILOT_HOME` in user scope. Genuine local body edits remain push candidates. Rule pre-sync leaves tools excluded by `enabledAgents` or `disabledAgents` untouched. When only team `paths` change, `applyTo` refreshes if the local file still matches diff --git a/skill-data/setup/references/uninstall.md b/skill-data/setup/references/uninstall.md index 8bcf17306..325a41fb1 100644 --- a/skill-data/setup/references/uninstall.md +++ b/skill-data/setup/references/uninstall.md @@ -17,7 +17,8 @@ machine** (all tools)?"* them. An instructions file several tools read (CodeBuddy and WorkBuddy share `.codebuddy/rules/teamai-context.md`) is cleaned block by block: a teamai block stays while a remaining tool on that file still writes it, so - `--agent workbuddy` keeps that file while CodeBuddy is installed. A file an + `--agent workbuddy` keeps that file while CodeBuddy is installed. The team + rules in a project's `.codebuddy/rules` are shared the same way. A file an earlier release wrote the blocks to, such as the project `AGENTS.md`, loses its teamai blocks, since no tool reads them there now. A file teamai created goes with its last block; one the user had before stays, even if empty. @@ -67,7 +68,8 @@ and give it your team repo URL."* and deletes `.opencode/opencode.json` when nothing else is left in it. The user's own entries stay. - Uninstall cleans legacy Codex rule copies at the recorded `toolRoots` - location, including publishers' bare local filenames. It keeps edited copies. + location, including publishers' bare local filenames, and the copies earlier + releases left in a project's `.workbuddy/rules`. It keeps edited copies. For a rule the team has removed, it deletes the copy only if its hash matches the recorded delivery. Without that record, it keeps the copy and names it in a warning. Save any diff --git a/src/__tests__/codebuddy-workbuddy-rules.test.ts b/src/__tests__/codebuddy-workbuddy-rules.test.ts new file mode 100644 index 000000000..ea4ae3010 --- /dev/null +++ b/src/__tests__/codebuddy-workbuddy-rules.test.ts @@ -0,0 +1,274 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import path from 'node:path'; +import os from 'node:os'; +import fse from 'fs-extra'; + +vi.mock('../config.js', async (importOriginal) => ({ + ...(await importOriginal()), + loadStateForScope: vi.fn(async () => ({})), + saveStateForScope: vi.fn(), +})); + +vi.mock('../utils/logger.js', () => ({ + log: { persist: vi.fn(), info: vi.fn(), success: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn(), dim: vi.fn() }, +})); + +import crypto from 'node:crypto'; +import { RulesHandler } from '../resources/rules.js'; +import { openLedger } from '../resources/delivered-copies.js'; +import { log } from '../utils/logger.js'; +import { TeamaiConfigSchema } from '../types.js'; +import type { LocalConfig, TeamaiConfig } from '../types.js'; + +/** + * CodeBuddy and WorkBuddy run the same engine and read CodeBuddy's rules + * format (#946). In a project both read `.codebuddy/rules`, so they share one + * copy there; in user scope each has its own home. + */ +const SCOPED = '---\npaths: ["src/**/*.ts", "test/**"]\n---\n\nUse named exports.\n'; +const RENDER = '---\nalwaysApply: false\npaths:\n - "src/**/*.ts"\n - "test/**"\n---\n\nUse named exports.\n'; + +describe('CodeBuddy and WorkBuddy rules (#946)', () => { + let tmpDir: string; + let homeDir: string; + let projectRoot: string; + let repoPath: string; + let handler: RulesHandler; + let teamConfig: TeamaiConfig; + let localConfig: LocalConfig; + + const home = (rel: string) => path.join(homeDir, rel); + const project = (rel: string) => path.join(projectRoot, rel); + + beforeEach(async () => { + tmpDir = await fse.mkdtemp(path.join(os.tmpdir(), 'teamai-codebuddy-rules-')); + homeDir = path.join(tmpDir, 'home'); + projectRoot = path.join(tmpDir, 'project'); + repoPath = path.join(tmpDir, 'team-repo'); + await fse.outputFile(path.join(repoPath, 'rules', 'scoped.md'), SCOPED); + await fse.ensureDir(projectRoot); + vi.stubEnv('HOME', homeDir); + vi.clearAllMocks(); + handler = new RulesHandler(); + teamConfig = TeamaiConfigSchema.parse({ team: 'test', repo: 'https://example.invalid/x/team.git' }); + localConfig = { + repo: { localPath: repoPath, remote: 'https://example.invalid/x/team.git' }, + username: 'u', + additionalRoles: [], + scope: 'user', + enabledAgents: ['codebuddy', 'workbuddy'], + } as unknown as LocalConfig; + }); + + afterEach(async () => { + vi.unstubAllEnvs(); + await fse.remove(tmpDir); + }); + + const inProject = () => { + localConfig = { ...localConfig, scope: 'project', projectRoot } as LocalConfig; + }; + + it('writes CodeBuddy\'s render to ~/.codebuddy/rules and ~/.workbuddy/rules in user scope', async () => { + await fse.ensureDir(home('.codebuddy')); + await fse.ensureDir(home('.workbuddy')); + + await handler.pullAllRules(teamConfig, localConfig); + + expect(await fse.readFile(home('.codebuddy/rules/scoped.md'), 'utf8')).toBe(RENDER); + expect(await fse.readFile(home('.workbuddy/rules/scoped.md'), 'utf8')).toBe(RENDER); + }); + + it('writes one copy to the project\'s .codebuddy/rules for both tools, and nothing to .workbuddy/rules', async () => { + inProject(); + await fse.ensureDir(project('.codebuddy')); + await fse.ensureDir(project('.workbuddy')); + + await handler.pullAllRules(teamConfig, localConfig); + + expect(await fse.readFile(project('.codebuddy/rules/scoped.md'), 'utf8')).toBe(RENDER); + expect(await fse.pathExists(project('.workbuddy/rules'))).toBe(false); + const targets = await handler.deliveryTargets(teamConfig, localConfig, (await handler.scanTeamForPull(teamConfig, localConfig))[0]); + expect(targets.map(({ dest }) => dest)).toEqual([project('.codebuddy/rules/scoped.md')]); + }); + + it('shares Qoder and Qoder CN\'s one project copy the same way, any two tools reading one file in one render', async () => { + inProject(); + localConfig = { ...localConfig, enabledAgents: ['qoder', 'qoder-cn'] } as LocalConfig; + await fse.ensureDir(project('.qoder')); + + const targets = await handler.deliveryTargets(teamConfig, localConfig, (await handler.scanTeamForPull(teamConfig, localConfig))[0]); + + expect(targets).toMatchObject([{ tool: 'qoder', dest: project('.qoder/rules/scoped.md'), sharedWith: ['qoder-cn'] }]); + }); + + it('delivers to .codebuddy/rules for WorkBuddy alone, probing .workbuddy for its install', async () => { + inProject(); + localConfig = { ...localConfig, enabledAgents: ['workbuddy'] } as LocalConfig; + + await handler.pullAllRules(teamConfig, localConfig); + expect(await fse.pathExists(project('.codebuddy/rules/scoped.md'))).toBe(false); + + await fse.ensureDir(project('.workbuddy')); + await handler.pullAllRules(teamConfig, localConfig); + expect(await fse.readFile(project('.codebuddy/rules/scoped.md'), 'utf8')).toBe(RENDER); + }); + + describe('the shared project copy', () => { + beforeEach(async () => { + inProject(); + await fse.ensureDir(project('.codebuddy')); + await fse.ensureDir(project('.workbuddy')); + await handler.pullAllRules(teamConfig, localConfig); + }); + + it.each([['codebuddy'], ['workbuddy']])('stays while only %s is enabled', async (tool) => { + await fse.outputFile(path.join(repoPath, 'rules', 'scoped.md'), SCOPED.replace('named', 'default')); + localConfig = { ...localConfig, enabledAgents: [tool] } as LocalConfig; + + await handler.pullAllRules(teamConfig, localConfig); + + expect(await fse.readFile(project('.codebuddy/rules/scoped.md'), 'utf8')).toBe(RENDER.replace('named', 'default')); + }); + + it('is not offered for push after a clean pull, and an edited body pushes without the CodeBuddy frontmatter', async () => { + expect(await handler.scanLocalForPush(teamConfig, localConfig)).toEqual([]); + + await fse.writeFile(project('.codebuddy/rules/scoped.md'), RENDER.replace('named', 'default')); + const items = await handler.scanLocalForPush(teamConfig, localConfig); + expect(items).toMatchObject([{ name: 'scoped', status: 'modified' }]); + await handler.pushItem(items[0], teamConfig, localConfig); + + expect(await fse.readFile(path.join(repoPath, 'rules', 'scoped.md'), 'utf8')).toBe(SCOPED.replace('named', 'default')); + }); + + it("keeps a member's own CodeBuddy rule there on pull and never offers it as a new team rule", async () => { + const mine = project('.codebuddy/rules/mine.md'); + await fse.writeFile(mine, '---\nalwaysApply: true\n---\n\nMine.\n'); + + await handler.pullAllRules(teamConfig, localConfig); + + expect(await fse.pathExists(mine)).toBe(true); + expect(await handler.scanLocalForPush(teamConfig, localConfig)).toEqual([]); + }); + }); + + const sha256 = (text: string) => crypto.createHash('sha256').update(text).digest('hex'); + const warnings = () => vi.mocked(log.warn).mock.calls.map(([message]) => String(message)); + + describe('a project\'s .workbuddy/rules, which WorkBuddy never read', () => { + const legacy = (file: string) => project(`.workbuddy/rules/${file}`); + + beforeEach(async () => { + inProject(); + await fse.ensureDir(project('.workbuddy')); + }); + + it('reclaims the copies an older pull wrote there, and delivers to .codebuddy/rules instead', async () => { + await fse.outputFile(legacy('scoped.md'), SCOPED); + await fse.outputFile(legacy('mine.md'), 'My own WorkBuddy note.\n'); + + await handler.pullAllRules(teamConfig, localConfig); + + expect(await fse.pathExists(legacy('scoped.md'))).toBe(false); + expect(await fse.readFile(legacy('mine.md'), 'utf8')).toBe('My own WorkBuddy note.\n'); + expect(await fse.readFile(project('.codebuddy/rules/scoped.md'), 'utf8')).toBe(RENDER); + expect(warnings()).toEqual([]); + }); + + it('removes the directory once nothing else is in it, and also for a WorkBuddy that is excluded', async () => { + localConfig = { ...localConfig, enabledAgents: ['claude'] } as LocalConfig; + await fse.outputFile(legacy('scoped.md'), SCOPED); + + await handler.pullAllRules(teamConfig, localConfig); + + expect(await fse.pathExists(project('.workbuddy/rules'))).toBe(false); + expect(await fse.pathExists(project('.workbuddy'))).toBe(true); + }); + + it('keeps an edited copy and names it once, saying where WorkBuddy reads a project\'s rules', async () => { + const edited = SCOPED.replace('named', 'my own'); + await fse.outputFile(legacy('scoped.md'), edited); + + await handler.pullAllRules(teamConfig, localConfig); + + expect(await fse.readFile(legacy('scoped.md'), 'utf8')).toBe(edited); + expect(warnings()).toHaveLength(1); + expect(warnings()[0]).toContain(legacy('scoped.md')); + expect(warnings()[0]).toContain('WorkBuddy reads a project\'s rules from .codebuddy/rules'); + expect(warnings()[0]).not.toContain('Codex'); + }); + }); + + describe('the team rules WorkBuddy\'s one-time migration copied from ~/.codebuddy/rules', () => { + const migrated = (file: string) => home(`.workbuddy/rules/${file}`); + + beforeEach(async () => { + await fse.ensureDir(home('.codebuddy')); + await fse.outputFile(home('.workbuddy/.migrated-from-codebuddy'), '2026-09-01T00:00:00.000Z'); + await fse.outputFile(path.join(repoPath, 'rules', 'backend.md'), 'Backend rule.\n'); + await fse.outputFile(path.join(repoPath, 'rules', 'frontend.md'), 'Frontend rule.\n'); + }); + + it('re-renders a copy still delivered, removes unedited ones no longer delivered, and keeps the rest', async () => { + // Delivered here: verbatim, as ~/.codebuddy/rules held it. + await fse.outputFile(migrated('scoped.md'), SCOPED); + // Not delivered to this member (filtered out): verbatim. + await fse.outputFile(migrated('backend.md'), 'Backend rule.\n'); + // Not delivered: an older version, proven only by ~/.codebuddy/rules' record. + await fse.outputFile(migrated('frontend.md'), 'Frontend rule, older.\n'); + // Not delivered, and the member changed it. + await fse.outputFile(home('.workbuddy/rules/edited.md'), 'Edited.\n'); + await fse.outputFile(path.join(repoPath, 'rules', 'edited.md'), 'Team version.\n'); + // The member's own CodeBuddy rule, migrated with the rest. + await fse.outputFile(migrated('mine.md'), 'Mine.\n'); + const ledger = openLedger({ [home('.codebuddy/rules/frontend.md')]: sha256('Frontend rule, older.\n') }); + const rules = (await handler.scanTeamForPull(teamConfig, localConfig)).filter((rule) => rule.name === 'scoped'); + + await handler.pullAllRules(teamConfig, localConfig, rules, [], ledger); + + expect(await fse.readFile(migrated('scoped.md'), 'utf8')).toBe(RENDER); + expect(ledger.hashes[migrated('scoped.md')]).toBe(sha256(RENDER)); + expect(await fse.pathExists(migrated('backend.md'))).toBe(false); + expect(await fse.pathExists(migrated('frontend.md'))).toBe(false); + expect(await fse.readFile(migrated('edited.md'), 'utf8')).toBe('Edited.\n'); + expect(await fse.readFile(migrated('mine.md'), 'utf8')).toBe('Mine.\n'); + expect(warnings()).toHaveLength(1); + expect(warnings()[0]).toContain(migrated('edited.md')); + expect(warnings()[0]).toContain('~/.codebuddy/rules'); + expect(warnings()[0]).not.toContain(migrated('mine.md')); + }); + + it('keeps an edited copy of a rule still delivered, as an edit of the copy it came from (#822)', async () => { + const source = home('.codebuddy/rules/scoped.md'); + const edited = SCOPED.replace('named', 'my own'); + await fse.outputFile(migrated('scoped.md'), edited); + const ledger = openLedger({ [source]: sha256(SCOPED) }); + + await handler.pullAllRules(teamConfig, localConfig, undefined, [], ledger); + + expect(await fse.readFile(migrated('scoped.md'), 'utf8')).toBe(edited); + expect(ledger.kept.map(({ dest }) => dest)).toContain(migrated('scoped.md')); + expect(ledger.hashes[migrated('scoped.md')]).toBe(sha256(SCOPED)); + }); + + it.each([ + ['without the migration marker', async () => { await fse.remove(home('.workbuddy/.migrated-from-codebuddy')); }, {}], + ['while WorkBuddy is excluded', async () => {}, { enabledAgents: ['codebuddy'] }], + ])('leaves ~/.workbuddy/rules alone %s', async (_label, arrange, config) => { + await arrange(); + localConfig = { ...localConfig, ...config } as LocalConfig; + await fse.outputFile(migrated('backend.md'), 'Backend rule.\n'); + await fse.outputFile(migrated('edited.md'), 'Edited.\n'); + await fse.outputFile(path.join(repoPath, 'rules', 'edited.md'), 'Team version.\n'); + const rules = (await handler.scanTeamForPull(teamConfig, localConfig)).filter((rule) => rule.name === 'scoped'); + + await handler.pullAllRules(teamConfig, localConfig, rules, [], openLedger({})); + + expect(await fse.readFile(migrated('backend.md'), 'utf8')).toBe('Backend rule.\n'); + expect(await fse.readFile(migrated('edited.md'), 'utf8')).toBe('Edited.\n'); + expect(warnings().filter((message) => message.includes('copied it from'))).toEqual([]); + }); + }); +}); + diff --git a/src/__tests__/doctor-rules-delivery.test.ts b/src/__tests__/doctor-rules-delivery.test.ts index 4245c9f9e..e2741ee4a 100644 --- a/src/__tests__/doctor-rules-delivery.test.ts +++ b/src/__tests__/doctor-rules-delivery.test.ts @@ -225,6 +225,49 @@ describe('doctor — rules delivered on disk', () => { expect(check.fix).not.toContain('.mdc'); }); + describe('CodeBuddy and WorkBuddy (#946)', () => { + const ALWAYS = '---\nalwaysApply: true\n---\n\n'; + const defaults = TeamaiConfigSchema.parse({ team: 't', repo: 'owner/repo' }).toolPaths; + + async function deliverCodebuddy(dir: string): Promise { + for (const name of ['coding-style', 'reviews']) await fse.outputFile(path.join(dir, `${name}.md`), `${ALWAYS}Body of ${name}\n`); + } + + beforeEach(() => { + teamConfig.toolPaths = { codebuddy: defaults.codebuddy, workbuddy: defaults.workbuddy }; + }); + + it('checks the shared project .codebuddy/rules once, naming both tools', async () => { + const projectRoot = path.join(tempDir, 'project'); + Object.assign(localConfig, { scope: 'project', projectRoot }); + await fse.ensureDir(path.join(projectRoot, '.workbuddy')); + await deliverCodebuddy(path.join(projectRoot, '.codebuddy/rules')); + + const rules = (await checks()).filter((c) => c.name.startsWith('Rules delivered to')); + expect(rules.map((c) => c.name)).toEqual(['Rules delivered to codebuddy, workbuddy']); + expect(await rules[0].check()).toBe(true); + + // A verbatim copy, as teamai wrote it before: CodeBuddy ignores its `paths:` form. + await fse.writeFile(path.join(projectRoot, '.codebuddy/rules/reviews.md'), 'Body of reviews\n'); + const check = await rulesCheck('codebuddy, workbuddy'); + expect(await check.check()).toBe(false); + expect(check.fix).toContain(path.join(projectRoot, '.codebuddy/rules')); + expect(check.fix).toContain('delivered from an older copy: reviews'); + expect(check.fix).toContain('`alwaysApply` or `paths`'); + }); + + it('checks WorkBuddy\'s user rules in ~/.workbuddy/rules', async () => { + await fse.ensureDir(path.join(homeDir, '.codebuddy')); + await deliverCodebuddy(path.join(homeDir, '.workbuddy/rules')); + + const workbuddy = await rulesCheck('workbuddy'); + expect(await workbuddy.check()).toBe(true); + const codebuddy = await rulesCheck('codebuddy'); + expect(await codebuddy.check()).toBe(false); + expect(codebuddy.fix).toContain(path.join(homeDir, '.codebuddy/rules')); + }); + }); + it('passes a copy the member changed since teamai delivered it, which pull keeps (#822)', async () => { const edited = path.join(homeDir, CLAUDE_RULES, 'reviews.md'); await fse.writeFile(edited, 'My own version\n'); diff --git a/src/__tests__/e2e/doctor-delivery-cli.test.ts b/src/__tests__/e2e/doctor-delivery-cli.test.ts index 3bc0aa867..d33da57cc 100644 --- a/src/__tests__/e2e/doctor-delivery-cli.test.ts +++ b/src/__tests__/e2e/doctor-delivery-cli.test.ts @@ -171,7 +171,7 @@ describe('teamai doctor delivery checks (e2e)', () => { // the delivered copy with the render, so a placeholder is a stale copy. write(path.join(home, '.claude/agents/reviewer.md'), CLAUDE_AGENT_MD); write(path.join(home, '.codex/agents/reviewer.toml'), CODEX_AGENT_TOML); - write(path.join(home, '.codebuddy/rules/coding-style.md'), 'Coding style body\n'); + write(path.join(home, '.codebuddy/rules/coding-style.md'), '---\nalwaysApply: true\n---\n\nCoding style body\n'); write(path.join(home, '.codebuddy/agents/reviewer.md'), CLAUDE_AGENT_MD); write(path.join(home, '.config/opencode/rules/coding-style.md'), 'Coding style body\n'); // The glob the pull adds; without it every .md above is inert. diff --git a/src/__tests__/helpers/rule-parsers.ts b/src/__tests__/helpers/rule-parsers.ts index ae1e82a55..4e861bf76 100644 --- a/src/__tests__/helpers/rule-parsers.ts +++ b/src/__tests__/helpers/rule-parsers.ts @@ -7,7 +7,7 @@ import path from 'node:path'; * `TEAMAI_RULE_PARSER_BUNDLES` at them, as `=` entries separated * by the platform's path delimiter, e.g. * - * TEAMAI_RULE_PARSER_BUNDLES=cursor=$HOME/.local/share/cursor-agent/versions/ + * TEAMAI_RULE_PARSER_BUNDLES=cursor=$HOME/.local/share/cursor-agent/versions/:codebuddy=/node_modules/@tencent-ai/codebuddy-code * * A test whose tool has no entry is skipped; CI runs the byte-exact contract * tests instead. A loader for another tool goes here beside Cursor's. @@ -75,3 +75,45 @@ export function loadCursorRuleParser(bundle: string): CursorRuleParser { ].join('\n')); return factory() as CursorRuleParser; } + +export interface CodebuddyRuleParser { + /** CodeBuddy's read of one rule file: ALWAYS, or MANUAL (path-scoped) with its globs. */ + parse(file: string): Promise<{ type: string; globs: string[] | undefined; alwaysApply: boolean; content: string }>; +} + +/** The source of the class method `head` starts, braces matched; `head` must be unique in `source`. */ +function methodSource(source: string, head: RegExp, file: string): string { + const at = source.search(head); + if (at < 0) throw new Error(`no ${head} in ${file}`); + return functionSource(source, at); +} + +/** + * CodeBuddy Code's memory parser, out of `dist/codebuddy.js` in the + * `@tencent-ai/codebuddy-code` package (checked against 2.160.0). WorkBuddy's + * engine (`dist/codebuddy-lite-wb.mjs`) ships the same parser. The parser + * class is found by its `scope`/`autoGeneratedGlobs` options and the + * frontmatter helpers by their names on `MarkdownUtils`, which the build keeps; + * the module aliases the parser reaches them through are rewritten to locals. + */ +export function loadCodebuddyRuleParser(bundle: string): CodebuddyRuleParser { + const file = fs.statSync(bundle).isDirectory() ? path.join(bundle, 'dist', 'codebuddy.js') : bundle; + const source = fs.readFileSync(file, 'utf8'); + + const helpers = ['extractFrontMatterWithContent', 'parseListField', 'splitByCommaRespectingBraces'] + .map((name) => methodSource(source, new RegExp(`static ${name}\\(`), file)); + const parse = methodSource(source, /async parse\([\w$]+,[\w$]+\)\{let [\w$]+,\{scope:[\w$]+,autoGeneratedGlobs:/, file); + const parseGlobs = methodSource(source, /parseGlobs\([\w$]+\)\{if\(![\w$]+\.paths\)return;/, file); + const local = (code: string): string => code + .replace(/\(0,[\w$]+\.readFile\)/g, 'readFile') + .replace(/[\w$]+\.MarkdownUtils\./g, 'MarkdownUtils.') + .replace(/[\w$]+\.[\w$]+\.(MANUAL|ALWAYS)\b/g, '"$1"'); + + const factory = new Function('readFile', [ + `class MarkdownUtils { ${helpers.join('\n')} }`, + `class Parser { ${local(parse)}\n${local(parseGlobs)} }`, + 'const parser = new Parser();', + 'return { parse: (file) => parser.parse(file, { scope: "project" }) };', + ].join('\n')); + return factory(fs.promises.readFile) as CodebuddyRuleParser; +} diff --git a/src/__tests__/pre-push-sync.test.ts b/src/__tests__/pre-push-sync.test.ts index 0c8e07de0..432cfeb67 100644 --- a/src/__tests__/pre-push-sync.test.ts +++ b/src/__tests__/pre-push-sync.test.ts @@ -323,15 +323,16 @@ describe('syncTeamUpdatesToLocal — rules', () => { }); it('should sync all installed tool directories', async () => { - // Add a second tool - await fse.ensureDir(path.join(homeDir, '.workbuddy', 'rules')); - teamConfig.toolPaths.workbuddy = { skills: '.workbuddy/skills', rules: '.workbuddy/rules' }; + // Add a second tool that takes the team rule verbatim (WorkBuddy now gets + // CodeBuddy's render, #946) + await fse.ensureDir(path.join(homeDir, '.tclaude', 'rules')); + teamConfig.toolPaths.tclaude = { skills: '.tclaude/skills', rules: '.tclaude/rules' }; // Team repo has v2 await fse.writeFile(path.join(repoPath, 'rules', 'shared.md'), 'v2'); // Both tool dirs have v1 await fse.writeFile(path.join(homeDir, '.claude/rules', 'shared.md'), 'v1'); - await fse.writeFile(path.join(homeDir, '.workbuddy/rules', 'shared.md'), 'v1'); + await fse.writeFile(path.join(homeDir, '.tclaude/rules', 'shared.md'), 'v1'); // Old team repo was v1 mockGetFileContentAtRev.mockResolvedValue(Buffer.from('v1')); @@ -339,7 +340,7 @@ describe('syncTeamUpdatesToLocal — rules', () => { // Both should now have v2 const claudeContent = await fse.readFile(path.join(homeDir, '.claude/rules', 'shared.md'), 'utf-8'); - const wbContent = await fse.readFile(path.join(homeDir, '.workbuddy/rules', 'shared.md'), 'utf-8'); + const wbContent = await fse.readFile(path.join(homeDir, '.tclaude/rules', 'shared.md'), 'utf-8'); expect(claudeContent).toBe('v2'); expect(wbContent).toBe('v2'); }); diff --git a/src/__tests__/pull-workbuddy-rules-move.test.ts b/src/__tests__/pull-workbuddy-rules-move.test.ts new file mode 100644 index 000000000..3e9922260 --- /dev/null +++ b/src/__tests__/pull-workbuddy-rules-move.test.ts @@ -0,0 +1,146 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import crypto from 'node:crypto'; +import path from 'node:path'; +import os from 'node:os'; +import fse from 'fs-extra'; + +vi.mock('../config.js', async (importOriginal) => ({ + ...(await importOriginal()), + requireInit: vi.fn(), + loadState: vi.fn().mockResolvedValue({ lastPull: null, lastPullRev: null }), + saveState: vi.fn(), + loadStateForScope: vi.fn(async () => ({})), + saveStateForScope: vi.fn(), + loadLocalConfigForScope: vi.fn(), + loadTeamConfig: vi.fn(), + detectProjectConfig: vi.fn().mockResolvedValue(null), + autoDetectInit: vi.fn(), +})); + +vi.mock('../utils/git.js', async (importOriginal) => ({ + ...(await importOriginal()), + pullRepo: vi.fn().mockResolvedValue('already up to date'), + getHeadRev: vi.fn().mockResolvedValue('abc1234'), + createGit: vi.fn(), +})); + +// pull() takes a real ~/.teamai/.sync-lock; parallel workers would race on it. +vi.mock('../update.js', () => ({ + acquireLock: vi.fn().mockResolvedValue(true), + releaseLock: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock('../utils/logger.js', () => ({ + log: { + persist: vi.fn(), + info: vi.fn(), + success: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + dim: vi.fn(), + }, + spinner: vi.fn(() => ({ + start: vi.fn().mockReturnThis(), + succeed: vi.fn().mockReturnThis(), + fail: vi.fn().mockReturnThis(), + warn: vi.fn().mockReturnThis(), + info: vi.fn().mockReturnThis(), + stop: vi.fn().mockReturnThis(), + })), +})); + +import { pull } from '../pull.js'; +import { log } from '../utils/logger.js'; +import { detectProjectConfig, loadStateForScope, loadTeamConfig, saveStateForScope } from '../config.js'; +import { TeamaiConfigSchema, type LocalConfig, type State } from '../types.js'; + +const sha256 = (text: string) => crypto.createHash('sha256').update(text).digest('hex'); + +/** + * WorkBuddy's project rules move from `.workbuddy/rules`, which it never read, + * to CodeBuddy's `.codebuddy/rules` (#946). The new path has no delivery + * record, so a pull at an unchanged team revision must still write it. + */ +describe('a pull at an unchanged team revision after WorkBuddy\'s project rules move (#946)', () => { + let tmpDir: string; + let projectRoot: string; + let saved: State; + + const SCOPED = '---\npaths: ["src/**"]\n---\n\nUse named exports.\n'; + const RENDER = '---\nalwaysApply: false\npaths:\n - "src/**"\n---\n\nUse named exports.\n'; + const oldCopy = () => path.join(projectRoot, '.workbuddy', 'rules', 'scoped.md'); + const newCopy = () => path.join(projectRoot, '.codebuddy', 'rules', 'scoped.md'); + const record = () => Object.values(saved.lastPullByWorkspace ?? {})[0]; + + beforeEach(async () => { + tmpDir = await fse.mkdtemp(path.join(os.tmpdir(), 'teamai-workbuddy-move-')); + const homeDir = path.join(tmpDir, 'home'); + projectRoot = path.join(tmpDir, 'project'); + const repoPath = path.join(tmpDir, 'team-repo'); + await fse.ensureDir(homeDir); + await fse.ensureDir(path.join(projectRoot, '.workbuddy')); + await fse.outputFile(path.join(repoPath, 'rules', 'scoped.md'), SCOPED); + vi.stubEnv('HOME', homeDir); + saved = {} as State; + vi.mocked(saveStateForScope).mockImplementation(async (state) => { + saved = structuredClone(state); + }); + vi.mocked(loadStateForScope).mockImplementation(async () => structuredClone(saved) as never); + vi.mocked(loadTeamConfig).mockResolvedValue( + TeamaiConfigSchema.parse({ team: 'test', repo: 'https://example.invalid/x/team.git' }), + ); + vi.mocked(detectProjectConfig).mockResolvedValue({ + repo: { localPath: repoPath, remote: 'https://example.invalid/x/team.git' }, + username: 'u', + updatePolicy: 'auto', + additionalRoles: [], + scope: 'project', + projectRoot, + enabledAgents: ['workbuddy'], + } as LocalConfig); + await pull({}); + // What an older CLI left at this revision: the verbatim copy in + // .workbuddy/rules, on record, and nothing in .codebuddy/rules. + await fse.remove(newCopy()); + await fse.outputFile(oldCopy(), SCOPED); + const { [newCopy()]: _dropped, ...rest } = record().delivered ?? {}; + record().delivered = { ...rest, [oldCopy()]: sha256(SCOPED) }; + vi.mocked(log.success).mockClear(); + vi.mocked(log.warn).mockClear(); + }); + + afterEach(async () => { + vi.mocked(saveStateForScope).mockReset(); + vi.mocked(loadStateForScope).mockImplementation(async () => ({}) as never); + vi.mocked(detectProjectConfig).mockResolvedValue(null); + vi.unstubAllEnvs(); + await fse.remove(tmpDir); + }); + + it('writes the rule to .codebuddy/rules, records it, and reclaims the .workbuddy/rules copy', async () => { + await pull({}); + + const successes = vi.mocked(log.success).mock.calls.map(([message]) => String(message)); + expect(successes.some((message) => message.includes('Already synced at abc1234'))).toBe(true); + expect(await fse.readFile(newCopy(), 'utf8')).toBe(RENDER); + expect(record().delivered?.[newCopy()]).toBe(sha256(RENDER)); + expect(await fse.pathExists(oldCopy())).toBe(false); + expect(record().delivered?.[oldCopy()]).toBeUndefined(); + expect(await fse.pathExists(path.dirname(oldCopy()))).toBe(false); + expect(vi.mocked(log.warn)).not.toHaveBeenCalled(); + }); + + it('also writes .codebuddy/rules for a .workbuddy/rules copy the member edited, which it keeps and names', async () => { + const edited = SCOPED.replace('named', 'my own'); + await fse.writeFile(oldCopy(), edited); + + await pull({}); + + expect(await fse.readFile(newCopy(), 'utf8')).toBe(RENDER); + expect(record().delivered?.[newCopy()]).toBe(sha256(RENDER)); + expect(await fse.readFile(oldCopy(), 'utf8')).toBe(edited); + const warnings = vi.mocked(log.warn).mock.calls.map(([message]) => String(message)); + expect(warnings.filter((message) => message.includes(`Kept ${oldCopy()}`))).toHaveLength(1); + }); +}); diff --git a/src/__tests__/push-namespace-e2e.test.ts b/src/__tests__/push-namespace-e2e.test.ts index ccef65666..963c14ee1 100644 --- a/src/__tests__/push-namespace-e2e.test.ts +++ b/src/__tests__/push-namespace-e2e.test.ts @@ -21,6 +21,13 @@ const CLI = path.join(ROOT, 'dist', 'index.js'); const PUSH_AGENTS = ['claude', 'codex', 'codebuddy', 'opencode'] as const; type PushAgent = (typeof PUSH_AGENTS)[number]; +/** + * Whether a new file in the tool's rules directory is a new team rule. A tool + * with a rules format of its own keeps the member's own rules there, in that + * format, so push never offers one (CodeBuddy since #946, as Cursor). + */ +const pushesNewRule = (agent: PushAgent): boolean => agent !== 'codebuddy'; + const GIT_ENV = { GIT_AUTHOR_NAME: 'TeamAI CI', GIT_AUTHOR_EMAIL: 'ci@teamai.test', @@ -278,12 +285,14 @@ describe('push places new rules and agents in a namespace (issue #649)', () => { ); // The generic git provider cannot open a PR; the branch is still pushed. - expect(result.output).toContain('[rules] my-rule → rules/fe-know/my-rule.md'); + if (pushesNewRule(agent)) expect(result.output).toContain('[rules] my-rule → rules/fe-know/my-rule.md'); + else expect(result.output).not.toContain('[rules] my-rule'); expect(result.output).toContain('[agents] vr → agents/fe-agents/vr.yaml'); const { branch, files } = branchFiles(fixture); expect(branch, result.output).not.toBe(''); - expect(files).toContain('rules/fe-know/my-rule.md'); + if (pushesNewRule(agent)) expect(files).toContain('rules/fe-know/my-rule.md'); + else expect(files).not.toContain('rules/fe-know/my-rule.md'); expect(files).toContain('skills/fe-skills/my-skill/SKILL.md'); expect(files).toContain('agents/fe-agents/vr.yaml'); // The shared root is what shipped the rule to the whole team before #649. @@ -296,7 +305,9 @@ describe('push places new rules and agents in a namespace (issue #649)', () => { const state = readState(fixture) as { placedRules?: unknown; pendingPushes: Array<{ items: Array> }> }; expect(state.placedRules ?? {}).toEqual({}); expect(state.pendingPushes.at(-1)?.items).toEqual(expect.arrayContaining([ - expect.objectContaining({ type: 'rules', name: 'my-rule', relativePath: 'rules/fe-know/my-rule.md', placed: true }), + ...pushesNewRule(agent) + ? [expect.objectContaining({ type: 'rules', name: 'my-rule', relativePath: 'rules/fe-know/my-rule.md', placed: true })] + : [], expect.objectContaining({ type: 'agents', name: 'vr', relativePath: 'agents/fe-agents/vr.yaml', placed: true }), ])); }, 60_000); @@ -948,7 +959,7 @@ describe('push namespace placement reaches the PR providers (issue #649)', () => expect(result.output).toContain('Pull Request created: https://github.com/team/issue-649/pull/649'); expect(fs.readFileSync(ghLog, 'utf8')).toContain('pr create -R team/issue-649'); const { files } = branchFiles(fixture); - expect(files).toContain('rules/fe-know/my-rule.md'); + if (pushesNewRule(agent)) expect(files).toContain('rules/fe-know/my-rule.md'); expect(files).toContain('agents/fe-agents/vr.yaml'); expect(files).not.toContain('rules/my-rule.md'); }, 60_000); @@ -994,7 +1005,7 @@ describe('push namespace placement reaches the PR providers (issue #649)', () => ); expect(requestPaths).toEqual(['/api/v4/projects/team%2Fissue-649/merge_requests']); const { files } = branchFiles(fixture); - expect(files).toContain('rules/fe-know/my-rule.md'); + if (pushesNewRule(agent)) expect(files).toContain('rules/fe-know/my-rule.md'); expect(files).toContain('agents/fe-agents/vr.yaml'); expect(files).not.toContain('rules/my-rule.md'); } finally { diff --git a/src/__tests__/rule-parsers.test.ts b/src/__tests__/rule-parsers.test.ts index 545055f9e..063b7bd10 100644 --- a/src/__tests__/rule-parsers.test.ts +++ b/src/__tests__/rule-parsers.test.ts @@ -1,6 +1,10 @@ -import { describe, expect, it } from 'vitest'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { afterAll, describe, expect, it } from 'vitest'; +import { teamRuleToCodebuddyRule } from '../resources/codebuddy-rule.js'; import { teamRuleToCursorMdc } from '../resources/cursor-mdc.js'; -import { loadCursorRuleParser, ruleParserBundle } from './helpers/rule-parsers.js'; +import { loadCodebuddyRuleParser, loadCursorRuleParser, ruleParserBundle } from './helpers/rule-parsers.js'; /** * Each render read back by the tool's own parser, taken from its installed @@ -29,3 +33,38 @@ describe.skipIf(!cursorBundle)('Cursor reads the .mdc render as intended', () => expect(parsed!.body).toBe(BODY); }); }); + +const codebuddyBundle = ruleParserBundle('codebuddy'); + +describe.skipIf(!codebuddyBundle)('CodeBuddy (and WorkBuddy) read the CodeBuddy render as intended', () => { + const parser = codebuddyBundle ? loadCodebuddyRuleParser(codebuddyBundle) : undefined; + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'teamai-codebuddy-parser-')); + afterAll(() => fs.rmSync(dir, { recursive: true, force: true })); + + it.each([ + ['an unscoped rule', BODY, 'ALWAYS', undefined], + ['an inline list', `---\npaths: ["src/**/*.ts", "test/**"]\n---\n\n${BODY}`, 'MANUAL', ['src/**/*.ts', 'test/**']], + ['a block list', `---\npaths:\n - "src/**/*.ts"\n - test/**\n---\n\n${BODY}`, 'MANUAL', ['src/**/*.ts', 'test/**']], + ['a comma-separated string', `---\npaths: src/**/*.ts, test/**\n---\n\n${BODY}`, 'MANUAL', ['src/**/*.ts', 'test/**']], + ['a brace glob', `---\npaths:\n - "src/{a,b}/**"\n - "**/*.{ts,tsx}"\n---\n\n${BODY}`, 'MANUAL', ['src/{a,b}/**', '**/*.{ts,tsx}']], + ['an unquoted alias-like glob', `---\npaths: **/*.ts\n---\n\n${BODY}`, 'MANUAL', ['**/*.ts']], + ])('%s', async (label, source, type, globs) => { + const file = path.join(dir, `${label.replaceAll(' ', '-')}.md`); + fs.writeFileSync(file, teamRuleToCodebuddyRule(source)); + + const parsed = await parser!.parse(file); + + expect(parsed.type).toBe(type); + expect(parsed.alwaysApply).toBe(type === 'ALWAYS'); + expect(parsed.globs).toEqual(globs); + expect(parsed.content).toBe(BODY.trim()); + }); + + // The verbatim copy teamai wrote before: CodeBuddy kept the brackets. + it('reads the old verbatim copy of an inline list with brackets in its globs', async () => { + const file = path.join(dir, 'verbatim.md'); + fs.writeFileSync(file, `---\npaths: ["src/**/*.ts", "test/**"]\n---\n\n${BODY}`); + + expect((await parser!.parse(file)).globs).toEqual(['["src/**/*.ts"', '"test/**"]']); + }); +}); diff --git a/src/__tests__/rule-render-contracts.test.ts b/src/__tests__/rule-render-contracts.test.ts index a4c588b6e..16f8d2cdd 100644 --- a/src/__tests__/rule-render-contracts.test.ts +++ b/src/__tests__/rule-render-contracts.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from 'vitest'; +import { teamRuleToCodebuddyRule } from '../resources/codebuddy-rule.js'; import { teamRuleToKiroSteering } from '../resources/kiro-steering.js'; import { teamRuleToQoderRule } from '../resources/qoder-rule.js'; @@ -60,6 +61,30 @@ describe('Qoder rule render', () => { }); }); +describe('CodeBuddy rule render (CodeBuddy and WorkBuddy)', () => { + const SCOPED = '---\nalwaysApply: false\npaths:\n - "src/**/*.ts"\n - "test/**"\n---\n\nUse named exports.\n'; + + it('makes an unscoped rule always applied', () => { + expect(teamRuleToCodebuddyRule(UNSCOPED)).toBe('---\nalwaysApply: true\n---\n\nUse named exports.\n'); + }); + + // Its frontmatter parser reads lines, not YAML: an inline list keeps its + // brackets in the globs, so the render always writes a block list. + it.each([ + ['an inline list', INLINE], + ['a block list', BLOCK], + ['a comma-separated string', '---\npaths: src/**/*.ts, test/**\n---\n\nUse named exports.\n'], + ])('scopes %s with alwaysApply false and paths as a block list', (_label, source) => { + expect(teamRuleToCodebuddyRule(source)).toBe(SCOPED); + }); + + it('keeps a brace glob whole, since a list item is not split on commas', () => { + expect(teamRuleToCodebuddyRule(BRACE)).toBe( + '---\nalwaysApply: false\npaths:\n - "src/{a,b}/**"\n---\n\nUse named exports.\n', + ); + }); +}); + describe('team rule paths, shared by every render', () => { // gray-matter caches a parse by content, failures included: the retry that // quotes `**/*.ts` must not lose to a cached failure on the next render. diff --git a/src/__tests__/uninstall.test.ts b/src/__tests__/uninstall.test.ts index c030bd4c7..43f02dc3f 100644 --- a/src/__tests__/uninstall.test.ts +++ b/src/__tests__/uninstall.test.ts @@ -1347,6 +1347,72 @@ describe('uninstall', () => { expect(log.warn).toHaveBeenCalledWith(expect.stringContaining(`Kept \`/.mcp.json\` in ${await fse.realpath(excludeFile)}`)); }); + describe('CodeBuddy and WorkBuddy share a project\'s .codebuddy/rules (#946)', () => { + async function sharedFixture(agent: 'codebuddy' | 'workbuddy' | undefined, others: { disabled?: boolean } = {}) { + const { homeDir, repoPath } = await setupFixture(tmpDir); + const projectRoot = path.join(tmpDir, 'business-repo'); + vi.stubEnv('HOME', homeDir); + vi.stubEnv('SHELL', '/bin/zsh'); + const defaults = TeamaiConfigSchema.parse({ team: 't', repo: 'https://example.invalid/t.git' }).toolPaths; + const teamConfig = makeTeamConfig({ toolPaths: { codebuddy: defaults.codebuddy, workbuddy: defaults.workbuddy } }); + const other = agent === 'codebuddy' ? 'workbuddy' : 'codebuddy'; + const localConfig = makeLocalConfig(homeDir, repoPath, { + scope: 'project', + projectRoot, + ...(others.disabled ? { disabledAgents: [other] } : {}), + repo: { localPath: repoPath, remote: '', kind: 'self', businessRepoRoot: projectRoot }, + }); + mockAutoDetectInit.mockResolvedValue({ localConfig, teamConfig }); + await fse.ensureDir(path.join(projectRoot, '.workbuddy')); + const copy = path.join(projectRoot, '.codebuddy', 'rules', 'team-rule.md'); + await fse.outputFile(copy, '---\nalwaysApply: true\n---\n\n# Team Rule\n'); + return copy; + } + + it.each(['codebuddy', 'workbuddy'] as const)('uninstall --agent %s keeps the copy the other still reads', async (agent) => { + const copy = await sharedFixture(agent); + + await uninstall({ force: true, agent }); + + expect(await fse.pathExists(copy)).toBe(true); + }); + + it.each(['codebuddy', 'workbuddy'] as const)('uninstall --agent %s removes the copy once the other is excluded', async (agent) => { + const copy = await sharedFixture(agent, { disabled: true }); + + await uninstall({ force: true, agent }); + + expect(await fse.pathExists(copy)).toBe(false); + }); + + it('removes an unedited copy an older pull left in .workbuddy/rules, and names an edited one with why', async () => { + const copy = await sharedFixture('workbuddy'); + const legacy = (file: string) => path.join(path.dirname(path.dirname(path.dirname(copy))), '.workbuddy', 'rules', file); + await fse.outputFile(legacy('team-rule.md'), '# Team Rule'); + await fse.outputFile(path.join(tmpDir, 'team-repo', 'rules', 'other.md'), 'Other rule.\n'); + await fse.outputFile(legacy('other.md'), 'Other rule.\nMy own note.\n'); + + await uninstall({ force: true, agent: 'workbuddy' }); + + expect(await fse.pathExists(legacy('team-rule.md'))).toBe(false); + expect(await fse.readFile(legacy('other.md'), 'utf8')).toBe('Other rule.\nMy own note.\n'); + const kept = vi.mocked(log.warn).mock.calls.map(([message]) => String(message)) + .filter((message) => message.includes(legacy('other.md'))); + expect(kept).toHaveLength(1); + expect(kept[0]).toContain('WorkBuddy reads a project\'s rules from .codebuddy/rules'); + expect(kept[0]).not.toContain('Codex'); + }); + + it('a full uninstall removes the copy, counted once', async () => { + const copy = await sharedFixture(undefined); + + await uninstall({ force: true }); + + expect(await fse.pathExists(copy)).toBe(false); + expect(log.success).toHaveBeenCalledWith('Removed 1 rule files'); + }); + }); + describe('the block protects a config holding a resolved value (#882)', () => { const block = [ '# [teamai:mcp-exclude:start] project MCP configs holding resolved ${VAR} values', diff --git a/src/doctor-delivery.ts b/src/doctor-delivery.ts index 3dd2d4e9c..1f0392c5d 100644 --- a/src/doctor-delivery.ts +++ b/src/doctor-delivery.ts @@ -75,6 +75,8 @@ function appendTo(buckets: Map, tool: string, name: string): v /** What one tool was owed, and which of it did not arrive intact. */ interface ToolDelivery { + /** The tool whose format its items land in; the map key also names the tools sharing the copy. */ + tool: string; /** Where its items land — the fix names it when the filename is derived. */ dir: string; /** Item names grouped by the problem label `classify` gave them. */ @@ -105,10 +107,12 @@ async function walkDelivery( if (targets.length === 0) unreceived.push(item.name); for (const target of targets) { - let delivery = byTool.get(target.tool); + // One check for a copy several tools read, naming them all (#946). + const key = [target.tool, ...target.sharedWith ?? []].join(', '); + let delivery = byTool.get(key); if (!delivery) { - delivery = { dir: path.dirname(target.dest), problems: new Map() }; - byTool.set(target.tool, delivery); + delivery = { tool: target.tool, dir: path.dirname(target.dest), problems: new Map() }; + byTool.set(key, delivery); } const problem = await classify(target, item); if (problem !== null) appendTo(delivery.problems, problem, item.name); @@ -298,7 +302,7 @@ export async function buildRulesDeliveryChecks(ctx: DoctorContext): Promise { try { const key = await checkoutRecordKey(localConfig); - if (!key) return; const state = await loadStateForScope(localConfig); const ledger = await openCheckoutLedger(localConfig, state); - if (ledger.previous === undefined) return; const { items } = await resolveDesiredRules(freshConfig, localConfig, roleContext); - const rewritten = await (getHandler('rules') as RulesHandler).rerenderOutdatedCopies( - freshConfig, localConfig, items, ledger, - ); + const handler = getHandler('rules') as RulesHandler; + const reclaimed = await handler.reclaimLegacyRuleCopies(freshConfig, localConfig, items, ledger); + const rewritten = key && ledger.previous !== undefined + ? await handler.rerenderOutdatedCopies(freshConfig, localConfig, items, ledger) + : []; reportKept(ledger, scopeLabel); - if (rewritten.length === 0) return; + if (!key || (reclaimed === 0 && rewritten.length === 0)) return; const record = localConfig.scope === 'user' ? await userScopeRecord(state) : state.lastPullByWorkspace?.[key]; if (record) { record.delivered = ledger.hashes; await saveStateForScope(state, localConfig); } - log.success(`[${scopeLabel}] Rewrote ${rewritten.length} rule(s) in their tool's own format: ${rewritten.join(', ')}`); + if (rewritten.length > 0) { + log.success(`[${scopeLabel}] Rewrote ${rewritten.length} rule(s) in their tool's own format: ${rewritten.join(', ')}`); + } } catch (e) { log.warn( - `[${scopeLabel}] Could not check whether delivered rules need their tool's format: ${(e as Error).message}. ` - + 'Copies may still be in an older format; fix the cause, then run `teamai pull --force`.', + `[${scopeLabel}] Could not check whether delivered rules need their tool's format or a new place: ${(e as Error).message}. ` + + 'Copies may still be in an older format, or where the tool does not read them; fix the cause, then run `teamai pull --force`.', ); } } @@ -1349,9 +1356,7 @@ async function pullForScope( if (resourceTypes.includes('rules')) { try { const { items } = await resolveDesiredRules(freshConfig, localConfig, roleContext); - await (getHandler('rules') as RulesHandler).syncCodexInstructionRules( - freshConfig, localConfig, items, openLedger(await deliveredHashes(localConfig, state)), - ); + await (getHandler('rules') as RulesHandler).syncCodexInstructionRules(freshConfig, localConfig, items); } catch (error) { log.warn(`[${scopeLabel}] Codex's team rules were not updated: ${(error as Error).message}. Run \`teamai pull --force\` to retry.`); } @@ -1364,7 +1369,8 @@ async function pullForScope( log.warn(`[${scopeLabel}] OpenCode's rules globs were not updated: ${(error as Error).message}. Run \`teamai pull --force\` to retry.`); } // Same reason: a CLI that gives a tool its own rules format must - // re-render the copies an older one wrote verbatim (#946). + // re-render the copies an older one wrote verbatim, and reclaim the + // ones it left where the tool does not read them (#938, #946). await rerenderOutdatedRules(freshConfig, localConfig, roleContext, scopeLabel); } // The repo has not moved, but an agent's model may have (#830). diff --git a/src/resources/base.ts b/src/resources/base.ts index 1404f7317..7fc18d525 100644 --- a/src/resources/base.ts +++ b/src/resources/base.ts @@ -7,6 +7,13 @@ import type { DeliveryLedger } from './delivered-copies.js'; const TOMBSTONE_FILE = '.removed'; +/** + * The root that says a tool is installed, for a tool with a path under + * another tool's root: WorkBuddy reads a project's rules from CodeBuddy's + * `.codebuddy/rules` (#946), which says nothing about WorkBuddy. + */ +const INSTALL_ROOTS: Readonly> = { workbuddy: '.workbuddy' }; + /** Detect an installed tool while respecting tool-specific user roots. */ export async function isToolInstalledForConfig( tool: string, @@ -20,7 +27,7 @@ export async function isToolInstalledForConfig( || (exactConfigPath !== undefined && await pathExists(exactConfigPath)) || pathExists(getCopilotHome()); } - return ResourceHandler.isToolInstalled(toolPath, baseDir); + return ResourceHandler.isToolInstalled(Object.hasOwn(INSTALL_ROOTS, tool) ? INSTALL_ROOTS[tool] : toolPath, baseDir); } /** diff --git a/src/resources/codebuddy-rule.ts b/src/resources/codebuddy-rule.ts new file mode 100644 index 000000000..27706766a --- /dev/null +++ b/src/resources/codebuddy-rule.ts @@ -0,0 +1,33 @@ +import type { RuleFormat } from './rule-format.js'; +import { mergeRuleBodyIntoTeamMd, ruleBodyEqualsTeamMd, rulePaths, teamRuleBody, teamRuleData } from './team-rule.js'; + +/** + * CodeBuddy rule files (`.codebuddy/rules/*.md`, `~/.codebuddy/rules/*.md`), + * which WorkBuddy reads too, in the same engine + * (https://www.codebuddy.ai/docs/cli/memory): + * + * - team `paths: [glob, ...]` → `alwaysApply: false` + `paths:` as a block list + * - no `paths` → `alwaysApply: true` + * + * CodeBuddy's frontmatter parser reads lines, not YAML: an inline + * `paths: ["a", "b"]` becomes globs with the brackets and quotes in them, so + * the render always writes a block list, one quoted glob per item (the parser + * strips the quotes). An item is never split on its commas, so a brace glob + * stays whole. + */ +export function teamRuleToCodebuddyRule(rawTeamRule: string): string { + const paths = rulePaths(teamRuleData(rawTeamRule)); + const frontmatter = paths.length > 0 + ? ['alwaysApply: false', 'paths:', ...paths.map((glob) => ` - ${JSON.stringify(glob)}`)] + : ['alwaysApply: true']; + return `---\n${frontmatter.join('\n')}\n---\n\n${teamRuleBody(rawTeamRule)}\n`; +} + +/** CodeBuddy's rules format, which WorkBuddy shares. */ +export const CODEBUDDY_RULE_FORMAT: RuleFormat = { + extension: '.md', + render: teamRuleToCodebuddyRule, + bodyEquals: ruleBodyEqualsTeamMd, + mergeBodyIntoTeam: mergeRuleBodyIntoTeamMd, + scopeFields: ['alwaysApply', 'paths'], +}; diff --git a/src/resources/rule-format.ts b/src/resources/rule-format.ts index f62dd5c54..65bc80af9 100644 --- a/src/resources/rule-format.ts +++ b/src/resources/rule-format.ts @@ -3,9 +3,9 @@ * * The team repo always stores rules as tool-neutral `.md`. A tool with * a rules format of its own gets a render of it (`RULE_FORMATS`): Cursor and - * JoyCode `.mdc`, Copilot `.instructions.md`, Kiro steering and Qoder rules - * `.md` with their own frontmatter. Every other tool takes a verbatim `.md` - * copy. + * JoyCode `.mdc`, Copilot `.instructions.md`, Kiro steering, Qoder and + * CodeBuddy (which WorkBuddy shares) rules `.md` with their own frontmatter. + * Every other tool takes a verbatim `.md` copy. * * This module is the single place that decision lives, mirroring * `agentFileExtensionForTool` in `./agent-format.ts`. Every site that writes, @@ -15,7 +15,8 @@ * below. */ -import type { TeamaiConfig } from '../types.js'; +import type { Scope, TeamaiConfig } from '../types.js'; +import { CODEBUDDY_RULE_FORMAT } from './codebuddy-rule.js'; import { COPILOT_INSTRUCTIONS_FORMAT } from './copilot-instructions.js'; import { CURSOR_MDC_FORMAT } from './cursor-mdc.js'; import { KIRO_STEERING_FORMAT } from './kiro-steering.js'; @@ -50,6 +51,9 @@ const RULE_FORMATS: Readonly> = { kiro: KIRO_STEERING_FORMAT, qoder: QODER_RULE_FORMAT, 'qoder-cn': QODER_RULE_FORMAT, + codebuddy: CODEBUDDY_RULE_FORMAT, + // Same engine as CodeBuddy; in a project it reads .codebuddy/rules too. + workbuddy: CODEBUDDY_RULE_FORMAT, }; const SESSION_HOOK_RULE_TOOLS = new Set(['codex', 'codex-internal', 'tcodex']); @@ -141,16 +145,89 @@ export function instructionFileInstallProbe(tool: string, toolPath: ToolPath): s } /** - * The rules directory each tool that now gets rules from its session-start - * hook received `.md` copies in before #938, relative to the tool's base - * dir in either scope. The tool never read them. It keeps its own `*.rules` - * exec-policy files there, so only teamai's copies may be removed from it. + * A rules directory where earlier pulls left team rule copies the tool does + * not load as teamai means it to. Pull reclaims the unedited ones on every + * rules sync (`RulesHandler.reclaimLegacyRuleCopies`): a copy of a rule still + * delivered is replaced by its current delivery, others are removed, and an + * edited one is kept and named. `uninstall` removes the same unedited ones. + * + * Adding a directory is one entry. A copy is unedited when it holds + * `legacyRender` of the team rule (now or at a revision this checkout + * pulled), the tool's current render, or the hash the delivery ledger + * recorded for it, or for its namesake in `copiedFrom.dir`. */ -export const LEGACY_RULE_DIRS: Readonly> = { - codex: '.codex/rules', - 'codex-internal': '.codex-internal/rules', - tcodex: '.tcodex/rules', -}; +export interface LegacyRuleDir { + readonly tool: string; + /** The scopes earlier pulls wrote it in. */ + readonly scopes: readonly Scope[]; + /** Relative to the tool's base dir in that scope (HOME or the project root). */ + readonly dir: string; + /** The extension the copies were written with. */ + readonly ext: '.md' | '.mdc'; + /** What an older teamai wrote there from the team rule; the team `.md` verbatim when absent. */ + readonly legacyRender?: (rawTeamRule: string) => string; + /** + * For a directory the tool does read, whose copies the tool itself copied + * from another one: that directory, relative to the same base dir, and the + * file the tool leaves once it has copied. The ledger hash recorded in + * `dir` for the same file also proves a copy unedited, or edited when it + * differs. Such a directory is reclaimed only once the marker exists and + * while the tool is not excluded, even while teamai delivers to it, and the + * built-in rules teamai deploys there are left to that delivery. + */ + readonly copiedFrom?: { readonly dir: string; readonly marker: string }; + /** Why a copy kept there is a problem, completing "Kept : ..., and". */ + readonly why: string; + /** What to do with a kept copy, as one sentence. */ + readonly advice: string; +} + +/** + * The warning naming the copies kept in a legacy rules directory: why they + * matter there, and what to do with them. + */ +export function keptLegacyCopiesWarning(files: readonly string[], entry: LegacyRuleDir): string { + return `Kept ${files.join(', ')}: teamai could not verify that ${files.length === 1 ? 'it matches' : 'they match'} ` + + `what it delivered there, and ${entry.why}. ${entry.advice}`; +} + +const codexLegacyDir = (tool: string, dir: string): LegacyRuleDir => ({ + tool, + scopes: ['user', 'project'], + dir, + ext: '.md', + // Codex keeps its own `*.rules` exec-policy files there, so only teamai's + // copies may be removed from it. + why: 'Codex does not read .md files in its rules directory (team rules now reach it through its session-start hook)', + advice: 'Delete what you did not edit; to keep your changes, move them into AGENTS.md outside the teamai markers, then delete the copy.', +}); + +export const LEGACY_RULE_DIRS: readonly LegacyRuleDir[] = [ + // Before #938 the Codex family got `.md` copies it never read. + codexLegacyDir('codex', '.codex/rules'), + codexLegacyDir('codex-internal', '.codex-internal/rules'), + codexLegacyDir('tcodex', '.tcodex/rules'), + // WorkBuddy reads a project's rules from CodeBuddy's .codebuddy/rules (#946). + { + tool: 'workbuddy', + scopes: ['project'], + dir: '.workbuddy/rules', + ext: '.md', + why: 'WorkBuddy reads a project\'s rules from .codebuddy/rules, not from .workbuddy/rules', + advice: 'Delete what you did not edit; to keep your changes, move them into .codebuddy/rules under a name of your own, then delete the copy.', + }, + // WorkBuddy's one-time migration (`migrateLegacyDataOnce`) copied + // ~/.codebuddy/rules, team copies included, into the rules it loads. + { + tool: 'workbuddy', + scopes: ['user'], + dir: '.workbuddy/rules', + ext: '.md', + copiedFrom: { dir: '.codebuddy/rules', marker: '.workbuddy/.migrated-from-codebuddy' }, + why: 'WorkBuddy copied it from ~/.codebuddy/rules into the rules it loads, and teamai no longer delivers it here', + advice: 'Delete it if you did not edit it; to keep your changes, rename it to a name of your own.', + }, +]; /** * Every extension a rule file may carry on disk, newest layout first. diff --git a/src/resources/rules.ts b/src/resources/rules.ts index 61931bf01..ede73f74e 100644 --- a/src/resources/rules.ts +++ b/src/resources/rules.ts @@ -23,12 +23,27 @@ import { sharesRulesDirWithMember, isLegacyCursorRuleFile, LEGACY_RULE_DIRS, + keptLegacyCopiesWarning, + type LegacyRuleDir, type RuleFormat, writesInstructionBlock, instructionFileInstallProbe, } from './rule-format.js'; import { injectClaudeMdSection, removeClaudeMdSection } from '../utils/claudemd.js'; +/** The copies earlier pulls left in one of `LEGACY_RULE_DIRS` (`RulesHandler.legacyRuleCopies`). */ +export interface LegacyRuleCopies { + entry: LegacyRuleDir; + /** The directory, resolved for this scope. */ + dir: string; + /** `entry.copiedFrom.dir`, resolved; undefined for a directory the tool does not read. */ + copiedFrom: string | undefined; + /** The copies still teamai's. */ + owned: string[]; + /** The copies the member edited, or that teamai cannot prove it wrote. */ + edited: string[]; +} + export class RulesHandler extends ResourceHandler { readonly type = 'rules' as const; @@ -251,8 +266,9 @@ export class RulesHandler extends ResourceHandler { * Where `item` lands for each tool that receives rules. The filename and * bytes are tool-dependent (`RULE_FORMATS`) — `.md` verbatim, `.mdc` for * Cursor-compatible tools, `.instructions.md` for Copilot, `.md` with its - * own frontmatter for Kiro and Qoder — so a reader cannot derive them from - * the rule's name alone. + * own frontmatter for Kiro, Qoder and CodeBuddy — so a reader cannot derive + * them from the rule's name alone. Tools that read the same file in the same + * render get one target, naming the others in `sharedWith`. */ async deliveryTargets( teamConfig: TeamaiConfig, @@ -279,10 +295,18 @@ export class RulesHandler extends ResourceHandler { const destDir = path.join(resolveToolBaseDir(tool, localConfig), toolPath.rules); const ext = ruleFileExtensionForTool(tool); + const dest = path.join(destDir, `${localName}${ext}`); + const content = source === null ? undefined : renderRuleForTool(tool, source); + // Tools that read one directory in one format share the copy (#946). + const shared = targets.find((target) => target.dest === dest && target.content === content); + if (shared) { + (shared.sharedWith ??= []).push(tool); + continue; + } targets.push({ tool, - dest: path.join(destDir, `${localName}${ext}`), - content: source === null ? undefined : renderRuleForTool(tool, source), + dest, + content, ...(localName !== item.name ? { supersedes: path.join(destDir, `${item.name}${ext}`) } : {}), @@ -544,7 +568,9 @@ export class RulesHandler extends ResourceHandler { } } - await this.syncCodexInstructionRules(teamConfig, localConfig, rules, ledger); + await this.syncCodexInstructionRules(teamConfig, localConfig, rules); + // Before the empty-set return: a copy outlives the rule that selected it. + await this.reclaimLegacyRuleCopies(teamConfig, localConfig, rules, ledger); // OpenCode does not auto-scan a rules directory: the .md files are inert // until referenced from `instructions` in opencode.json. Activate (or, when @@ -642,7 +668,7 @@ export class RulesHandler extends ResourceHandler { continue; } const deployed = path.join(destDir, localFile); - if (await isDeliveredRender(tool, deployed, replaced, localConfig.repo.localPath, deliveredRevs)) { + if (await isDeliveredRender([toolRender(tool)], deployed, replaced, localConfig.repo.localPath, deliveredRevs)) { await remove(deployed); log.debug(`Removed ${localFile} from ${tool}: a namespace rule replaces it`); } else { @@ -706,15 +732,13 @@ export class RulesHandler extends ResourceHandler { /** * The Codex family's part of a rules sync: the team rules go into its * user-scope AGENTS.md, which only it reads; in a project its session-start - * hook adds them (#938). The copies earlier pulls left in its rules - * directory are reclaimed. Public so the "Already synced" pull can run it + * hook adds them (#938). Public so the "Already synced" pull can run it * after a CLI upgrade. */ async syncCodexInstructionRules( teamConfig: TeamaiConfig, localConfig: LocalConfig, rules: ResourceItem[], - ledger?: DeliveryLedger, ): Promise { const block = await teamRulesBlock(rules); for (const [tool, toolPath] of Object.entries(scopedToolPaths(teamConfig, localConfig))) { @@ -738,54 +762,106 @@ export class RulesHandler extends ResourceHandler { log.warn(`Failed to update team rules in ${file}: ${(e as Error).message}`); } } - await this.reclaimLegacyRuleCopies(teamConfig, localConfig, ledger); } /** - * Remove the `.md` copies earlier pulls wrote to a rules directory the - * tool never read (`LEGACY_RULE_DIRS`) that are still teamai's - * (`legacyRuleCopies`). The copies kept are named in one warning per rules - * sync, until the member deletes them. Public so the "Already synced" pull - * can run it after a CLI upgrade without a full sync (#938). + * Reclaim the copies earlier pulls left in `LEGACY_RULE_DIRS` that are + * still teamai's (`legacyRuleCopies`). One of a rule in `rules` (what this + * sync delivers) is replaced by the rule's current delivery: rewritten in + * place where the tool still reads that path, otherwise removed. Either + * way, kept or not, the rule's current destination is written when it is + * missing: that is what moves a rule on the "Already synced" pull, where + * nothing else writes the new path (#946). Other unedited copies are + * removed. The edited ones are named in one warning per directory, until + * the member deletes them. One at a path pull delivers to is left to pull, + * which keeps and names it as an edit (#822) once it carries the record of + * the copy the tool copied it from. Public so the "Already synced" pull can + * run it after a CLI upgrade (#938). Returns how many files it wrote or + * removed, whose records changed in `ledger`. */ async reclaimLegacyRuleCopies( teamConfig: TeamaiConfig, localConfig: LocalConfig, + rules: readonly ResourceItem[], ledger: DeliveryLedger | undefined, - ): Promise { - const kept: string[] = []; - for (const { tool, dir, owned, edited } of await this.legacyRuleCopies(teamConfig, localConfig, ledger?.previous)) { + ): Promise { + let changed = 0; + const write = async (dest: string, content: string): Promise => { + await ensureDir(path.dirname(dest)); + await writeFile(dest, content); + if (ledger) await recordDelivered(ledger.hashes, dest); + changed++; + }; + for (const { entry, dir, copiedFrom, owned, edited } of await this.legacyRuleCopies(teamConfig, localConfig, ledger?.previous)) { + // Where each copy's rule is delivered to this tool now. + const delivery = new Map(); + for (const rule of rules) { + const target = (await this.deliveryTargets(teamConfig, localConfig, rule)) + .find(({ tool, sharedWith }) => tool === entry.tool || sharedWith?.includes(entry.tool)); + if (!target) continue; + for (const name of new Set([rule.name, await this.localNameFor(rule.name, localConfig)])) { + delivery.set(path.join(dir, `${name}${entry.ext}`), target); + } + } + let removed = 0; for (const file of owned) { + const target = delivery.get(file); + if (target?.dest === file) { + if (target.content !== undefined && await readFileSafe(file) !== target.content) await write(file, target.content); + continue; + } await remove(file); if (ledger) forgetDelivered(ledger.hashes, file); - log.debug(`Removed ${file}: ${tool} gets the team rules from its session-start hook`); + changed++; + removed++; + log.debug(`Removed ${file}: ${entry.why}`); } - kept.push(...edited); - // Codex's own `*.rules` keep the directory; only an emptied one goes. - if (owned.length > 0) await pruneEmptyDirs(dir); - } - if (kept.length > 0) { - log.warn( - `Kept ${kept.join(', ')}: teamai could not verify that ${kept.length === 1 ? 'it matches' : 'they match'} what it delivered there, ` - + 'and Codex does not read .md files in its rules directory (team rules now reach it through its session-start hook). ' - + 'Delete what you did not edit; to keep your changes, move them into AGENTS.md outside the teamai markers, ' - + 'then delete the copy.', - ); + // The rule's current destination, when it moved and nothing wrote it yet: + // kept or not, the old copy is not where the tool reads it. + for (const file of [...owned, ...edited]) { + const target = delivery.get(file); + if (target === undefined || target.dest === file || target.content === undefined) continue; + if (!await pathExists(target.dest)) await write(target.dest, target.content); + } + const kept: string[] = []; + for (const file of edited) { + if (delivery.get(file)?.dest !== file) { + kept.push(file); + continue; + } + // A copy the tool made of one teamai recorded is that copy, edited: + // with its record, pull keeps it rather than writing over it. + const source = copiedFrom === undefined ? undefined : ledger?.previous?.[path.join(copiedFrom, path.relative(dir, file))]; + if (source !== undefined && ledger?.previous !== undefined && ledger.previous[file] === undefined) { + ledger.previous[file] = source; + ledger.hashes[file] = source; + changed++; + } + } + if (kept.length > 0) log.warn(keptLegacyCopiesWarning(kept, entry)); + // Files of the tool's own (Codex's `*.rules`) keep the directory; only an + // emptied one goes, and never one the tool reads. + if (removed > 0 && entry.copiedFrom === undefined) await pruneEmptyDirs(dir); } + return changed; } /** - * The `.md` copies earlier pulls wrote to each rules directory a tool - * never read (`LEGACY_RULE_DIRS`), split into the ones still teamai's and the - * ones the member edited. A copy is teamai's while it holds what teamai - * delivered there: the team rule verbatim, now or at a revision this - * checkout pulled, or as `previous` (the delivery ledger) recorded it; the - * built-in `teamai-recall.md` as any teamai version deployed it. Every team - * rule counts, not just the ones delivered here, since a copy outlives the - * role or tag that selected it. A copy of a rule the team removed is teamai's - * only while it matches its recorded delivery hash. Without that record, - * the copy stays because it may contain the member's edits. A directory a team - * `toolPaths` still delivers rules to is not listed. + * The copies earlier pulls left in each of `LEGACY_RULE_DIRS` for this + * scope, split into the ones still teamai's and the ones the member edited. + * A copy is teamai's while it holds what teamai delivered there: the + * entry's legacy render of the team rule (verbatim by default) or the + * tool's current render, now or at a revision this checkout pulled; the + * hash `previous` (the delivery ledger) recorded for it or, for an entry + * with `copiedFrom`, for its namesake there; the built-in `teamai-recall.md` + * as any teamai version deployed it. Every team rule counts, not just the + * ones delivered here, since a copy outlives the role or tag that selected + * it. A copy of a rule the team removed is teamai's only on a recorded + * hash. Without that record, the copy stays because it may contain the + * member's edits. A directory a team `toolPaths` delivers rules to is not + * listed, unless the entry is for one the tool reads (`copiedFrom`), which + * is listed only once the tool's marker says it copied into it and while the + * tool is not excluded. * * Read-only and public so `uninstall` removes exactly what a pull reclaims. */ @@ -793,53 +869,71 @@ export class RulesHandler extends ResourceHandler { teamConfig: TeamaiConfig, localConfig: LocalConfig, previous: DeliveredHashes | undefined, - ): Promise> { + ): Promise { const teamRules = await this.scanTeamForPull(teamConfig, localConfig); const tombstoned = [...await this.readTombstones(localConfig)] .filter((name) => !teamRules.some((rule) => rule.name === name)); let deliveredRevs: readonly string[] | undefined; - const out: Array<{ tool: string; dir: string; owned: string[]; edited: string[] }> = []; + const out: LegacyRuleCopies[] = []; // A team `toolPaths` that still names one of these dirs delivers there. const deliveredDirs = new Set( Object.entries(scopedToolPaths(teamConfig, localConfig)) .filter(([, toolPath]) => toolPath.rules) .map(([tool, toolPath]) => path.join(resolveToolBaseDir(tool, localConfig), toolPath.rules!)), ); - for (const [tool, rel] of Object.entries(LEGACY_RULE_DIRS)) { + for (const entry of LEGACY_RULE_DIRS) { + const { tool, dir: rel, ext } = entry; + if (!entry.scopes.includes(localConfig.scope)) continue; const dir = localConfig.scope === 'user' ? path.join(resolveToolRootDir(tool, path.dirname(rel), localConfig.toolRoots), path.basename(rel)) : path.join(resolveToolBaseDir(tool, localConfig), rel); - if (deliveredDirs.has(dir) || !await pathExists(dir)) continue; + const baseDir = resolveToolBaseDir(tool, localConfig); + if (entry.copiedFrom === undefined + ? deliveredDirs.has(dir) + // A directory the tool reads is the tool's, until it has copied into + // it, and not teamai's while the tool is excluded. + : isAgentExcluded(localConfig, tool) || !await pathExists(path.join(baseDir, entry.copiedFrom.marker))) continue; + if (!await pathExists(dir)) continue; + const copiedFrom = entry.copiedFrom === undefined ? undefined : path.join(baseDir, entry.copiedFrom.dir); + // The hash teamai recorded writing this copy, or the one the tool copied it from. + const recordedUnchanged = async (file: string): Promise => { + const disk = await fileHash(file); + if (disk === null) return false; + if (previous?.[file] === disk) return true; + return copiedFrom !== undefined && previous?.[path.join(copiedFrom, path.relative(dir, file))] === disk; + }; + const renders = [entry.legacyRender ?? ((raw: string) => raw), toolRender(tool)]; const owned: string[] = []; const edited: string[] = []; for (const rule of teamRules) { // A publisher's copy uses its bare local name; older namespaced copies // can remain beside it, so check both against the same delivery proof. for (const name of new Set([rule.name, await this.localNameFor(rule.name, localConfig)])) { - const file = path.join(dir, `${name}.md`); + const file = path.join(dir, `${name}${ext}`); if (!await pathExists(file)) continue; deliveredRevs ??= ( await (await import('../pull.js')).resolveCheckoutBases(localConfig, await loadStateForScope(localConfig)) ).revs; - const recorded = previous?.[file]; - const delivered = (recorded !== undefined && recorded === await fileHash(file)) - || await isDeliveredRender(tool, file, rule, localConfig.repo.localPath, deliveredRevs); + const delivered = await recordedUnchanged(file) + || await isDeliveredRender(renders, file, rule, localConfig.repo.localPath, deliveredRevs); (delivered ? owned : edited).push(file); } } // The source is gone, so only a recorded hash proves a copy is unchanged. for (const name of tombstoned) { for (const localName of new Set([name, await this.localNameFor(name, localConfig)])) { - const file = path.join(dir, `${localName}.md`); + const file = path.join(dir, `${localName}${ext}`); if (!await pathExists(file)) continue; - const recorded = previous?.[file]; - (recorded !== undefined && recorded === await fileHash(file) ? owned : edited).push(file); + (await recordedUnchanged(file) ? owned : edited).push(file); } } - const recall = path.join(dir, 'teamai-recall.md'); - const recallContent = await readFileSafe(recall); - if (recallContent !== null) (isDeployedRecallRule(recallContent) ? owned : edited).push(recall); - out.push({ tool, dir, owned: [...new Set(owned)], edited: [...new Set(edited)].filter((file) => !owned.includes(file)) }); + // A directory the tool reads gets teamai's built-in rules too. + if (entry.copiedFrom === undefined) { + const recall = path.join(dir, `teamai-recall${ext}`); + const recallContent = await readFileSafe(recall); + if (recallContent !== null) (isDeployedRecallRule(recallContent) ? owned : edited).push(recall); + } + out.push({ entry, dir, copiedFrom, owned: [...new Set(owned)], edited: [...new Set(edited)].filter((file) => !owned.includes(file)) }); } return out; } @@ -956,7 +1050,7 @@ export class RulesHandler extends ResourceHandler { for (const { tool, dest, supersedes } of await this.deliveryTargets(teamConfig, localConfig, item)) { // `supersedes` marks the author's own root copy, not a delivered one. if (supersedes) continue; - if (!await isDeliveredRender(tool, dest, item, localConfig.repo.localPath, deliveredRevs)) continue; + if (!await isDeliveredRender([toolRender(tool)], dest, item, localConfig.repo.localPath, deliveredRevs)) continue; await remove(dest); if (ledger) forgetDelivered(ledger.hashes, dest); touchedDirs.add(path.join(resolveToolBaseDir(tool, localConfig), scopedToolPaths(teamConfig, localConfig)[tool].rules!)); @@ -982,19 +1076,24 @@ export class RulesHandler extends ResourceHandler { } } +/** What pull writes for `tool` from a team rule. */ +function toolRender(tool: string): (rawTeamRule: string) => string { + return (raw) => renderRuleForTool(tool, raw); +} + /** sha256 of `content`, as `fileHash` and the delivery ledger spell it. */ function contentHash(content: string): string { return crypto.createHash('sha256').update(content).digest('hex'); } /** - * Whether `deployed` holds exactly what pull renders for `tool` from the team - * rule, as it is now or as it was at one of `deliveredRevs`: a root rule - * edited in the same push that adds its namespace override leaves the older - * render behind, which nobody edited. + * Whether `deployed` holds exactly one of `renders` of the team rule, as it is + * now or as it was at one of `deliveredRevs`: a root rule edited in the same + * push that adds its namespace override leaves the older render behind, which + * nobody edited. */ async function isDeliveredRender( - tool: string, + renders: ReadonlyArray<(rawTeamRule: string) => string>, deployed: string, rule: ResourceItem, repoPath: string, @@ -1002,11 +1101,12 @@ async function isDeliveredRender( ): Promise { const current = await readFileSafe(deployed); if (current === null) return false; + const matches = (raw: string): boolean => renders.some((render) => current === render(raw)); const team = await readFileSafe(rule.sourcePath); - if (team !== null && current === renderRuleForTool(tool, team)) return true; + if (team !== null && matches(team)) return true; for (const rev of deliveredRevs) { const delivered = await getFileContentAtRev(repoPath, rev, `./${rule.relativePath}`); - if (delivered !== null && current === renderRuleForTool(tool, delivered.toString('utf-8'))) return true; + if (delivered !== null && matches(delivered.toString('utf-8'))) return true; } return false; } diff --git a/src/types.ts b/src/types.ts index 9fab51b69..50e74dc3e 100644 --- a/src/types.ts +++ b/src/types.ts @@ -509,7 +509,20 @@ export const TeamaiConfigSchema = z.object({ // provider scans as user-dsh root (rank 400). dsh discovers both directory // bundles (/SKILL.md) and flat Markdown files there natively. dsh: { skills: '.dsh/skills' }, - workbuddy: { skills: '.workbuddy/skills', rules: '.workbuddy/rules', settings: '.workbuddy/settings.json', claudemd: 'AGENTS.md', agents: '.workbuddy/agents', mcp: '.workbuddy/mcp.json', mcpProject: '.workbuddy/mcp.json' }, + // WorkBuddy runs CodeBuddy's engine: in a project it reads CodeBuddy's + // .codebuddy/rules, which the two share (one copy), and in user scope its + // own ~/.workbuddy/rules (#946). Its install probe stays .workbuddy + // (`isToolInstalledForConfig`), whatever directory its rules land in. + workbuddy: { + skills: '.workbuddy/skills', + rules: '.codebuddy/rules', + settings: '.workbuddy/settings.json', + claudemd: 'AGENTS.md', + agents: '.workbuddy/agents', + mcp: '.workbuddy/mcp.json', + mcpProject: '.workbuddy/mcp.json', + userScope: { rules: '.workbuddy/rules' }, + }, // OpenCode reads project config from /.opencode/ but user config from // ~/.config/opencode/ — a different prefix, hence userScope. Skills are also // read natively from .claude/skills, but we write .opencode/skills so an @@ -902,6 +915,11 @@ export interface DeliveryTarget { * rule twice. */ supersedes?: string; + /** + * The other tools that read this same `dest`, served by the one copy: in a + * project CodeBuddy and WorkBuddy both read `.codebuddy/rules` (#946). + */ + sharedWith?: string[]; /** * The exact bytes `pullItem` writes at `dest`, for a handler that renders * its destination rather than copying a tree there. It is what tells a copy diff --git a/src/uninstall.ts b/src/uninstall.ts index e863454b4..4782f30fb 100644 --- a/src/uninstall.ts +++ b/src/uninstall.ts @@ -36,7 +36,7 @@ import { type ManagedMcpManifest, } from './types.js'; import { BUILTIN_RULE_NAMES, TEAMAI_CONTEXT_RULE_NAME } from './builtin-rules.js'; -import { ruleStemFromFilename, writesInstructionBlock, type InstructionBlock } from './resources/rule-format.js'; +import { keptLegacyCopiesWarning, ruleStemFromFilename, writesInstructionBlock, type InstructionBlock, type LegacyRuleDir } from './resources/rule-format.js'; import { agentStemFromFilename } from './resources/agent-format.js'; import { resolveDocsDestination } from './resources/docs.js'; import { listTeamAgentDirs } from './resources/agents.js'; @@ -113,8 +113,8 @@ interface RemovalPlan { skillDirs: SkillDirEntry[]; /** Rule .md files synced from team repo (plus CLI built-in rules). */ ruleFiles: string[]; - /** Copies in a tool's legacy rules directory the member edited: never removed, only named. */ - keptRuleFiles: string[]; + /** Copies in a tool's legacy rules directory the member edited, by directory: never removed, only named. */ + keptRuleFiles: { files: string[]; entry: LegacyRuleDir }[]; /** The rules globs teamai owns in OpenCode's opencode.json `instructions`, per file (#946). */ opencodeOwnedGlobs: OpencodeRuleGlobEntries[]; /** Built-in agent .md files deployed by the CLI (e.g. teamai-recall). */ @@ -179,7 +179,7 @@ interface ToolResources { keptGlobal: string[]; skillDirs: SkillDirEntry[]; ruleFiles: string[]; - keptRuleFiles: string[]; + keptRuleFiles: { files: string[]; entry: LegacyRuleDir }[]; opencodeOwnedGlobs: OpencodeRuleGlobEntries[]; agentFiles: string[]; } @@ -737,11 +737,13 @@ async function buildRemovalPlan( // pull would reclaim go, and the ones the member edited stay, named. const legacyCopies = await new RulesHandler() .legacyRuleCopies(teamConfig, localConfig, await deliveredHashes(localConfig)); - for (const { tool, owned, edited } of legacyCopies) { - const res = perTool.get(tool); + for (const { entry, owned, edited } of legacyCopies) { + // A directory the tool reads is its rules directory, collected above. + if (entry.copiedFrom !== undefined) continue; + const res = perTool.get(entry.tool); if (!res) continue; res.ruleFiles.push(...owned); - res.keptRuleFiles.push(...edited); + if (edited.length > 0) res.keptRuleFiles.push({ files: edited, entry }); } // (d) continued: OpenCode loads its rules through globs in opencode.json, @@ -849,6 +851,14 @@ async function buildRemovalPlan( } } + // A rule file another enabled, installed tool reads stays: in a project + // CodeBuddy and WorkBuddy share `.codebuddy/rules` (#946). + const retainedRuleFiles = new Set(); + for (const [tool, resources] of perTool) { + if (toolsToMerge.includes(tool) || !activeTools.has(tool)) continue; + for (const file of resources.ruleFiles) retainedRuleFiles.add(file); + } + // Merge tool-specific resources for selected tools for (const tool of toolsToMerge) { const res = perTool.get(tool); @@ -882,7 +892,7 @@ async function buildRemovalPlan( if (blocks.length > 0) plan.claudeMdFiles.push({ path: file, blocks, owned: false }); } plan.skillDirs.push(...res.skillDirs); - plan.ruleFiles.push(...res.ruleFiles); + plan.ruleFiles.push(...res.ruleFiles.filter((file) => !retainedRuleFiles.has(file) && !plan.ruleFiles.includes(file))); plan.keptRuleFiles.push(...res.keptRuleFiles); plan.opencodeOwnedGlobs.push(...res.opencodeOwnedGlobs); plan.agentFiles.push(...res.agentFiles); @@ -1484,14 +1494,7 @@ export async function uninstall(opts: UninstallOptions): Promise { } const plan = await buildRemovalPlan(localConfig, teamConfig, agentKey); // Uninstall never removes these, so they are named whatever happens next. - if (plan.keptRuleFiles.length > 0) { - const one = plan.keptRuleFiles.length === 1; - log.warn( - `Kept ${plan.keptRuleFiles.join(', ')}: teamai could not verify that ${one ? 'it matches' : 'they match'} what it delivered there. ` - + 'Codex does not read .md files in its rules directory; ' - + `delete ${one ? 'it' : 'them'} once you have saved what you need.`, - ); - } + for (const { files, entry } of plan.keptRuleFiles) log.warn(keptLegacyCopiesWarning(files, entry)); const exclusionOnly = isPlanEmpty(plan) && agentKey && localConfig.scope === 'project' && ['pi', 'omp', 'hermes', ...CODEX_TOOL_IDS].includes(agentKey); From c53b1119eefae717d0997cf392b71dc80268ca38 Mon Sep 17 00:00:00 2001 From: Saul Moro Date: Fri, 2 Oct 2026 01:34:48 +0200 Subject: [PATCH 06/20] fix(rules): render Oh My Pi rules with flat namespace names (#946) --- CHANGELOG.md | 1 + docs/designs/data-directory-layout.md | 18 +- docs/usage-guide.md | 6 +- docs/usage-guide.zh-CN.md | 6 +- .../core/references/contribute-member.md | 5 +- src/__tests__/doctor-rules-delivery.test.ts | 33 ++ .../fixtures/rule-parsers/omp/rules.ts | 464 ++++++++++++++++++ src/__tests__/omp-rule-parser.test.ts | 64 +++ src/__tests__/omp.test.ts | 249 +++++++++- src/__tests__/pre-push-sync.test.ts | 14 + .../pull-rule-format-upgrade.test.ts | 96 ++++ src/__tests__/pull-tombstone.test.ts | 30 +- src/__tests__/rule-render-contracts.test.ts | 30 ++ src/__tests__/uninstall.test.ts | 25 + src/doctor-delivery.ts | 23 +- src/pull.ts | 28 +- src/resources/omp-rule.ts | 55 +++ src/resources/rule-format.ts | 72 ++- src/resources/rules.ts | 241 ++++++++- src/types.ts | 8 + src/uninstall.ts | 9 + src/utils/pre-push-sync.ts | 11 +- 22 files changed, 1432 insertions(+), 56 deletions(-) create mode 100644 src/__tests__/fixtures/rule-parsers/omp/rules.ts create mode 100644 src/__tests__/omp-rule-parser.test.ts create mode 100644 src/resources/omp-rule.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 6e74e5424..0e6b798a3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -46,6 +46,7 @@ All notable changes to this project will be documented in this file. See [standa ### 🐛 Bug Fixes - Kiro and Qoder (and Qoder CN) now get each rule in their own rules format. Kiro ignores `paths:`, so the verbatim copy pull wrote applied every rule everywhere, and Qoder documents `paths:` only for its CLI, not for Desktop. Kiro steering files get `inclusion: fileMatch` with a `fileMatchPattern` list, or `inclusion: always`; Qoder rules get `trigger: glob` with one comma-separated `glob:` line, `{a,b}` expanded, or `trigger: always_on`. The first pull after upgrading rewrites a copy still holding what teamai delivered, even when the team repo has not moved; a copy you edited is kept and named. `teamai push` sends back only the body, and a steering or Qoder rule file with no matching team rule is the member's own: pull no longer deletes it, and push no longer offers it as a new team rule. `teamai doctor` compares each copy with the new render and its fix names the fields that tool scopes by (for [#946](https://github.com/Tencent/teamai-cli/issues/946)). +- Oh My Pi now gets each rule in its own rules format, in `.omp/rules` and `~/.omp/agent/rules`. A rule copied verbatim had neither `alwaysApply` nor a `description`, so OMP dropped every one, and a namespaced rule in a subdirectory was never read, since OMP reads only the top level. An unscoped rule gets `alwaysApply: true`; a scoped one gets `globs` and a `description` (its first heading, or a line naming its globs), so OMP lists it in its rulebook; a namespaced rule is written flat, `rules/fe/style.md` as `fe.style.md`, and `teamai push` sends an edit of it back to `rules/fe/style.md`. A namespaced rule whose flat name another rule you receive has is not written, pull names both, and `doctor` fails; a file of your own with that name is never overwritten or removed. The first pull after upgrading rewrites a copy still holding what teamai delivered, writes the flat copy of a namespaced rule, and removes the old nested copy unless you edited it (then it names it), even when the team repo has not moved and with no delivery record. `remove`, tombstones, `uninstall` and `doctor` follow the flat name (for [#946](https://github.com/Tencent/teamai-cli/issues/946)). - A rule scoped with an unquoted glob such as `paths: **/*.ts` lost its scope on every render after the first in a run, so Cursor, JoyCode or doctor could see it as always on: the frontmatter parse kept a failed attempt in gray-matter's cache. A `paths:` string is now split on its top-level commas only, so `src/{a,b}/**` stays one glob for Cursor instead of becoming `src/{a, b}/**` (for [#946](https://github.com/Tencent/teamai-cli/issues/946)). - teamai's OpenClaw hook runs. Its handler read an `event` field OpenClaw never sends and subscribed to `session:start`, which OpenClaw does not emit; its `HOOK.md` name was not valid YAML; and OpenClaw loads a workspace hook only once `openclaw.json` enables its entry, which teamai never wrote. The handler now keys on the event's `type` and `action`, runs `session-start` on `command:new`, `command:reset`, `session:auto-reset` and `gateway:startup` and `prompt-submit` on `message:received`, and passes the event's workspace as the hook's `cwd`. init, pull and `hooks inject` add `hooks.internal.entries.teamai-status-report.enabled`, unless that would narrow OpenClaw's open-ended hook discovery or you switched it off, in which case they warn and name `openclaw hooks enable teamai-status-report` (earlier versions set `hooks.internal.enabled` when `OPENCLAW_STATE_DIR` was set, so those machines see this warning until the command is run); `hooks remove` and uninstall take the entry out. The workspace and config follow `OPENCLAW_PROFILE`, `OPENCLAW_CONFIG_PATH` and `OPENCLAW_WORKSPACE_DIR` as OpenClaw does, a server-pushed `SessionStart` or `UserPromptSubmit` agent hook subscribes to the events OpenClaw emits and gets its own entry (`teamai-agent-`) so the allowlist keeps it, and `teamai doctor` fails `OpenClaw hook enabled` while the entry is missing or off (for [#946](https://github.com/Tencent/teamai-cli/issues/946)). - A project-scope `teamai pull` no longer rewrites Hermes' global `SOUL.md` rules block, and a project with no rules no longer erases it: only a user-scope pull writes it, `doctor` checks it only in user scope, and a project-scope `uninstall` leaves it. Hermes gets no project rules; in a project `init` and `doctor` say why (for [#946](https://github.com/Tencent/teamai-cli/issues/946)). diff --git a/docs/designs/data-directory-layout.md b/docs/designs/data-directory-layout.md index 4826b4343..412d16b1a 100644 --- a/docs/designs/data-directory-layout.md +++ b/docs/designs/data-directory-layout.md @@ -106,7 +106,7 @@ and skill the member never edited, and "never edited" means equal to the version at a revision *this* checkout synced, not the shared `lastPullRev` another checkout may have moved (#812). Rules of a tool with its own rules format (`RULE_FORMATS` in `rule-format.ts`: Cursor, JoyCode, Copilot, Kiro, -Qoder) compare bodies against those revisions, ignoring derived frontmatter, +Qoder, Oh My Pi) compare bodies against those revisions, ignoring derived frontmatter, and render refreshed copies in the tool's native format. Rule sync uses the same tool root as the scanner, including `COPILOT_HOME` for user-scope Copilot instructions. It checks `isAgentExcluded` before installation detection, so retained tool @@ -177,14 +177,20 @@ older CLI's render, such as Claude extras in a Qoder copy). Without a is left alone. Rules get the same treatment for a render change (#946): when a CLI upgrade -gives a tool its own rules format (Kiro, Qoder, CodeBuddy), the fast path -rewrites each rule copy that still has the bytes `delivered` records but is not -the current render, records the new bytes, and leaves a copy without an entry -alone. A copy the member changed is kept and named when teamai would now +gives a tool its own rules format (Kiro, Qoder, CodeBuddy, Oh My Pi), the fast +path rewrites each rule copy that still has the bytes `delivered` records but +is not the current render, records the new bytes, and of a copy without an +entry rewrites only one that is the team rule verbatim, which is what the older +CLI wrote. A copy the member changed is kept and named when teamai would now deliver other bytes there. A destination that moved (WorkBuddy's project rules, from `.workbuddy/rules` to `.codebuddy/rules`) has no entry at its new path, so the fast path first reclaims the old copies (`LEGACY_RULE_DIRS`), writes the -new path where it is missing, and records the change. +new path where it is missing, and records the change. Oh My Pi's flat names +(`/.md` became `..md`, in the same directory) work the same +way through `movedFrom`: the copy at the old path is what lets the fast path +write the new one, with or without a `delivered` file; the old copy goes while +it has the recorded bytes or, with no entry, the team rule verbatim, and is +named otherwise, since the tool does not read it. ### Why the main worktree, not `git-common-dir` (verified) diff --git a/docs/usage-guide.md b/docs/usage-guide.md index 3c665166b..7ea2a3365 100644 --- a/docs/usage-guide.md +++ b/docs/usage-guide.md @@ -832,7 +832,7 @@ Exclusion rules take effect after role and tag filtering. When running `teamai p ### Push local resources -Before scanning, `push` refreshes unedited old rule copies from the team repo. For a tool with a rules format of its own (Cursor and JoyCode `.mdc`, Copilot `.instructions.md`, Kiro steering, Qoder rules), it compares Markdown bodies independently of the generated header and renders updates in that tool's format. Local body edits are preserved. For Copilot this applies to project rules and user rules under `COPILOT_HOME`. Each copy it refreshes is recorded as delivered, so the next `teamai pull` still updates it instead of keeping it as your change. +Before scanning, `push` refreshes unedited old rule copies from the team repo. For a tool with a rules format of its own (Cursor and JoyCode `.mdc`, Copilot `.instructions.md`, Kiro steering, Qoder and Oh My Pi rules), it compares Markdown bodies independently of the generated header and renders updates in that tool's format. Local body edits are preserved. For Copilot this applies to project rules and user rules under `COPILOT_HOME`. Each copy it refreshes is recorded as delivered, so the next `teamai pull` still updates it instead of keeping it as your change. When only the team's `paths` change, `push` also refreshes Copilot's `applyTo` if the local file still matches a recorded version's generated copy. A locally edited header is preserved in this case. @@ -2326,6 +2326,8 @@ These paths are verified against the ZCode desktop app: profiles created in its Oh My Pi (OMP) is available as a built-in target. TeamAI deploys skills, rules, and subagents to OMP's native directories — `.omp/skills/`, `.omp/rules/`, and `.omp/agents/` at project scope, and `~/.omp/agent/skills/`, `~/.omp/agent/rules/`, and `~/.omp/agent/agents/` at user scope (user-scope resources live under the agent directory `~/.omp/agent/`, a different prefix from the project one, so TeamAI switches prefixes with the scope). Team instructions go to `~/.omp/agent/RULES.md` in user scope and, in project scope, into each turn's system prompt through the extension below (see [Where the blocks go](#where-the-blocks-go)), and MCP servers merge into `~/.omp/agent/mcp.json` / `/.omp/mcp.json` (Claude `mcpServers` shape — see the MCP section above). Skills are one-level `/SKILL.md` bundles and TeamAI fills in a `description` on sync, which OMP's native skill provider requires to discover a skill. These paths follow OMP's documented discovery layout (verified against OMP 18.2.5). Hooks ride OMP's extension runner: `teamai pull` writes a single generated extension to `~/.omp/agent/extensions/teamai-hooks.ts` (never a project copy — OMP auto-loads both roots and would double-dispatch every event), which forwards OMP's `session_start` / `session_stop` / `before_agent_start` / `tool_result` events to the same `teamai hook-dispatch` entry point every other agent uses, gated on the session `cwd`. In a project session it also asks for the member's team instructions at `session_start` and appends them to the system prompt in `before_agent_start`. Every event carries the OMP session id (`ctx.sessionManager.getSessionId()`; a subagent has its own), and `tool_result` also the tool's text output and a status from `isError`, so upvote **adoption** runs for OMP's main agent: OMP sets no session variable in its shell, so a recall joins the session of the `bash` call that ran it, and a `read` with a line selector (`x.md:50-200`, `x.md:raw`) counts as a read of the file. From OMP 18.3.2 a subagent's events also carry its `ctx.agent` id and name, so the `teamai-recall` subagent's own reads never count. A subagent's session file sits under its parent's, whose header names the parent session, so the extension links the two on the subagent's tool calls, and a doc the main agent opens after a subagent's recall is upvoted (verified against OMP 18.4.8). The `session_stop` handler returns nothing, so a dispatch can never force a session continuation, and there is no matcher-scoped post-tool-use pass because OMP's tool ids are lowercase (`bash`, `read`, …) and it has no `Skill` / `TodoWrite` tool. User-scope `teamai uninstall` removes the extension; project uninstall preserves it for other projects. A same-named file without the TeamAI marker is never overwritten or removed, as with Pi. OMP profiles (`OMP_PROFILE` / `PI_CODING_AGENT_DIR` / `PI_CONFIG_DIR`), which relocate the agent directory, are not supported; the default `~/.omp/agent/` layout is used. +Rules are written in OMP's own frontmatter, in `.omp/rules/` and `~/.omp/agent/rules/`: a rule without `paths:` gets `alwaysApply: true`, so its text is in every prompt; a rule with `paths:` gets `globs` with its globs and a `description` (its first Markdown heading, or `Team rule for files matching `), so OMP lists it in the prompt's rulebook as `name (globs): description` and reads it when the work matches. OMP drops a rule with neither, which is what every verbatim copy teamai wrote before was. OMP reads only the top level of its rules directory, so a namespaced rule is written flat: `rules/fe/style.md` becomes `fe.style.md`, and `push` sends an edit of that file back to `rules/fe/style.md`. A namespaced rule whose flat name another rule you receive also has is not written: a root rule such as `rules/fe.style.md` keeps the file, two namespaced rules both go without, and `pull` names them while `doctor` fails. A file of your own that has a team rule's flat name is never overwritten or removed: only a delivery record or the exact render makes it teamai's. On `push`, only the Markdown body flows back, and a rule file in an OMP rules directory with no matching team rule is yours: `pull` leaves it and `push` never offers it as a new team rule. Copies an older teamai wrote verbatim are rewritten on the next `pull` when they still hold what teamai delivered, and an older nested `/.md` copy is removed once its flat copy is written, with or without a delivery record (the record, or the team rule verbatim, proves it unedited). One you edited is kept and named, since OMP does not read it: copy your edit into the flat file to keep it. OMP reads `.omp/rules/` only in the directory the session starts in, so the project's rules reach a session started at the project root, not one started in a subdirectory. + ### DeepSeek Harness DeepSeek Harness (`dsh`) is supported for TeamAI skills and shared resources. DSH's official Claude-hook bridge is a profile plugin rather than a settings-file hook surface, so when a user-level `~/.dsh/` installation is present, `teamai init`, `teamai pull`, or `teamai hooks inject` writes a Claude-compatible hook config and a Cordis patch under `~/.teamai/dsh/`. @@ -2382,7 +2384,7 @@ teamai remove rules --force # Skip the prompt, for scripts and CI Besides the provider, clone, config and hook checks, `doctor` verifies what reached your machine. ` is installed` fails when `enabledAgents` lists a tool that nothing would be delivered to, which is the case where a pull reports success and that tool receives nothing. It asks the same resolver the sync uses, so a tool that keeps its skills somewhere other than its tool root, as OpenClaw does with its workspace directory, is judged where the sync would actually write. It reports an installed tool as passing too, so `--json` carries one entry per enabled tool either way. The checks at the end of a pull cover the scope that pull resolved from the current directory; run `teamai doctor` in another scope to check that one. `Skills delivered to ` compares the skills your role namespaces, tag subscriptions and exclusions resolve to against what is on disk for each installed tool: it reports a skill that was never delivered separately from one that arrived unreadable — `SKILL.md` missing, its frontmatter unparseable, or its `name` not matching the directory, which keeps the agent from ever discovering it. `Team docs delivered` compares the docs you receive (a docs namespace you do not have active is left out) against `sharing.docs.localDir`, which has one destination rather than one per tool; each expected document has to be a file that can be read, so a directory or a dangling link sitting on the name counts as missing. It also reports extra non-hidden local files as stale, including when the team bundle is empty. Hidden local files are preserved and do not fail this check, and neither does a local copy of a team doc in a namespace you do not have active: pull removes it when it is unchanged and names it when you edited it. `doctor` also prints notes, which are information rather than failed checks. Each note names a namespace skill, agent, rule, shared-instructions file, env variable, hook, MCP server or team model profile that replaces a root one here (`rules: "style" from rules/checkout/style.md replaces rules/style.md`). When a namespace contributes env variables, hooks, MCP servers or team model profiles, a note also counts where that type's entries come from (`env: 3 received here (2 root, 1 checkout)`). Without roles or projects, the notes name each file the team repo defines more than once instead, and each env variable, hook or MCP server name repeated in its root file. -`Rules delivered to ` and `Agents delivered to ` do the same for the other two per-tool resources, and both ask the handler where an item lands rather than deriving a path: a rule's filename and content change per tool (`.md` verbatim, `.mdc` with derived `globs`/`alwaysApply`, `.instructions.md` with `applyTo`, Kiro's `.md` with `inclusion`/`fileMatchPattern`, Qoder's `.md` with `trigger`/`glob`, CodeBuddy's `.md` with `alwaysApply`/`paths`; tools that read one copy, as CodeBuddy and WorkBuddy do in a project, get one check naming both), and an agent's destination comes from its render, with `targets:` deciding which tools are owed a copy at all. A delivered rule is compared with the bytes the handler renders for that tool, not merely read for the keys its tool needs: a `.mdc` whose `globs` no longer match the team rule's `paths:` applies to the wrong files while carrying a perfectly legal `alwaysApply`, and that reads here as `delivered from an older copy` — the same label as a body that drifted, because both landed successfully and are still wrong. An agent is compared with the bytes its render produces, so a copy left behind by an older spec — a plain pull skips a scope whose team repo has not changed, so it can sit there indefinitely — is reported as `delivered from an older spec` rather than passing as present. `Every team agent reaches a tool` names an agent that renders for no installed tool — usually a spec that does not parse, or a `targets:` list naming only tools you do not have. These two are `doctor`-only: they read every rule per tool and parse every agent, which would spend the budget the checks at the end of a pull run under. +`Rules delivered to ` and `Agents delivered to ` do the same for the other two per-tool resources, and both ask the handler where an item lands rather than deriving a path: a rule's filename and content change per tool (`.md` verbatim, `.mdc` with derived `globs`/`alwaysApply`, `.instructions.md` with `applyTo`, Kiro's `.md` with `inclusion`/`fileMatchPattern`, Qoder's `.md` with `trigger`/`glob`, Oh My Pi's flat `.md` with `alwaysApply` or `globs`/`description`, CodeBuddy's `.md` with `alwaysApply`/`paths`; tools that read one copy, as CodeBuddy and WorkBuddy do in a project, get one check naming both), and an agent's destination comes from its render, with `targets:` deciding which tools are owed a copy at all. A delivered rule is compared with the bytes the handler renders for that tool, not merely read for the keys its tool needs: a `.mdc` whose `globs` no longer match the team rule's `paths:` applies to the wrong files while carrying a perfectly legal `alwaysApply`, and that reads here as `delivered from an older copy` — the same label as a body that drifted, because both landed successfully and are still wrong. An agent is compared with the bytes its render produces, so a copy left behind by an older spec — a plain pull skips a scope whose team repo has not changed, so it can sit there indefinitely — is reported as `delivered from an older spec` rather than passing as present. `Every team agent reaches a tool` names an agent that renders for no installed tool — usually a spec that does not parse, or a `targets:` list naming only tools you do not have. These two are `doctor`-only: they read every rule per tool and parse every agent, which would spend the budget the checks at the end of a pull run under. Three tools do not read a rules directory, so a per-file check cannot speak for them and each gets one of its own. `Team rules are active in opencode` checks that `opencode.json` (`.opencode/opencode.json` in a project) lists every glob the pull owns under `instructions`, and no stale one such as the relative `rules/*.md` an earlier release wrote: OpenCode does not auto-scan `.opencode/rules`, so without it every delivered `.md` is inert while the per-file check keeps passing. In user scope, `Team rules are inlined in Hermes SOUL.md` compares the teamai-managed block of `SOUL.md` with what the team rules inline to, since Hermes reads standing instructions from that one file rather than from a directory — a deleted block, or one left on an older rule set, is a tool reading the wrong rules with nothing on disk to show for it. In user scope, `Team rules are inlined in Codex AGENTS.md` compares the team-rules block of the tool's `AGENTS.md` with what the team rules inline to, and fails when an `AGENTS.override.md` beside it shadows the file. In a project, `Project rules and instructions reach whole through its session hooks` fails when the teamai `SessionStart` or `SubagentStart` entry in that tool's `hooks.json` is missing or does not set `additionalContextLimit: 0`, without which Codex keeps only the start and end of a large set. Codex has no `Rules delivered to ` check. diff --git a/docs/usage-guide.zh-CN.md b/docs/usage-guide.zh-CN.md index 4d2f1d9d2..e726b498b 100644 --- a/docs/usage-guide.zh-CN.md +++ b/docs/usage-guide.zh-CN.md @@ -738,7 +738,7 @@ excludedSkills: ### 推送本地资源 -扫描前,`push` 会用团队仓库的新版刷新未修改的旧规则副本。对于有自有规则格式的工具(Cursor 与 JoyCode 的 `.mdc`、Copilot 的 `.instructions.md`、Kiro steering、Qoder rules),会单独比较 Markdown 正文,忽略自动生成的头部,并以该工具的格式写入更新;本地正文编辑会保留。对 Copilot,此行为适用于项目规则和 `COPILOT_HOME` 下的用户规则。它刷新的每份副本都会记录为 teamai 写入的内容,因此下一次 `teamai pull` 仍会更新它,而不会当作你的修改保留。 +扫描前,`push` 会用团队仓库的新版刷新未修改的旧规则副本。对于有自有规则格式的工具(Cursor 与 JoyCode 的 `.mdc`、Copilot 的 `.instructions.md`、Kiro steering、Qoder 与 Oh My Pi rules),会单独比较 Markdown 正文,忽略自动生成的头部,并以该工具的格式写入更新;本地正文编辑会保留。对 Copilot,此行为适用于项目规则和 `COPILOT_HOME` 下的用户规则。它刷新的每份副本都会记录为 teamai 写入的内容,因此下一次 `teamai pull` 仍会更新它,而不会当作你的修改保留。 团队仅修改 `paths` 时,只要本地文件仍与某个已记录版本的生成副本一致,`push` 也会刷新 Copilot 的 `applyTo`;此时本地手动修改过的头部会保留。 @@ -2169,6 +2169,8 @@ ZCode 已作为内置目标支持。Skills 下发到 `.zcode/skills/`(ZCode Oh My Pi(OMP)已作为内置目标支持。TeamAI 将 Skills、Rules 和 Subagents 下发到 OMP 的原生目录——项目级为 `.omp/skills/`、`.omp/rules/` 和 `.omp/agents/`,用户级为 `~/.omp/agent/skills/`、`~/.omp/agent/rules/` 和 `~/.omp/agent/agents/`(用户级资源位于 agent 目录 `~/.omp/agent/` 下,与项目级前缀不同,TeamAI 会随作用域自动切换)。团队指令在用户范围写入 `~/.omp/agent/RULES.md`,在项目范围由下文的 extension 加入每轮的系统提示(见[这些块写到哪里](#这些块写到哪里));MCP Server 合并进 `~/.omp/agent/mcp.json` / `/.omp/mcp.json`(Claude `mcpServers` 结构,见上文 MCP 章节)。Skills 采用一层 `/SKILL.md` 目录结构,TeamAI 在同步时补全 `description`——OMP 原生 skill 发现要求该字段。以上路径遵循 OMP 官方文档的发现布局(对照 OMP 18.2.5 验证)。Hooks 走 OMP 的 extension runner:`teamai pull` 会生成唯一的 extension 写入 `~/.omp/agent/extensions/teamai-hooks.ts`(绝不写项目副本——OMP 会同时加载两个根并导致每个事件双派发),它把 OMP 的 `session_start` / `session_stop` / `before_agent_start` / `tool_result` 事件转发给所有 agent 共用的 `teamai hook-dispatch` 入口,并按会话 `cwd` 做项目门控。在项目会话中,它还会在 `session_start` 时获取成员的团队指令,并在 `before_agent_start` 中追加到系统提示。每个事件都携带 OMP 会话 id(`ctx.sessionManager.getSessionId()`;subagent 有自己的会话),`tool_result` 还带上工具的文本输出和根据 `isError` 得出的状态,因此 upvote **采纳(adoption)**在 OMP 主 agent 上生效:OMP 不在其 shell 中设置会话变量,所以 recall 归入运行它的那次 `bash` 调用所在的会话;带行选择器的 `read`(`x.md:50-200`、`x.md:raw`)计为对该文件的读取。从 OMP 18.3.2 起,subagent 的事件还会携带其 `ctx.agent` 的 id 和名称,因此 `teamai-recall` subagent 自身的读取从不计入。subagent 的会话文件位于父会话文件之下,父会话文件的头部写明父会话 id,因此 extension 会在 subagent 的工具调用中关联这两个会话,主 agent 在 subagent recall 之后打开的文档会被 upvote(对照 OMP 18.4.8 验证)。`session_stop` 处理器不返回任何值,分发绝不会强制会话继续;由于 OMP 的工具名是小写(`bash`、`read` 等)且没有 `Skill` / `TodoWrite` 工具,post-tool-use 不做 matcher 定向分发。用户级 `teamai uninstall` 会移除该 extension;项目级卸载为其他项目保留它。与 Pi 一样,不带 TeamAI 标记的同名文件绝不会被覆盖或删除。OMP 的 profile(`OMP_PROFILE` / `PI_CODING_AGENT_DIR` / `PI_CONFIG_DIR`,会迁移 agent 目录)暂不支持,使用默认的 `~/.omp/agent/` 布局。 +Rules 以 OMP 自己的 frontmatter 写入 `.omp/rules/` 与 `~/.omp/agent/rules/`:没有 `paths:` 的规则写成 `alwaysApply: true`,其正文进入每次的提示;带 `paths:` 的规则写成 `globs`(即其 glob 列表)加一个 `description`(正文的第一个 Markdown 标题,没有标题时为 `Team rule for files matching `),OMP 会在提示的 rulebook 中以 `name (globs): description` 列出它,并在工作匹配时读取。两者都没有的规则会被 OMP 丢弃,teamai 以前原样复制的每条规则正是如此。OMP 只读取 rules 目录的顶层,因此 namespace 下的规则会平铺写入:`rules/fe/style.md` 写成 `fe.style.md`,`push` 会把对该文件的修改写回 `rules/fe/style.md`。若你收到的另一条规则也对应同一个平铺文件名,该规则不会写入:根目录规则(例如 `rules/fe.style.md`)保留该文件,两条 namespace 规则则都不写入;`pull` 会指出这些规则,`doctor` 会报告失败。与团队规则平铺文件名相同的你自己的文件不会被覆盖或删除:只有下发记录或与渲染结果完全一致才能证明它是 teamai 写入的。`push` 时只有 Markdown 正文回流;OMP rules 目录中没有对应团队规则的文件属于你自己:`pull` 不会删除它,`push` 也不会把它当作新的团队规则提交。旧版 teamai 原样写入的副本,若仍是 teamai 写入的内容,下一次 `pull` 会改写为新格式;旧的嵌套副本 `/.md` 在平铺副本写入后删除,无论是否有下发记录(记录,或与团队规则原文一致,即可证明未被修改);你修改过的副本会保留并给出提示,因为 OMP 不会读取它:如需保留修改,请把它复制到平铺文件中。OMP 只在会话启动的目录读取 `.omp/rules/`,因此项目规则只到达从项目根目录启动的会话,从子目录启动的会话收不到。 + ### DeepSeek Harness DeepSeek Harness(`dsh`)支持 TeamAI Skills 和共享资源。DSH 官方的 Claude Hook Bridge 是通过 profile 插件加载的,并不是设置文件中的 Hooks;因此检测到用户级 `~/.dsh/` 安装后,`teamai init`、`teamai pull` 或 `teamai hooks inject` 会在 `~/.teamai/dsh/` 下生成兼容 Claude 的 Hook 配置和 Cordis patch。 @@ -2225,7 +2227,7 @@ teamai remove rules --force # 跳过确认,用于脚本和 CI 除了托管平台、clone、配置和 hook 检查之外,`doctor` 还会验证落到本机上的内容。` is installed` 在 `enabledAgents` 列出了不会收到任何内容的工具时失败——这正是 pull 报告成功、而该工具什么都没收到的情况。它使用与同步相同的解析逻辑,因此像 OpenClaw 这样把 skills 放在 workspace 目录而非工具根目录的工具,会在同步真正写入的位置被判断。工具已安装时也会作为通过项报告,因此 `--json` 无论哪种情况都会为每个已启用工具给出一条记录。pull 结束时的检查只覆盖它从当前目录解析出的那个 scope;其他 scope 请在对应目录下运行 `teamai doctor`。`Skills delivered to ` 会把角色命名空间、标签订阅与排除规则解析出的 skill 集合,与每个已安装工具磁盘上的内容比对:从未送达的 skill 与送达但不可读的 skill 会分别报告——后者指 `SKILL.md` 缺失、frontmatter 无法解析,或其 `name` 与目录名不一致,导致 agent 永远发现不了它。`Team docs delivered` 将你应收到的文档(不含未激活的 docs namespace)与 `sharing.docs.localDir` 比对(它只有一个目标目录,而非每个工具一个);每个应有的文档都必须是可读取的文件,因此占用了该名字的目录或断链接也算缺失。它还会将本地多余的非隐藏文件报告为过期文档,即使团队文档已经删空也会检查;本地隐藏文件会保留,不会使检查失败,未激活 namespace 中团队文档的本地副本也不会:pull 会删除未修改的副本,并点名你修改过的副本。`doctor` 还会输出提示,它们只是信息,不是失败的检查。每条提示指出一个在本机替换了根目录条目的 namespace skill、agent、rule、共享指令文件、env 变量、hook、MCP server 或团队模型配置(`rules: "style" from rules/checkout/style.md replaces rules/style.md`)。当某个 namespace 提供了 env 变量、hook、MCP server 或团队模型配置时,还会有一条提示按来源统计该类型的条目(`env: 3 received here (2 root, 1 checkout)`)。未配置角色或项目时,提示改为列出团队仓库中重复定义的每个文件,以及在根文件中重复出现的每个 env 变量、hook 或 MCP server 名称。 -`Rules delivered to ` 与 `Agents delivered to ` 对另外两类按工具下发的资源做同样的事,并且都向 handler 询问落点,而不是自行拼路径:rule 的文件名和内容因工具而异(`.md` 原样、`.mdc` 带派生的 `globs`/`alwaysApply`、`.instructions.md` 带 `applyTo`、Kiro 的 `.md` 带 `inclusion`/`fileMatchPattern`、Qoder 的 `.md` 带 `trigger`/`glob`、CodeBuddy 的 `.md` 带 `alwaysApply`/`paths`;共用一份副本的工具,例如项目中的 CodeBuddy 与 WorkBuddy,只有一项同时点名两者的检查),agent 的落点来自渲染结果,且由 `targets:` 决定哪些工具应当收到。已送达的 rule 会与 handler 为该工具渲染出的字节逐一比对,而不只是检查该工具所需的键是否存在:`globs` 与团队 rule 的 `paths:` 不再一致的 `.mdc`,即使 `alwaysApply` 取值合法,也会作用到错误的文件上;这里会报告为 `delivered from an older copy`——正文漂移的副本同样如此,因为两者都写入成功,却都是错的。agent 会与渲染结果逐字节比对:旧版 spec 留下的副本(普通 pull 会跳过团队仓库未变化的 scope,它可能一直留在那里)报告为 `delivered from an older spec`,而不是当作已送达。`Every team agent reaches a tool` 会指出在任何已安装工具上都无法渲染的 agent,通常是 spec 解析失败,或 `targets:` 只列了本机没有的工具。这两项仅在 `doctor` 中运行:它们会按工具读取每条 rule、解析每个 agent,放进 pull 结束时的检查会耗尽其时间预算。 +`Rules delivered to ` 与 `Agents delivered to ` 对另外两类按工具下发的资源做同样的事,并且都向 handler 询问落点,而不是自行拼路径:rule 的文件名和内容因工具而异(`.md` 原样、`.mdc` 带派生的 `globs`/`alwaysApply`、`.instructions.md` 带 `applyTo`、Kiro 的 `.md` 带 `inclusion`/`fileMatchPattern`、Qoder 的 `.md` 带 `trigger`/`glob`、Oh My Pi 平铺的 `.md` 带 `alwaysApply` 或 `globs`/`description`、CodeBuddy 的 `.md` 带 `alwaysApply`/`paths`;共用一份副本的工具,例如项目中的 CodeBuddy 与 WorkBuddy,只有一项同时点名两者的检查),agent 的落点来自渲染结果,且由 `targets:` 决定哪些工具应当收到。已送达的 rule 会与 handler 为该工具渲染出的字节逐一比对,而不只是检查该工具所需的键是否存在:`globs` 与团队 rule 的 `paths:` 不再一致的 `.mdc`,即使 `alwaysApply` 取值合法,也会作用到错误的文件上;这里会报告为 `delivered from an older copy`——正文漂移的副本同样如此,因为两者都写入成功,却都是错的。agent 会与渲染结果逐字节比对:旧版 spec 留下的副本(普通 pull 会跳过团队仓库未变化的 scope,它可能一直留在那里)报告为 `delivered from an older spec`,而不是当作已送达。`Every team agent reaches a tool` 会指出在任何已安装工具上都无法渲染的 agent,通常是 spec 解析失败,或 `targets:` 只列了本机没有的工具。这两项仅在 `doctor` 中运行:它们会按工具读取每条 rule、解析每个 agent,放进 pull 结束时的检查会耗尽其时间预算。 有三个工具并不读取 rules 目录,按文件比对的检查无法代表它们,因此各自单列一项。`Team rules are active in opencode` 检查 `opencode.json`(项目中为 `.opencode/opencode.json`)的 `instructions` 中是否列着 teamai 所拥有的每条 glob,且没有过时的条目(如旧版本写入的相对 `rules/*.md`):OpenCode 不会自动扫描 `.opencode/rules`,缺了它,已送达的每个 `.md` 都不会生效,而按文件比对的检查依旧通过。user scope 下,`Team rules are inlined in Hermes SOUL.md` 把 `SOUL.md` 中 teamai 管理的代码块与团队 rule 内联后的内容比对——Hermes 的常驻指令来自这一个文件而非某个目录,因此代码块被删除或停留在旧版规则集上,都意味着该工具读到的是错误的规则,而磁盘上看不出任何异常。user scope 下,`Team rules are inlined in Codex AGENTS.md` 把该工具 `AGENTS.md` 中的 team-rules 区块与团队 rule 内联后的内容比对,旁边有 `AGENTS.override.md` 遮蔽该文件时也会失败。在项目中,当该工具 `hooks.json` 中的 teamai `SessionStart` 或 `SubagentStart` 条目缺失或没有设置 `additionalContextLimit: 0` 时,`Project rules and instructions reach whole through its session hooks` 会失败:没有它,Codex 对较大的内容只保留开头和结尾。Codex 没有 `Rules delivered to ` 检查。 diff --git a/skill-data/core/references/contribute-member.md b/skill-data/core/references/contribute-member.md index 303ba3120..4f969c2ac 100644 --- a/skill-data/core/references/contribute-member.md +++ b/skill-data/core/references/contribute-member.md @@ -119,12 +119,15 @@ The doc lands in the team's `learnings/` and appears for teammates on their next Before listing rules, `push` refreshes copies whose bodies still match a recorded sync revision. The header teamai generates for a tool's own rules format (Cursor and JoyCode `.mdc`, Copilot `applyTo`, Kiro `inclusion`, Qoder -`trigger`, CodeBuddy and WorkBuddy `alwaysApply`) does not count as a local edit: unedited old copies update in that +`trigger`, CodeBuddy and WorkBuddy `alwaysApply`, Oh My Pi `alwaysApply`/`globs`) does not count as a local edit: unedited old copies update in that format, including under `COPILOT_HOME` in user scope. Genuine local body edits remain push candidates. Rule pre-sync leaves tools excluded by `enabledAgents` or `disabledAgents` untouched. When only team `paths` change, `applyTo` refreshes if the local file still matches a recorded version's generated copy; locally edited headers are kept. The copies push refreshes are recorded, so a later `teamai pull` still updates them. +Oh My Pi reads only the top of its rules directory, so a namespaced rule is +written flat there (`rules/fe/style.md` as `fe.style.md`); an edit of that file +pushes back to `rules/fe/style.md`. ## If push is denied diff --git a/src/__tests__/doctor-rules-delivery.test.ts b/src/__tests__/doctor-rules-delivery.test.ts index e2741ee4a..f9b7c8808 100644 --- a/src/__tests__/doctor-rules-delivery.test.ts +++ b/src/__tests__/doctor-rules-delivery.test.ts @@ -225,6 +225,39 @@ describe('doctor — rules delivered on disk', () => { expect(check.fix).not.toContain('.mdc'); }); + it('checks the flat file OMP reads for a namespaced rule, not the nested path (#946)', async () => { + await writeTeamRule('fe/style'); + teamConfig.toolPaths = { omp: { rules: '.omp/agent/rules' } }; + const dir = path.join(homeDir, '.omp/agent/rules'); + for (const name of ['coding-style', 'reviews']) { + await fse.outputFile(path.join(dir, `${name}.md`), `---\nalwaysApply: true\n---\n\nBody of ${name}\n`); + } + // Where an older teamai left it: OMP does not read below the top level. + await fse.outputFile(path.join(dir, 'fe', 'style.md'), 'Body of fe/style\n'); + + const missing = await rulesCheck('omp'); + expect(await missing.check()).toBe(false); + expect(missing.fix).toContain('not delivered: fe/style'); + + await fse.outputFile(path.join(dir, 'fe.style.md'), '---\nalwaysApply: true\n---\n\nBody of fe/style\n'); + expect(await (await rulesCheck('omp')).check()).toBe(true); + }); + + it('fails for a namespaced rule OMP gets no file for, as a root rule has its flat name (#946)', async () => { + await writeTeamRule('fe/style'); + await writeTeamRule('fe.style'); + teamConfig.toolPaths = { omp: { rules: '.omp/agent/rules' } }; + const dir = path.join(homeDir, '.omp/agent/rules'); + for (const name of ['coding-style', 'reviews', 'fe.style']) { + await fse.outputFile(path.join(dir, `${name}.md`), `---\nalwaysApply: true\n---\n\nBody of ${name}\n`); + } + + const check = await rulesCheck('omp'); + expect(await check.check()).toBe(false); + expect(check.fix).toContain('not written, as another team rule has its flat name: fe/style'); + expect(check.fix).toContain('rename one of them in the team repo'); + }); + describe('CodeBuddy and WorkBuddy (#946)', () => { const ALWAYS = '---\nalwaysApply: true\n---\n\n'; const defaults = TeamaiConfigSchema.parse({ team: 't', repo: 'owner/repo' }).toolPaths; diff --git a/src/__tests__/fixtures/rule-parsers/omp/rules.ts b/src/__tests__/fixtures/rule-parsers/omp/rules.ts new file mode 100644 index 000000000..6ea667d72 --- /dev/null +++ b/src/__tests__/fixtures/rule-parsers/omp/rules.ts @@ -0,0 +1,464 @@ +/** + * Oh My Pi's rule parser, vendored so teamai's OMP render is read back by the + * code that reads it (#946). Source: @oh-my-pi/pi-coding-agent and + * @oh-my-pi/pi-utils 18.2.1 (https://github.com/can1357/oh-my-pi): + * + * parseFrontmatter pi-utils/src/frontmatter.ts + * loadRulesDir coding-agent/src/discovery/builtin.ts (loadRules) + helpers.ts (loadFilesFromDir) + * buildRule, discoverRuleFromMarkdown coding-agent/src/discovery/helpers.ts + * parseRuleConditionAndScope, parseRuleAgents coding-agent/src/capability/rule.ts + * bucketRules coding-agent/src/capability/rule-buckets.ts + * + * Changes from the source, all needed to run outside Bun: Bun's `YAML.parse` + * is the `yaml` package's; the native `glob` that lists a rules directory is + * `readdirSync` with the same non-recursive `*.{md,mdc}` pattern, hidden + * files skipped; `Bun.Glob` in `ruleAppliesToAgent` is an exact match (teamai + * writes no `agents`); the TTSR manager is an interface that never accepts a + * rule (teamai writes no `condition`); the frontmatter warning is dropped. + * Update it from a new OMP release by re-copying these functions. + * + * MIT License + * + * Copyright (c) 2025 Mario Zechner + * Copyright (c) 2025-2026 Can Bölük + * Copyright (c) 2026 Stencil Labs, Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +import fs from 'node:fs'; +import path from 'node:path'; +import YAML from 'yaml'; + +export interface Rule { + name: string; + path: string; + content: string; + globs?: string[]; + alwaysApply?: boolean; + description?: string; + condition?: string[]; + astCondition?: string[]; + scope?: string[]; + agents?: string[]; + interruptMode?: 'never' | 'prose-only' | 'tool-only' | 'always'; +} + +type RuleFrontmatter = Record; + +// ─── pi-utils/src/frontmatter.ts ───────────────────────────────────────── + +function stripHtmlComments(content: string): string { + return content.replace(//g, ''); +} + +function kebabToCamel(key: string): string { + if (!key.includes('-')) return key; + return key.replace(/-([a-z])/g, (_, c: string) => c.toUpperCase()); +} + +function normalizeFrontmatterKeys(obj: T): T { + if (obj === null || typeof obj !== 'object') return obj; + if (Array.isArray(obj)) { + let changed = false; + const out: unknown[] = Array.from({ length: obj.length }); + for (let i = 0; i < obj.length; i++) { + const v: unknown = obj[i]; + const nv = normalizeFrontmatterKeys(v); + out[i] = nv; + if (nv !== v) changed = true; + } + return (changed ? (out as unknown) : obj) as T; + } + let changed = false; + const result: Record = {}; + for (const [key, value] of Object.entries(obj as Record)) { + const nk = key.includes('-') ? kebabToCamel(key) : key; + const nv = normalizeFrontmatterKeys(value); + result[nk] = nv; + if (nk !== key || nv !== value) changed = true; + } + return (changed ? result : obj) as T; +} + +const PLAIN_SCALAR_KEY_VALUE = /^(\s*[A-Za-z_][\w-]*:\s+)(\S.*?)(\s*)$/; +const FLOW_OR_EXPLICIT_VALUE_START = new Set(['"', "'", '[', '{', '|', '>', '!', '&', '*', '#']); + +function quoteAmbiguousPlainScalars(metadata: string): string | undefined { + let changed = false; + const lines = metadata.split('\n').map((line) => { + const match = line.match(PLAIN_SCALAR_KEY_VALUE); + if (!match) return line; + const [, prefix, rawValue, suffix] = match; + const value = rawValue.trimEnd(); + if (!value.includes(': ')) return line; + if (FLOW_OR_EXPLICIT_VALUE_START.has(value[0])) return line; + changed = true; + return `${prefix}${JSON.stringify(value)}${suffix}`; + }); + return changed ? lines.join('\n') : undefined; +} + +function parseYamlRecord(metadata: string, repairTabs: boolean): Record | null { + const loaded: unknown = YAML.parse(repairTabs ? metadata.replaceAll('\t', ' ') : metadata); + if (loaded === null || loaded === undefined) return null; + if (typeof loaded !== 'object' || Array.isArray(loaded)) return null; + return loaded as Record; +} + +export function parseFrontmatter(content: string): { frontmatter: Record; body: string } { + const frontmatter: Record = {}; + + const newlineNormalized = content.replace(/\r\n?/g, '\n'); + const normalized = stripHtmlComments(newlineNormalized); + if (!normalized.startsWith('---')) { + return { frontmatter, body: normalized }; + } + + const endIndex = normalized.indexOf('\n---', 3); + if (endIndex === -1) { + return { frontmatter, body: normalized }; + } + + const metadata = normalized.slice(4, endIndex); + const body = normalized.slice(endIndex + 4).trim(); + + try { + const loaded = parseYamlRecord(metadata, true); + return { frontmatter: normalizeFrontmatterKeys({ ...frontmatter, ...loaded }), body }; + } catch { + const quotedMetadata = quoteAmbiguousPlainScalars(metadata); + if (quotedMetadata) { + try { + const loaded = parseYamlRecord(quotedMetadata, true); + return { frontmatter: normalizeFrontmatterKeys({ ...frontmatter, ...loaded }), body }; + } catch { + // Fall through to the simple key/value fallback. + } + } + + for (const line of metadata.split('\n')) { + const match = line.match(/^([\w-]+):\s*(.*)$/); + if (!match) continue; + const raw = match[2].trim(); + let value: unknown = raw; + if (raw.length > 0) { + try { + const parsed: unknown = YAML.parse(raw); + if (parsed !== null && typeof parsed !== 'object') value = parsed; + else if (Array.isArray(parsed)) value = parsed; + } catch { + // keep the raw string + } + } + frontmatter[match[1]] = value; + } + + return { frontmatter: normalizeFrontmatterKeys(frontmatter), body }; + } +} + +// ─── coding-agent/src/capability/rule.ts ───────────────────────────────── + +const CONDITION_GLOB_SCOPE_TOOLS = ['edit', 'write'] as const; + +function normalizeRuleField(value: unknown): string[] | undefined { + if (typeof value === 'string') { + const token = value.trim(); + return token.length > 0 ? [token] : undefined; + } + if (!Array.isArray(value)) { + return undefined; + } + + const tokens = value + .filter((item): item is string => typeof item === 'string') + .map((item) => item.trim()) + .filter((item) => item.length > 0); + if (tokens.length === 0) { + return undefined; + } + + return Array.from(new Set(tokens)); +} + +function splitScopeTokens(value: string): string[] { + const tokens: string[] = []; + let current = ''; + let parenDepth = 0; + let bracketDepth = 0; + let braceDepth = 0; + let quote: '"' | "'" | undefined; + for (let i = 0; i < value.length; i++) { + const char = value[i]; + if (quote) { + current += char; + if (char === quote && value[i - 1] !== '\\') { + quote = undefined; + } + continue; + } + if (char === '"' || char === "'") { + quote = char; + current += char; + continue; + } + if (char === '(') { + parenDepth++; + current += char; + continue; + } + if (char === ')') { + parenDepth = Math.max(0, parenDepth - 1); + current += char; + continue; + } + if (char === '[') { + bracketDepth++; + current += char; + continue; + } + if (char === ']') { + bracketDepth = Math.max(0, bracketDepth - 1); + current += char; + continue; + } + if (char === '{') { + braceDepth++; + current += char; + continue; + } + if (char === '}') { + braceDepth = Math.max(0, braceDepth - 1); + current += char; + continue; + } + if (char === ',' && parenDepth === 0 && bracketDepth === 0 && braceDepth === 0) { + const token = current.trim(); + if (token.length > 0) { + tokens.push(token); + } + current = ''; + continue; + } + current += char; + } + + const tail = current.trim(); + if (tail.length > 0) { + tokens.push(tail); + } + + return tokens; +} + +function normalizeScopeField(value: unknown): string[] | undefined { + const normalized = normalizeRuleField(value); + if (!normalized) { + return undefined; + } + + const tokens = normalized + .flatMap(splitScopeTokens) + .map((token) => { + const quote = token[0]; + if (token.length >= 2 && (quote === '"' || quote === "'") && token[token.length - 1] === quote) { + return token.slice(1, -1).trim(); + } + return token; + }) + .filter((item) => item.length > 0); + if (tokens.length === 0) { + return undefined; + } + return Array.from(new Set(tokens)); +} + +function parseRuleAgents(value: unknown): string[] | undefined { + const tokens = normalizeScopeField(value); + if (!tokens) { + return undefined; + } + return Array.from(new Set(tokens.map((token) => token.replace(/\s*,\s*/g, ',').toLowerCase()))); +} + +function ruleAppliesToAgent(rule: Pick, agentName: string | undefined): boolean { + const patterns = rule.agents; + if (!patterns || patterns.length === 0 || agentName === undefined) { + return true; + } + const name = agentName.trim().toLowerCase(); + // Source: `pattern === name || new Bun.Glob(pattern).match(name)`. + return patterns.some((pattern) => pattern === name); +} + +function isLikelyFileGlob(value: string): boolean { + const token = value.trim(); + if (token.length === 0) { + return false; + } + if (/[\\^$+|()]/.test(token)) { + return false; + } + if (!/[?*[\]{}]/.test(token)) { + return false; + } + if (token.includes('/')) { + return true; + } + return /^\*\.[^\s/]+$/.test(token); +} + +function parseRuleConditionAndScope(frontmatter: RuleFrontmatter): Pick { + const rawCondition = frontmatter.condition ?? frontmatter.ttsr_trigger ?? frontmatter.ttsrTrigger; + const parsedCondition = normalizeRuleField(rawCondition); + const astCondition = normalizeRuleField(frontmatter.astCondition); + const parsedScope = normalizeScopeField(frontmatter.scope); + + const inferredScope: string[] = []; + const condition: string[] = []; + for (const token of parsedCondition ?? []) { + if (isLikelyFileGlob(token)) { + for (const toolName of CONDITION_GLOB_SCOPE_TOOLS) { + inferredScope.push(`tool:${toolName}(${token})`); + } + continue; + } + condition.push(token); + } + + if (condition.length === 0 && inferredScope.length > 0) { + condition.push('.*'); + } + + const scope = [...(parsedScope ?? []), ...inferredScope]; + return { + condition: condition.length > 0 ? Array.from(new Set(condition)) : undefined, + astCondition, + scope: scope.length > 0 ? Array.from(new Set(scope)) : undefined, + }; +} + +// ─── coding-agent/src/discovery/helpers.ts ─────────────────────────────── + +function buildRule(name: string, body: string, frontmatter: RuleFrontmatter, filePath: string): Rule { + const { condition, astCondition, scope } = parseRuleConditionAndScope(frontmatter); + + let globs: string[] | undefined; + if (Array.isArray(frontmatter.globs)) { + globs = frontmatter.globs.filter((item): item is string => typeof item === 'string'); + } else if (typeof frontmatter.globs === 'string') { + globs = [frontmatter.globs]; + } + + const resolvedName = name.replace(/\.(md|mdc)$/, ''); + const rawMode = frontmatter.interruptMode; + const interruptMode: Rule['interruptMode'] = rawMode === 'never' || rawMode === 'prose-only' || rawMode === 'tool-only' || rawMode === 'always' + ? rawMode + : undefined; + return { + name: resolvedName, + path: filePath, + content: body, + globs, + alwaysApply: frontmatter.alwaysApply === true, + description: typeof frontmatter.description === 'string' ? frontmatter.description : undefined, + condition, + astCondition, + scope, + agents: parseRuleAgents(frontmatter.agents), + interruptMode, + }; +} + +export function discoverRuleFromMarkdown(name: string, content: string, filePath: string): Rule | null { + const { frontmatter, body } = parseFrontmatter(content); + if (frontmatter.enabled === false) return null; + return buildRule(name, body, frontmatter, filePath); +} + +// ─── coding-agent/src/discovery/builtin.ts (loadRules), one rules dir ──── + +/** + * The rules OMP loads from one `rules` directory (`.omp/rules`, + * `~/.omp/agent/rules`): `*.md` and `*.mdc` at its top level only. The + * capability then drops a rule without content (`ruleCapability.validate`). + */ +export function loadRulesDir(dir: string): Rule[] { + let entries: fs.Dirent[]; + try { + entries = fs.readdirSync(dir, { withFileTypes: true }); + } catch { + return []; + } + const rules: Rule[] = []; + for (const entry of entries) { + if (!entry.isFile() || entry.name.startsWith('.') || !/\.(md|mdc)$/.test(entry.name)) continue; + const filePath = path.join(dir, entry.name); + const rule = discoverRuleFromMarkdown(entry.name, fs.readFileSync(filePath, 'utf8'), filePath); + if (rule && rule.content) rules.push(rule); + } + return rules; +} + +// ─── coding-agent/src/capability/rule-buckets.ts ───────────────────────── + +interface TtsrManager { + addRule(rule: Rule): boolean; +} + +export interface RuleBuckets { + rulebookRules: Rule[]; + alwaysApplyRules: Rule[]; +} + +/** + * Split the rules into the always-apply ones (their text in the system + * prompt) and the rulebook (listed by name, globs and description, read on + * demand); a rule in neither is dropped. TTSR rules are registered, not + * bucketed. + */ +export function bucketRules( + rules: readonly Rule[], + ttsrManager: TtsrManager = { addRule: () => false }, + options: { agentName?: string } = { agentName: 'main' }, +): RuleBuckets { + const includedRules: Rule[] = []; + for (const rule of rules) { + if (!ruleAppliesToAgent(rule, options.agentName)) continue; + includedRules.push(rule); + } + + const rulebookRules: Rule[] = []; + const alwaysApplyRules: Rule[] = []; + + for (const rule of includedRules) { + const hasTtsrCondition = (rule.condition && rule.condition.length > 0) || (rule.astCondition && rule.astCondition.length > 0); + const isTtsrRule = hasTtsrCondition ? ttsrManager.addRule(rule) : false; + if (isTtsrRule) continue; + if (rule.alwaysApply === true) { + alwaysApplyRules.push(rule); + continue; + } + if (rule.description) { + rulebookRules.push(rule); + } + } + + return { rulebookRules, alwaysApplyRules }; +} diff --git a/src/__tests__/omp-rule-parser.test.ts b/src/__tests__/omp-rule-parser.test.ts new file mode 100644 index 000000000..cf4009268 --- /dev/null +++ b/src/__tests__/omp-rule-parser.test.ts @@ -0,0 +1,64 @@ +import os from 'node:os'; +import path from 'node:path'; +import fse from 'fs-extra'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { teamRuleToOmpRule } from '../resources/omp-rule.js'; +import { bucketRules, loadRulesDir } from './fixtures/rule-parsers/omp/rules.js'; + +/** + * The OMP render read back by Oh My Pi's own rule parser, vendored from 18.2.1 + * (fixtures/rule-parsers/omp), so this runs everywhere, CI included (#946). + */ +describe('Oh My Pi reads the rule render as intended', () => { + let dir: string; + + beforeEach(async () => { + dir = await fse.mkdtemp(path.join(os.tmpdir(), 'teamai-omp-parser-')); + }); + + afterEach(async () => { + await fse.remove(dir); + }); + + const BODY = 'Use named exports.\n\n---\n\nA rule with a horizontal rule in it.'; + + it.each([ + ['an inline list', `---\npaths: ["src/**/*.ts", "test/**"]\n---\n\n${BODY}`, ['src/**/*.ts', 'test/**']], + ['a block list', `---\npaths:\n - "src/**/*.ts"\n - test/**\n---\n\n${BODY}`, ['src/**/*.ts', 'test/**']], + ['a brace glob', `---\npaths:\n - "src/{a,b}/**"\n---\n\n${BODY}`, ['src/{a,b}/**']], + ['an unquoted alias-like glob', `---\npaths: **/*.ts\n---\n\n${BODY}`, ['**/*.ts']], + ])('offers a rule scoped by %s in the rulebook with its globs', async (_label, source, globs) => { + await fse.writeFile(path.join(dir, 'scoped.md'), teamRuleToOmpRule(source)); + + const { alwaysApplyRules, rulebookRules } = bucketRules(loadRulesDir(dir)); + + expect(alwaysApplyRules).toEqual([]); + expect(rulebookRules).toMatchObject([ + { name: 'scoped', globs, description: `Team rule for files matching ${globs.join(', ')}`, content: BODY }, + ]); + }); + + it('always applies an unscoped rule', async () => { + await fse.writeFile(path.join(dir, 'plain.md'), teamRuleToOmpRule(`${BODY}\n`)); + + const { alwaysApplyRules, rulebookRules } = bucketRules(loadRulesDir(dir)); + + expect(alwaysApplyRules).toMatchObject([{ name: 'plain', content: BODY }]); + expect(rulebookRules).toEqual([]); + }); + + it('loads only the top level of its rules directory', async () => { + await fse.outputFile(path.join(dir, 'fe.style.md'), teamRuleToOmpRule('Flat.\n')); + await fse.outputFile(path.join(dir, 'fe', 'style.md'), teamRuleToOmpRule('Nested.\n')); + + expect(loadRulesDir(dir).map((rule) => rule.name)).toEqual(['fe.style']); + }); + + it('drops a rule with neither alwaysApply nor a description, which a verbatim scoped copy is', async () => { + await fse.writeFile(path.join(dir, 'verbatim.md'), `---\npaths: ["src/**"]\n---\n\n${BODY}`); + + const { alwaysApplyRules, rulebookRules } = bucketRules(loadRulesDir(dir)); + + expect([...alwaysApplyRules, ...rulebookRules]).toEqual([]); + }); +}); diff --git a/src/__tests__/omp.test.ts b/src/__tests__/omp.test.ts index eea5eda12..7deae1a8a 100644 --- a/src/__tests__/omp.test.ts +++ b/src/__tests__/omp.test.ts @@ -1,7 +1,7 @@ import os from 'node:os'; import path from 'node:path'; import fse from 'fs-extra'; -import { afterEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { KNOWN_AGENTS } from '../known-agents.js'; import { resolveMcpTargets } from '../mcp-reconcile.js'; import { @@ -12,6 +12,10 @@ import { AgentsHandler } from '../resources/agents.js'; import { detectMcpFormat } from '../resources/mcp-format.js'; import { ruleFileExtensionForTool, usesCursorMdcRules } from '../resources/rule-format.js'; import { RulesHandler } from '../resources/rules.js'; +import { openLedger, recordDelivered, type DeliveredHashes } from '../resources/delivered-copies.js'; +import { log } from '../utils/logger.js'; +import { resetWarnOnce } from '../utils/warn-once.js'; +import { loadStateForScope, saveStateForScope } from '../config.js'; import { TeamaiConfigSchema, scopedToolPaths } from '../types.js'; import type { LocalConfig, ResourceItem } from '../types.js'; @@ -116,7 +120,8 @@ describe('OMP rules directory is user-owned', () => { expect(await fse.readFile(path.join(homeDir, '.omp/agent/rules/notes.md'), 'utf8')).toBe('Personal rule.'); expect(await fse.readFile(path.join(homeDir, '.omp/agent/rules/nested/private.md'), 'utf8')).toBe('Personal nested rule.'); - expect(await fse.readFile(path.join(homeDir, '.omp/agent/rules/team.md'), 'utf8')).toBe('Team rule.'); + // OMP's own render of the team rule (#946). + expect(await fse.readFile(path.join(homeDir, '.omp/agent/rules/team.md'), 'utf8')).toBe('---\nalwaysApply: true\n---\n\nTeam rule.\n'); } finally { vi.unstubAllEnvs(); await fse.remove(tmp); @@ -232,3 +237,243 @@ describe('OMP receives legacy markdown team agents', () => { } }); }); + +describe('OMP gets its own rule render, namespaced rules flat (#946)', () => { + let tmp: string; + let homeDir: string; + let repoPath: string; + let projectRoot: string; + let localConfig: LocalConfig; + const handler = new RulesHandler(); + const teamConfig = TeamaiConfigSchema.parse({ team: 'test', repo: 'test/repo' }); + + const SCOPED = '---\npaths:\n - "src/**"\n---\n\nUse named exports.\n'; + const OMP_SCOPED = '---\ndescription: "Team rule for files matching src/**"\nglobs: ["src/**"]\n---\n\nUse named exports.\n'; + const NS = 'Namespaced rule.\n'; + const OMP_NS = '---\nalwaysApply: true\n---\n\nNamespaced rule.\n'; + const userRules = () => path.join(homeDir, '.omp/agent/rules'); + + beforeEach(async () => { + tmp = await fse.mkdtemp(path.join(os.tmpdir(), 'teamai-omp-render-')); + homeDir = path.join(tmp, 'home'); + repoPath = path.join(tmp, 'repo'); + projectRoot = path.join(tmp, 'project'); + await fse.outputFile(path.join(repoPath, 'rules', 'scoped.md'), SCOPED); + await fse.outputFile(path.join(repoPath, 'rules', 'fe', 'style.md'), NS); + await fse.ensureDir(userRules()); + await fse.ensureDir(path.join(homeDir, '.claude/rules')); + vi.stubEnv('HOME', homeDir); + resetWarnOnce(); + localConfig = { + repo: { localPath: repoPath, remote: 'test/repo' }, + username: 'test', + scope: 'user', + additionalRoles: [], + enabledAgents: ['omp', 'claude'], + } as unknown as LocalConfig; + }); + + afterEach(async () => { + vi.unstubAllEnvs(); + vi.restoreAllMocks(); + await fse.remove(tmp); + }); + + it('writes the OMP render to ~/.omp/agent/rules, a namespaced rule at the top level', async () => { + await handler.pullAllRules(teamConfig, localConfig); + + expect(await fse.readFile(path.join(userRules(), 'scoped.md'), 'utf8')).toBe(OMP_SCOPED); + expect(await fse.readFile(path.join(userRules(), 'fe.style.md'), 'utf8')).toBe(OMP_NS); + expect(await fse.pathExists(path.join(userRules(), 'fe'))).toBe(false); + // Claude reads its rules directory recursively, so its copy stays nested. + expect(await fse.readFile(path.join(homeDir, '.claude/rules/fe/style.md'), 'utf8')).toBe(NS); + }); + + it('writes the same to .omp/rules in project scope', async () => { + await fse.ensureDir(path.join(projectRoot, '.omp')); + localConfig.scope = 'project'; + localConfig.projectRoot = projectRoot; + + await handler.pullAllRules(teamConfig, localConfig); + + expect(await fse.readFile(path.join(projectRoot, '.omp/rules/scoped.md'), 'utf8')).toBe(OMP_SCOPED); + expect(await fse.readFile(path.join(projectRoot, '.omp/rules/fe.style.md'), 'utf8')).toBe(OMP_NS); + }); + + it('keeps the flat copy on the next pull, which has it on record', async () => { + const first = openLedger({}); + await handler.pullAllRules(teamConfig, localConfig, undefined, [], first); + + await handler.pullAllRules(teamConfig, localConfig, undefined, [], openLedger(first.hashes)); + + expect(await fse.readFile(path.join(userRules(), 'fe.style.md'), 'utf8')).toBe(OMP_NS); + }); + + it('a clean pull leaves nothing to push', async () => { + await handler.pullAllRules(teamConfig, localConfig); + + expect(await handler.scanLocalForPush(teamConfig, localConfig)).toEqual([]); + }); + + it('pushes an edit of a flat copy into rules//.md, without the OMP frontmatter', async () => { + await handler.pullAllRules(teamConfig, localConfig); + // Only the OMP copy is edited; Claude's stays as delivered. + const copy = path.join(userRules(), 'fe.style.md'); + await fse.writeFile(copy, OMP_NS.replace('Namespaced rule.', 'Edited namespaced rule.')); + + const items = await handler.scanLocalForPush(teamConfig, localConfig); + expect(items).toMatchObject([ + { name: 'fe/style', status: 'modified', sourcePath: copy, relativePath: 'rules/fe/style.md', namespace: 'fe' }, + ]); + await handler.pushItem(items[0], teamConfig, localConfig); + + expect(await fse.readFile(path.join(repoPath, 'rules', 'fe', 'style.md'), 'utf8')).toBe('Edited namespaced rule.\n'); + }); + + it('reclaims the nested copy an older teamai delivered, and keeps one the member edited', async () => { + // What an older teamai wrote: the team rules verbatim, namespaced ones nested. + const nested = path.join(userRules(), 'fe', 'style.md'); + const editedNested = path.join(userRules(), 'be', 'api.md'); + await fse.outputFile(path.join(repoPath, 'rules', 'be', 'api.md'), 'Backend rule.\n'); + await fse.outputFile(nested, NS); + await fse.outputFile(editedNested, 'Backend rule.\n'); + const previous: DeliveredHashes = {}; + await recordDelivered(previous, nested); + await recordDelivered(previous, editedNested); + await fse.writeFile(editedNested, 'My own backend wording.\n'); + const ledger = openLedger(previous); + + await handler.pullAllRules(teamConfig, localConfig, undefined, [], ledger); + + expect(await fse.pathExists(path.join(userRules(), 'fe'))).toBe(false); + expect(ledger.hashes[nested]).toBeUndefined(); + expect(await fse.readFile(editedNested, 'utf8')).toBe('My own backend wording.\n'); + expect(await fse.readFile(path.join(userRules(), 'fe.style.md'), 'utf8')).toBe(OMP_NS); + expect(await fse.readFile(path.join(userRules(), 'be.api.md'), 'utf8')).toBe('---\nalwaysApply: true\n---\n\nBackend rule.\n'); + }); + + it('removes the flat copy when the namespaced rule is removed', async () => { + await handler.pullAllRules(teamConfig, localConfig); + + const removed = await handler.removeItem('fe/style', teamConfig, localConfig); + + expect(removed).toContain(path.join(userRules(), 'fe.style.md')); + expect(await fse.pathExists(path.join(userRules(), 'fe.style.md'))).toBe(false); + }); + + it('leaves a namespaced rule out when a root rule has its flat name, and names both', async () => { + await fse.outputFile(path.join(repoPath, 'rules', 'fe.style.md'), 'Root rule.\n'); + const warn = vi.spyOn(log, 'warn'); + + await handler.pullAllRules(teamConfig, localConfig); + + expect(await fse.readFile(path.join(userRules(), 'fe.style.md'), 'utf8')).toBe('---\nalwaysApply: true\n---\n\nRoot rule.\n'); + expect(warn.mock.calls.map(([message]) => String(message))).toContain( + 'Skipped rule fe/style for omp: omp reads only the top level of its rules directory, where its file would be ' + + 'fe.style.md, which is the root rule fe.style. Rename one of them in the team repo.', + ); + }); + + it('writes neither of two namespaced rules with the same flat name, and says why', async () => { + await fse.outputFile(path.join(repoPath, 'rules', 'fe.style', 'x.md'), 'Other.\n'); + await fse.outputFile(path.join(repoPath, 'rules', 'fe', 'style.x.md'), 'One.\n'); + const warn = vi.spyOn(log, 'warn'); + + await handler.pullAllRules(teamConfig, localConfig); + + expect(await fse.pathExists(path.join(userRules(), 'fe.style.x.md'))).toBe(false); + expect(warn.mock.calls.map(([message]) => String(message))).toContain( + 'Skipped rules fe.style/x and fe/style.x for omp: omp reads only the top level of its rules directory, where ' + + 'both would be fe.style.x.md, so neither is written. Rename one of them in the team repo.', + ); + }); + + it('judges a flat-name clash only among the rules this member receives', async () => { + await fse.outputFile(path.join(repoPath, 'rules', 'fe.style', 'x.md'), 'Inactive namespace.\n'); + await fse.outputFile(path.join(repoPath, 'rules', 'fe', 'style.x.md'), 'Active namespace.\n'); + await fse.outputFile(path.join(repoPath, 'manifest', 'roles.yaml'), [ + 'version: 1', 'roles:', ' - id: dev', ' resources:', ' knowledge: [fe]', ' skills: []', + ' learnings: []', ' agents: []', '', + ].join('\n')); + localConfig.primaryRole = 'dev'; + const { buildRolePullContext, resolveDesiredRules } = await import('../resources/desired.js'); + const { items } = await resolveDesiredRules(teamConfig, localConfig, await buildRolePullContext(localConfig)); + + await handler.pullAllRules(teamConfig, localConfig, items); + + expect(await fse.readFile(path.join(userRules(), 'fe.style.x.md'), 'utf8')).toBe('---\nalwaysApply: true\n---\n\nActive namespace.\n'); + }); + + it('pushes the author\'s root copy of a rule they placed in a namespace, and leaves no flat copy beside it', async () => { + const state = await loadStateForScope(localConfig); + state.placedRules = { style: 'rules/fe/style.md' }; + await saveStateForScope(state, localConfig); + // A flat copy an earlier pull wrote before the placement was recorded. + await fse.outputFile(path.join(userRules(), 'fe.style.md'), OMP_NS); + + await handler.pullAllRules(teamConfig, localConfig); + + const authorCopy = path.join(userRules(), 'style.md'); + expect(await fse.readFile(authorCopy, 'utf8')).toBe(OMP_NS); + expect(await fse.pathExists(path.join(userRules(), 'fe.style.md'))).toBe(false); + await fse.writeFile(authorCopy, OMP_NS.replace('Namespaced rule.', 'Edited by the author.')); + const items = await handler.scanLocalForPush(teamConfig, localConfig); + expect(items.filter((item) => item.sourcePath === authorCopy)).toMatchObject([ + { name: 'style', relativePath: 'rules/fe/style.md', status: 'modified' }, + ]); + }); + + it('reclaims an unrecorded nested copy an older teamai wrote verbatim, on a machine with no delivery record', async () => { + const nested = path.join(userRules(), 'fe', 'style.md'); + await fse.outputFile(nested, NS); + + await handler.pullAllRules(teamConfig, localConfig, undefined, [], openLedger(undefined)); + + expect(await fse.pathExists(nested)).toBe(false); + expect(await fse.readFile(path.join(userRules(), 'fe.style.md'), 'utf8')).toBe(OMP_NS); + }); + + it('keeps an edited nested copy and says OMP does not read it', async () => { + const nested = path.join(userRules(), 'fe', 'style.md'); + await fse.outputFile(nested, 'My own wording.\n'); + const warn = vi.spyOn(log, 'warn'); + + await handler.pullAllRules(teamConfig, localConfig, undefined, [], openLedger({})); + + expect(await fse.readFile(nested, 'utf8')).toBe('My own wording.\n'); + expect(warn.mock.calls.map(([message]) => String(message))).toContain( + `Kept ${nested}: omp reads only the top level of its rules directory, so it does not read this copy of fe/style, ` + + `which teamai now delivers as ${path.join(userRules(), 'fe.style.md')}. To keep your edit, copy it into that ` + + 'file and share it with `teamai push`; then delete this one.', + ); + }); + + it("does not overwrite a member's own file that has a namespaced rule's flat name", async () => { + const mine = path.join(userRules(), 'fe.style.md'); + await fse.outputFile(mine, 'My own dotted rule.\n'); + const warn = vi.spyOn(log, 'warn'); + + await handler.pullAllRules(teamConfig, localConfig, undefined, [], openLedger({})); + + expect(await fse.readFile(mine, 'utf8')).toBe('My own dotted rule.\n'); + expect(warn.mock.calls.map(([message]) => String(message))).toContain( + `Kept ${mine}: teamai did not write it, and it is where omp would read team rule fe/style. ` + + 'Rename your file, then run `teamai pull --force`.', + ); + }); + + it("removes a tombstoned rule's flat copy only on record, not a member's own file of that name", async () => { + await fse.outputFile(path.join(repoPath, 'rules', '.removed'), 'be/api\nfe/old\n'); + const recorded = path.join(userRules(), 'fe.old.md'); + const mine = path.join(userRules(), 'be.api.md'); + await fse.outputFile(recorded, '---\nalwaysApply: true\n---\n\nOld.\n'); + await fse.outputFile(mine, 'My own dotted rule.\n'); + const previous: DeliveredHashes = {}; + await recordDelivered(previous, recorded); + + await handler.pullAllRules(teamConfig, localConfig, undefined, [], openLedger(previous)); + + expect(await fse.pathExists(recorded)).toBe(false); + expect(await fse.readFile(mine, 'utf8')).toBe('My own dotted rule.\n'); + }); +}); diff --git a/src/__tests__/pre-push-sync.test.ts b/src/__tests__/pre-push-sync.test.ts index 432cfeb67..02ee6b5dc 100644 --- a/src/__tests__/pre-push-sync.test.ts +++ b/src/__tests__/pre-push-sync.test.ts @@ -30,6 +30,7 @@ vi.mock('../utils/git.js', () => ({ import { syncTeamUpdatesToLocal } from '../utils/pre-push-sync.js'; import { fileHash } from '../utils/fs.js'; import { teamRuleToCopilotInstructions } from '../resources/copilot-instructions.js'; +import { teamRuleToOmpRule } from '../resources/omp-rule.js'; import { teamRuleToKiroSteering } from '../resources/kiro-steering.js'; import type { TeamaiConfig, LocalConfig } from '../types.js'; @@ -466,6 +467,19 @@ describe('syncTeamUpdatesToLocal — rules', () => { expect(await fse.readFile(localFile, 'utf-8')).toBe(teamRuleToKiroSteering(newRule)); }); + it('refreshes an unedited flat OMP copy of a namespaced rule from rules//.md (#946)', async () => { + await fse.ensureDir(path.join(homeDir, '.omp', 'agent', 'rules')); + teamConfig.toolPaths.omp = { rules: '.omp/rules', userScope: { rules: '.omp/agent/rules' } }; + await fse.outputFile(path.join(repoPath, 'rules', 'fe', 'style.md'), 'v2 content\n'); + const localFile = path.join(homeDir, '.omp/agent/rules', 'fe.style.md'); + await fse.writeFile(localFile, teamRuleToOmpRule('v1 content\n')); + mockGetFileContentAtRev.mockResolvedValue(Buffer.from('v1 content\n')); + + await syncTeamUpdatesToLocal(teamConfig, localConfig, 'abc1234'); + + expect(await fse.readFile(localFile, 'utf-8')).toBe(teamRuleToOmpRule('v2 content\n')); + }); + describe.each(['project', 'user'] as const)('Copilot rules in %s scope', (scope) => { let instructionsDir: string; const oldRule = '---\npaths: ["src/**/*.ts"]\n---\n\nv1 content\n'; diff --git a/src/__tests__/pull-rule-format-upgrade.test.ts b/src/__tests__/pull-rule-format-upgrade.test.ts index 151532600..29bb9667e 100644 --- a/src/__tests__/pull-rule-format-upgrade.test.ts +++ b/src/__tests__/pull-rule-format-upgrade.test.ts @@ -131,6 +131,102 @@ describe('a pull at an unchanged team revision after the rule formats change (#9 }); }); +/** + * OMP reads only the top of its rules directory, so a namespaced rule moved + * from `fe/style.md` to `fe.style.md`. The new path has no record, so the + * re-render above cannot reach it; the old copy is what moves it. + */ +describe('a pull at an unchanged team revision after OMP rules go flat (#946)', () => { + let tmpDir: string; + let homeDir: string; + let saved: State; + + const NS = 'Namespaced rule.\n'; + const OMP_NS = '---\nalwaysApply: true\n---\n\nNamespaced rule.\n'; + const rulesDir = () => path.join(homeDir, '.omp', 'agent', 'rules'); + const record = () => Object.values(saved.lastPullByWorkspace ?? {})[0]; + + beforeEach(async () => { + tmpDir = await fse.mkdtemp(path.join(os.tmpdir(), 'teamai-omp-flat-upgrade-')); + homeDir = path.join(tmpDir, 'home'); + const repoPath = path.join(tmpDir, 'team-repo'); + await fse.ensureDir(rulesDir()); + await fse.outputFile(path.join(repoPath, 'rules', 'fe', 'style.md'), NS); + await fse.outputFile(path.join(repoPath, 'rules', 'be', 'api.md'), 'Backend rule.\n'); + vi.stubEnv('HOME', homeDir); + saved = {} as State; + vi.mocked(saveStateForScope).mockImplementation(async (state) => { + saved = structuredClone(state); + }); + vi.mocked(loadStateForScope).mockImplementation(async () => structuredClone(saved) as never); + vi.mocked(loadTeamConfig).mockResolvedValue( + TeamaiConfigSchema.parse({ team: 'test', repo: 'https://example.invalid/x/team.git' }), + ); + vi.mocked(loadLocalConfigForScope).mockResolvedValue({ + repo: { localPath: repoPath, remote: 'https://example.invalid/x/team.git' }, + username: 'u', + updatePolicy: 'auto', + additionalRoles: [], + scope: 'user', + enabledAgents: ['omp'], + } as LocalConfig); + await pull({}); + // What an older CLI left at this revision: each rule verbatim and nested, + // on record; the member then edited the backend copy. + const delivered: Record = {}; + for (const name of ['fe.style.md', 'be.api.md']) await fse.remove(path.join(rulesDir(), name)); + for (const [rel, text] of [['fe/style.md', NS], ['be/api.md', 'Backend rule.\n']]) { + await fse.outputFile(path.join(rulesDir(), rel), text); + delivered[path.join(rulesDir(), rel)] = sha256(text); + } + record().delivered = delivered; + await fse.writeFile(path.join(rulesDir(), 'be', 'api.md'), 'My own backend wording.\n'); + vi.mocked(log.success).mockClear(); + vi.mocked(log.warn).mockClear(); + }); + + afterEach(async () => { + vi.mocked(saveStateForScope).mockReset(); + vi.mocked(loadStateForScope).mockImplementation(async () => ({}) as never); + vi.unstubAllEnvs(); + await fse.remove(tmpDir); + }); + + it('writes the flat copy OMP reads, and reclaims the nested one unless the member edited it', async () => { + await pull({}); + + const successes = vi.mocked(log.success).mock.calls.map(([message]) => String(message)); + expect(successes.some((message) => message.includes('Already synced at abc1234'))).toBe(true); + const flat = path.join(rulesDir(), 'fe.style.md'); + expect(await fse.readFile(flat, 'utf8')).toBe(OMP_NS); + expect(await fse.pathExists(path.join(rulesDir(), 'fe'))).toBe(false); + // The member's edit stays where it is; the flat copy still gets the team rule. + expect(await fse.readFile(path.join(rulesDir(), 'be', 'api.md'), 'utf8')).toBe('My own backend wording.\n'); + expect(await fse.readFile(path.join(rulesDir(), 'be.api.md'), 'utf8')).toBe('---\nalwaysApply: true\n---\n\nBackend rule.\n'); + expect(record().delivered?.[flat]).toBe(sha256(OMP_NS)); + expect(record().delivered?.[path.join(rulesDir(), 'fe', 'style.md')]).toBeUndefined(); + // The edited nested copy is named: OMP does not read it. + const warnings = vi.mocked(log.warn).mock.calls.map(([message]) => String(message)); + expect(warnings.filter((message) => message.includes(`Kept ${path.join(rulesDir(), 'be', 'api.md')}`))).toHaveLength(1); + }); + + it('does the same on a machine an older CLI left with no delivery record', async () => { + delete record().delivered; + // A root rule's verbatim copy too: the team rule's own bytes, so unedited. + await fse.outputFile(path.join(tmpDir, 'team-repo', 'rules', 'root.md'), 'Root rule.\n'); + await fse.outputFile(path.join(rulesDir(), 'root.md'), 'Root rule.\n'); + await fse.outputFile(path.join(rulesDir(), 'mine.md'), 'Not a team rule.\n'); + + await pull({}); + + expect(await fse.readFile(path.join(rulesDir(), 'root.md'), 'utf8')).toBe('---\nalwaysApply: true\n---\n\nRoot rule.\n'); + expect(await fse.readFile(path.join(rulesDir(), 'mine.md'), 'utf8')).toBe('Not a team rule.\n'); + expect(await fse.readFile(path.join(rulesDir(), 'fe.style.md'), 'utf8')).toBe(OMP_NS); + expect(await fse.pathExists(path.join(rulesDir(), 'fe'))).toBe(false); + expect(await fse.readFile(path.join(rulesDir(), 'be', 'api.md'), 'utf8')).toBe('My own backend wording.\n'); + }); +}); + /** * A CLI upgrade that moves OpenCode's rules globs must reach a machine whose * team revision has not moved, or OpenCode keeps loading through the old diff --git a/src/__tests__/pull-tombstone.test.ts b/src/__tests__/pull-tombstone.test.ts index 9c8da00c8..9d32cc272 100644 --- a/src/__tests__/pull-tombstone.test.ts +++ b/src/__tests__/pull-tombstone.test.ts @@ -45,7 +45,8 @@ vi.mock('../utils/logger.js', () => ({ })), })); -import { pull, cleanupInactiveNamespaceSkills } from '../pull.js'; +import { pull, cleanupInactiveNamespaceSkills, checkoutKey } from '../pull.js'; +import { fileHash } from '../utils/fs.js'; import { loadLocalConfigForScope, loadTeamConfig, detectProjectConfig, loadStateForScope } from '../config.js'; import type { TeamaiConfig, LocalConfig } from '../types.js'; @@ -191,6 +192,33 @@ describe('pull role-aware sync and cleanup', () => { expect(await fse.pathExists(path.join(homeDir, '.codex/rules', 'old-rule.md'))).toBe(false); }); + it("cleans up a tombstoned rule's flat OMP copy on record, not a member's file of that name or a live root rule (#946)", async () => { + // The beforeEach config, mocked; OMP added. + const teamConfig = (await loadTeamConfig(repoPath))!; + vi.mocked(loadTeamConfig).mockResolvedValue({ + ...teamConfig, + toolPaths: { ...teamConfig.toolPaths, omp: { rules: '.omp/rules', userScope: { rules: '.omp/agent/rules' } } }, + }); + const ompRules = path.join(homeDir, '.omp', 'agent', 'rules'); + await fse.writeFile(path.join(repoPath, 'rules', '.removed'), 'fe/old\nbe/api\nfe/live\n'); + await fse.writeFile(path.join(repoPath, 'rules', 'fe.live.md'), 'Live root rule.\n'); + const recorded = path.join(ompRules, 'fe.old.md'); + const mine = path.join(ompRules, 'be.api.md'); + await fse.outputFile(recorded, '---\nalwaysApply: true\n---\n\nOld.\n'); + await fse.outputFile(mine, 'My own dotted rule.\n'); + const delivered = { [recorded]: (await fileHash(recorded))! }; + vi.mocked(loadStateForScope).mockImplementation(async () => ({ + lastPull: null, + lastPullByWorkspace: { [await checkoutKey(homeDir)]: { rev: 'old', targets: [], delivered } }, + }) as unknown as Awaited>); + + await pull({}); + + expect(await fse.pathExists(recorded)).toBe(false); + expect(await fse.readFile(mine, 'utf8')).toBe('My own dotted rule.\n'); + expect(await fse.readFile(path.join(ompRules, 'fe.live.md'), 'utf8')).toBe('---\nalwaysApply: true\n---\n\nLive root rule.\n'); + }); + it('should clean up local skill directories that are tombstoned', async () => { // Tombstone for "old-skill" await fse.writeFile(path.join(repoPath, 'skills', '.removed'), 'old-skill\n'); diff --git a/src/__tests__/rule-render-contracts.test.ts b/src/__tests__/rule-render-contracts.test.ts index 16f8d2cdd..55c00307e 100644 --- a/src/__tests__/rule-render-contracts.test.ts +++ b/src/__tests__/rule-render-contracts.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest'; import { teamRuleToCodebuddyRule } from '../resources/codebuddy-rule.js'; import { teamRuleToKiroSteering } from '../resources/kiro-steering.js'; +import { teamRuleToOmpRule } from '../resources/omp-rule.js'; import { teamRuleToQoderRule } from '../resources/qoder-rule.js'; /** @@ -61,6 +62,35 @@ describe('Qoder rule render', () => { }); }); +describe('Oh My Pi rule render', () => { + it('makes an unscoped rule always applied', () => { + expect(teamRuleToOmpRule(UNSCOPED)).toBe('---\nalwaysApply: true\n---\n\nUse named exports.\n'); + }); + + it.each([ + ['an inline list', INLINE], + ['a block list', BLOCK], + ])('scopes %s with globs and a description, as OMP drops a rule with neither', (_label, source) => { + expect(teamRuleToOmpRule(source)).toBe( + '---\ndescription: "Team rule for files matching src/**/*.ts, test/**"\nglobs: ["src/**/*.ts", "test/**"]\n---\n\n' + + 'Use named exports.\n', + ); + }); + + it('keeps a brace glob whole', () => { + expect(teamRuleToOmpRule(BRACE)).toBe( + '---\ndescription: "Team rule for files matching src/{a,b}/**"\nglobs: ["src/{a,b}/**"]\n---\n\nUse named exports.\n', + ); + }); + + it('describes a scoped rule by its first heading, wherever it is', () => { + const source = '---\npaths: ["src/**"]\n---\n\nIntro line.\n\n## API "client" rules\n\nUse the shared client.\n'; + expect(teamRuleToOmpRule(source)).toBe( + '---\ndescription: "API \\"client\\" rules"\nglobs: ["src/**"]\n---\n\nIntro line.\n\n## API "client" rules\n\nUse the shared client.\n', + ); + }); +}); + describe('CodeBuddy rule render (CodeBuddy and WorkBuddy)', () => { const SCOPED = '---\nalwaysApply: false\npaths:\n - "src/**/*.ts"\n - "test/**"\n---\n\nUse named exports.\n'; diff --git a/src/__tests__/uninstall.test.ts b/src/__tests__/uninstall.test.ts index 43f02dc3f..9aeca135e 100644 --- a/src/__tests__/uninstall.test.ts +++ b/src/__tests__/uninstall.test.ts @@ -1198,6 +1198,31 @@ describe('uninstall', () => { expect(await fse.pathExists(path.join(cursorRules, 'my-own-rule.mdc'))).toBe(true); }); + it("removes the flat OMP copy of a namespaced team rule on uninstall, not a member's file of that name (#946)", async () => { + const { homeDir, repoPath } = await setupFixture(tmpDir); + vi.stubEnv('HOME', homeDir); + vi.stubEnv('SHELL', '/bin/zsh'); + await fse.outputFile(path.join(repoPath, 'rules', 'fe', 'style.md'), 'Frontend rule.\n'); + const ompRules = path.join(homeDir, '.omp', 'agent', 'rules'); + await fse.outputFile(path.join(ompRules, 'fe.style.md'), '---\nalwaysApply: true\n---\n\nFrontend rule.\n'); + await fse.outputFile(path.join(ompRules, 'my-own.rule.md'), 'Mine.\n'); + // The flat name of a team rule, but the member's own file: no record, not the render. + await fse.outputFile(path.join(repoPath, 'rules', 'be', 'api.md'), 'Backend rule.\n'); + await fse.outputFile(path.join(ompRules, 'be.api.md'), 'My own backend notes.\n'); + + const defaults = TeamaiConfigSchema.parse({ team: 't', repo: 'owner/repo' }).toolPaths; + mockAutoDetectInit.mockResolvedValue({ + localConfig: makeLocalConfig(homeDir, repoPath, { enabledAgents: ['omp'] }), + teamConfig: makeTeamConfig({ toolPaths: { omp: defaults.omp } }), + }); + + await uninstall({ force: true }); + + expect(await fse.pathExists(path.join(ompRules, 'fe.style.md'))).toBe(false); + expect(await fse.readFile(path.join(ompRules, 'my-own.rule.md'), 'utf8')).toBe('Mine.\n'); + expect(await fse.readFile(path.join(ompRules, 'be.api.md'), 'utf8')).toBe('My own backend notes.\n'); + }); + // Regression: MCP cleanup used to run after ~/.teamai/ was deleted, so the // ownership manifest was already gone and removeAll became a no-op. it('卸载时移除 teamai 管理的 MCP server,并保留用户自建的', async () => { diff --git a/src/doctor-delivery.ts b/src/doctor-delivery.ts index 1f0392c5d..b257df3b5 100644 --- a/src/doctor-delivery.ts +++ b/src/doctor-delivery.ts @@ -20,7 +20,7 @@ import { SHELL_PROFILE_CANDIDATE_NAMES, } from './utils/shell-profile.js'; import { getUserHome } from './utils/home.js'; -import { ruleFormatForTool } from './resources/rule-format.js'; +import { ruleFormatForTool, ruleStemsForTool } from './resources/rule-format.js'; /** * The checks that verify the payload rather than the plumbing: what each tool @@ -281,8 +281,8 @@ export async function buildRulesDeliveryChecks(ctx: DoctorContext): Promise ({ + ); + // A tool that reads only the top of its rules directory gets no file for a + // namespaced rule whose flat name another rule has (`deliveryTargets`). + for (const [tool, delivery] of byTool) { + if (!ruleFormatForTool(tool)?.flat) continue; + const stems = ruleStemsForTool(tool, items.map((item) => item.name)); + for (const item of items) if (!stems.has(item.name)) appendTo(delivery.problems, FLAT_NAME_TAKEN, item.name); + } + const perTool: Check[] = [...byTool].map(([tool, delivery]) => ({ name: `Rules delivered to ${tool}`, source: 'local', check: async () => !hasDeliveryProblem(delivery), // The fix names the directory rather than the tool: a rule's delivered // filename carries a per-tool extension the reader would have to derive. fix: `In ${delivery.dir}, ${describeProblems(delivery.problems, ruleLabels)}. ` + + (delivery.problems.has(FLAT_NAME_TAKEN) + ? `${tool} reads only the top level of that directory, so a rule not written there never reaches it: ` + + 'rename one of them in the team repo; `teamai pull` names the rules that share the file. ' + : '') + 'Run `teamai pull --force`: a plain pull skips a scope whose team repo has not changed, ' + `so it cannot restore this. ${olderRuleCopyMeaning(delivery.tool)}${changedByYouFix(delivery)}`, })); @@ -308,6 +320,9 @@ export async function buildRulesDeliveryChecks(ctx: DoctorContext): Promise rule.name)) + : new Set(); + for (const stem of flatStems) { + const localPath = path.join(baseDir, dir, `${stem}${ruleFileExtensionForTool(tool)}`); + if (ledger.previous?.[localPath] === undefined || !await pathExists(localPath)) continue; + if (await removedCopyChanged(ledger.previous, localPath)) { + log.warn(`[${scopeLabel}] Kept ${localPath}: the team removed the rule it is a copy of, but you changed this copy. Delete it when you no longer need it.`); + continue; + } + await remove(localPath); + forgetDelivered(ledger.hashes, localPath); + log.debug(`[${scopeLabel}] Cleaned up tombstoned rules copy ${stem} from ${dir}`); + } for (const name of tombstones) { for (const extension of tombstoneExtensions(type, tool)) { @@ -832,7 +848,10 @@ async function openCheckoutLedger(localConfig: LocalConfig, state?: State): Prom * Kiro and Qoder got their own format. Only a copy still on record as what * teamai wrote is rewritten (`RulesHandler.rerenderOutdatedCopies`), and the * record follows, so the next pull does not read the new bytes as an edit. A - * copy the member changed is kept and named, as a full sync names it. + * copy the member changed is kept and named, as a full sync names it. A copy + * with no record is rewritten only while it is the team rule verbatim, and a + * copy whose path moved within a tool's directory (OMP's flat names) is + * written at the new path, the old copy being the proof of delivery. * * The copies an older CLI left where the tool does not read them are * reclaimed first (`reclaimLegacyRuleCopies`, #938). That is also what writes @@ -852,9 +871,8 @@ async function rerenderOutdatedRules( const { items } = await resolveDesiredRules(freshConfig, localConfig, roleContext); const handler = getHandler('rules') as RulesHandler; const reclaimed = await handler.reclaimLegacyRuleCopies(freshConfig, localConfig, items, ledger); - const rewritten = key && ledger.previous !== undefined - ? await handler.rerenderOutdatedCopies(freshConfig, localConfig, items, ledger) - : []; + // With no record yet, it still rewrites a copy its bytes prove teamai's. + const rewritten = key ? await handler.rerenderOutdatedCopies(freshConfig, localConfig, items, ledger) : []; reportKept(ledger, scopeLabel); if (!key || (reclaimed === 0 && rewritten.length === 0)) return; const record = localConfig.scope === 'user' ? await userScopeRecord(state) : state.lastPullByWorkspace?.[key]; diff --git a/src/resources/omp-rule.ts b/src/resources/omp-rule.ts new file mode 100644 index 000000000..d931a5fc2 --- /dev/null +++ b/src/resources/omp-rule.ts @@ -0,0 +1,55 @@ +import type { RuleFormat } from './rule-format.js'; +import { mergeRuleBodyIntoTeamMd, ruleBodyEqualsTeamMd, rulePaths, teamRuleBody, teamRuleData } from './team-rule.js'; + +/** + * Oh My Pi rules (`.omp/rules/*.md`, `~/.omp/agent/rules/*.md`), in the + * frontmatter OMP 18.2.1 buckets rules by (`bucketRules`): + * + * - no `paths` → `alwaysApply: true`: the text is in every prompt + * - team `paths: [glob, ...]` → `globs` + `description`: listed in the + * prompt's rulebook as `name (globs): description`, read on demand + * + * OMP drops a rule with neither `alwaysApply` nor a `description`, which is + * what every verbatim copy was, so a description is generated + * (`ruleDescription`). The globs are a list, so a brace glob stays one entry. + * + * OMP lists `*.md` at the top of its rules directory only, so a namespaced + * rule is written flat (`flat`): `rules/fe/style.md` becomes `fe.style.md`. + * It reads `.omp/rules` only in the directory the session starts in. + */ +export function teamRuleToOmpRule(rawTeamRule: string): string { + const paths = rulePaths(teamRuleData(rawTeamRule)); + const body = teamRuleBody(rawTeamRule); + const frontmatter = paths.length > 0 + ? [ + `description: ${JSON.stringify(ruleDescription(body, paths))}`, + `globs: [${paths.map((glob) => JSON.stringify(glob)).join(', ')}]`, + ] + : ['alwaysApply: true']; + return `---\n${frontmatter.join('\n')}\n---\n\n${body}\n`; +} + +/** + * What the rulebook names a scoped rule by: its first Markdown heading, or a + * neutral line naming its globs. Not its first line of text, which is often + * the instruction itself and would then sit in every prompt. + */ +function ruleDescription(body: string, paths: readonly string[]): string { + let fenced = false; + for (const line of body.split('\n')) { + if (/^\s*(```|~~~)/.test(line)) fenced = !fenced; + const heading = fenced ? null : line.match(/^#{1,6}\s+(.+?)\s*#*\s*$/); + if (heading) return heading[1]; + } + return `Team rule for files matching ${paths.join(', ')}`; +} + +/** Oh My Pi's rules format. */ +export const OMP_RULE_FORMAT: RuleFormat = { + extension: '.md', + render: teamRuleToOmpRule, + bodyEquals: ruleBodyEqualsTeamMd, + mergeBodyIntoTeam: mergeRuleBodyIntoTeamMd, + scopeFields: ['alwaysApply', 'globs', 'description'], + flat: true, +}; diff --git a/src/resources/rule-format.ts b/src/resources/rule-format.ts index 65bc80af9..1fcabe1f0 100644 --- a/src/resources/rule-format.ts +++ b/src/resources/rule-format.ts @@ -3,9 +3,9 @@ * * The team repo always stores rules as tool-neutral `.md`. A tool with * a rules format of its own gets a render of it (`RULE_FORMATS`): Cursor and - * JoyCode `.mdc`, Copilot `.instructions.md`, Kiro steering, Qoder and - * CodeBuddy (which WorkBuddy shares) rules `.md` with their own frontmatter. - * Every other tool takes a verbatim `.md` copy. + * JoyCode `.mdc`, Copilot `.instructions.md`, Kiro steering, Qoder, CodeBuddy + * (which WorkBuddy shares) and Oh My Pi rules `.md` with their own + * frontmatter. Every other tool takes a verbatim `.md` copy. * * This module is the single place that decision lives, mirroring * `agentFileExtensionForTool` in `./agent-format.ts`. Every site that writes, @@ -20,6 +20,7 @@ import { CODEBUDDY_RULE_FORMAT } from './codebuddy-rule.js'; import { COPILOT_INSTRUCTIONS_FORMAT } from './copilot-instructions.js'; import { CURSOR_MDC_FORMAT } from './cursor-mdc.js'; import { KIRO_STEERING_FORMAT } from './kiro-steering.js'; +import { OMP_RULE_FORMAT } from './omp-rule.js'; import { QODER_RULE_FORMAT } from './qoder-rule.js'; type ToolPath = TeamaiConfig['toolPaths'][string]; @@ -36,6 +37,8 @@ export interface RuleFormat { mergeBodyIntoTeam(rawToolRule: string, existingTeamMd: string | null): string; /** The frontmatter fields the tool scopes a rule by, named in doctor's fix. */ readonly scopeFields: readonly string[]; + /** The tool reads only the top level of its rules directory, so a namespaced rule is written flat (`ruleStemsForTool`). */ + readonly flat?: true; } /** @@ -54,6 +57,7 @@ const RULE_FORMATS: Readonly> = { codebuddy: CODEBUDDY_RULE_FORMAT, // Same engine as CodeBuddy; in a project it reads .codebuddy/rules too. workbuddy: CODEBUDDY_RULE_FORMAT, + omp: OMP_RULE_FORMAT, }; const SESSION_HOOK_RULE_TOOLS = new Set(['codex', 'codex-internal', 'tcodex']); @@ -73,15 +77,73 @@ export function renderRuleForTool(tool: string, rawTeamRule: string): string { return ruleFormatForTool(tool)?.render(rawTeamRule) ?? rawTeamRule; } +/** `fe/style` as a tool that reads only the top of its rules directory gets it: `fe.style`. */ +export function flatStem(name: string): string { + return name.replaceAll('/', '.'); +} + +/** + * The file stem each of `teamNames` has in the tool's rules directory: the + * team name, or for a tool that reads only the top level (`RuleFormat.flat`) + * the name with its `/` turned into `.`, so `fe/style` is `fe.style`. A + * namespaced rule whose flat stem another of `teamNames` also has is left out + * (`flatStemSharers` names the others), so no two rules share a file and push + * maps each copy back to one rule; a root rule keeps its own name. + */ +export function ruleStemsForTool(tool: string, teamNames: Iterable): Map { + const names = [...new Set(teamNames)]; + if (!ruleFormatForTool(tool)?.flat) return new Map(names.map((name) => [name, name])); + const stems = new Map(); + for (const name of names) { + if (!name.includes('/') || flatStemSharers(tool, name, names).length === 0) stems.set(name, flatStem(name)); + } + return stems; +} + +/** The other names among `teamNames` that `name` shares its flat stem with in the tool's rules directory. */ +export function flatStemSharers(tool: string, name: string, teamNames: Iterable): string[] { + if (!ruleFormatForTool(tool)?.flat) return []; + const stem = flatStem(name); + return [...new Set(teamNames)].filter((other) => other !== name && flatStem(other) === stem); +} + +/** + * The team rule a file in the tool's rules directory (`stem`, its path less + * the extension) is the copy of, given the tool's `stems` + * (`ruleStemsForTool`): `fe.style` is `fe/style` for OMP. Undefined for a + * file below the top of a directory the tool reads only the top of, which is + * no rule of the tool's; `stem` itself when no team rule is delivered there. + */ +export function teamRuleNameForFile(tool: string, stem: string, stems: ReadonlyMap): string | undefined { + if (!ruleFormatForTool(tool)?.flat) return stem; + if (stem.includes('/')) return undefined; + for (const [name, delivered] of stems) { + if (delivered === stem) return name; + } + return stem; +} + +/** + * The flat stems the copies of `removed` rules have in the tool's rules + * directory, beyond their own names: none unless the tool reads only the top + * level, and none that a rule still in `teamNames` is delivered at. A file + * there may be the member's own, so only a delivery record makes it teamai's. + */ +export function flatStemsOfRemoved(tool: string, removed: Iterable, teamNames: Iterable): Set { + if (!ruleFormatForTool(tool)?.flat) return new Set(); + const live = new Set(ruleStemsForTool(tool, teamNames).values()); + return new Set([...removed].filter((name) => name.includes('/')).map(flatStem).filter((stem) => !live.has(stem))); +} + /** * True when the tool's rules directory also holds rules the member wrote in * the tool's own format, so pull removes only a copy it can prove it wrote * there: every tool with a rules format, except Cursor (teamai owns - * `.cursor/rules`), plus OMP and Pi. + * `.cursor/rules`), plus Pi. */ export function sharesRulesDirWithMember(tool: string): boolean { if (tool === 'cursor') return false; - return ruleFormatForTool(tool) !== undefined || tool === 'omp' || tool === 'pi'; + return ruleFormatForTool(tool) !== undefined || tool === 'pi'; } /** Extension teamai writes rules with for a given tool. */ diff --git a/src/resources/rules.ts b/src/resources/rules.ts index ede73f74e..311acc8bf 100644 --- a/src/resources/rules.ts +++ b/src/resources/rules.ts @@ -4,6 +4,7 @@ import { isToolInstalledForConfig, ResourceHandler } from './base.js'; import type { ResourceItem, ResourceItemStatus, DeliveryTarget, TeamaiConfig, LocalConfig } from '../types.js'; import { listFilesRecursive, pathExists, copyFile, ensureDir, remove, fileContentEqual, getFileMtime, listDirs, readFileSafe, writeFile, pruneEmptyDirs, fileHash } from '../utils/fs.js'; import { log } from '../utils/logger.js'; +import { warnOnce } from '../utils/warn-once.js'; import { TEAMAI_RULES_START, TEAMAI_RULES_END, TEAMAI_TEAM_RULES_START, TEAMAI_TEAM_RULES_END, resolveBaseDir, resolveToolBaseDir, resolveToolRootDir, isAgentExcluded, scopedToolPaths, SELF_KNOWLEDGE_SCAN_KEY } from '../types.js'; import { EXCLUDED_RULE_NAMES, isDeployedRecallRule, TEAMAI_CONTEXT_RULE_NAME } from '../builtin-rules.js'; import { splitFrontmatter } from '../utils/frontmatter.js'; @@ -20,6 +21,10 @@ import { ruleFormatForTool, renderRuleForTool, ruleStemFromFilename, + ruleStemsForTool, + flatStem, + flatStemSharers, + teamRuleNameForFile, sharesRulesDirWithMember, isLegacyCursorRuleFile, LEGACY_RULE_DIRS, @@ -108,10 +113,14 @@ export class RulesHandler extends ResourceHandler { const format = ruleFormatForTool(tool); const files = await listFilesRecursive(rulesDir); + const stems = await this.receivedRuleStems(tool, teamConfig, localConfig); for (const file of files) { if (!file.endsWith(ext)) continue; - // name includes subdirectory path, e.g. "common/coding-standards" - const name = file.slice(0, -ext.length); + // name includes subdirectory path, e.g. "common/coding-standards"; + // OMP's flat `fe.style` is the copy of `fe/style`, and it reads no + // file below the top, so none there is a rule to push. + const name = teamRuleNameForFile(tool, file.slice(0, -ext.length), stems); + if (name === undefined) continue; if (tombstones.has(name)) continue; if (EXCLUDED_RULE_NAMES.has(name)) continue; // Skip CLI built-in and legacy rules @@ -164,8 +173,8 @@ export class RulesHandler extends ResourceHandler { // File does not exist in team repo — candidate for "new". // Native rule directories can contain personal rules created by the // target tool, in its own format. Keep unknown files in a tool with a - // rules format, and in the OMP and Pi rule directories, local. - if (format || tool === 'omp' || tool === 'pi') continue; + // rules format, and in Pi's rule directory, local. + if (format || tool === 'pi') continue; const existing = candidates.get(name); if (!existing) { const mtime = await getFileMtime(localFilePath); @@ -282,6 +291,8 @@ export class RulesHandler extends ResourceHandler { // the render belongs here rather than in a second copy inside `doctor`. const source = await readFileSafe(item.sourcePath); const localName = await this.localNameFor(item.name, localConfig); + // For a tool that writes namespaced rules flat, resolved once on first need. + let received: string[] | undefined; const targets: DeliveryTarget[] = []; for (const [tool, toolPath] of Object.entries(scopedToolPaths(teamConfig, localConfig))) { if (isAgentExcluded(localConfig, tool)) continue; @@ -295,7 +306,23 @@ export class RulesHandler extends ResourceHandler { const destDir = path.join(resolveToolBaseDir(tool, localConfig), toolPath.rules); const ext = ruleFileExtensionForTool(tool); - const dest = path.join(destDir, `${localName}${ext}`); + let stem = localName; + let supersededStem: string | undefined = localName !== item.name ? item.name : undefined; + if (ruleFormatForTool(tool)?.flat && (localName.includes('/') || supersededStem !== undefined)) { + received ??= await this.receivedRuleNames(teamConfig, localConfig); + const names = [...received, item.name]; + const stems = ruleStemsForTool(tool, names); + supersededStem = supersededStem === undefined ? undefined : stems.get(supersededStem); + if (localName.includes('/')) { + const flat = stems.get(localName); + if (flat === undefined) { + warnOnce(flatNameClash(tool, localName, flatStemSharers(tool, localName, names), ext)); + continue; + } + stem = flat; + } + } + const dest = path.join(destDir, `${stem}${ext}`); const content = source === null ? undefined : renderRuleForTool(tool, source); // Tools that read one directory in one format share the copy (#946). const shared = targets.find((target) => target.dest === dest && target.content === content); @@ -307,14 +334,33 @@ export class RulesHandler extends ResourceHandler { tool, dest, content, - ...(localName !== item.name - ? { supersedes: path.join(destDir, `${item.name}${ext}`) } - : {}), + ...(supersededStem !== undefined ? { supersedes: path.join(destDir, `${supersededStem}${ext}`) } : {}), + ...(stem !== localName ? { movedFrom: path.join(destDir, `${localName}${ext}`) } : {}), }); } return targets; } + /** + * The team rules this member receives in this scope, as pull resolves them: + * a tool that writes namespaced rules flat judges a clash of flat names + * among these alone, so a rule the member does not get costs them nothing. + */ + private async receivedRuleNames(teamConfig: TeamaiConfig, localConfig: LocalConfig): Promise { + const { buildRolePullContext, resolveDesiredRules } = await import('./desired.js'); + const { items } = await resolveDesiredRules(teamConfig, localConfig, await buildRolePullContext(localConfig)); + return items.map((rule) => rule.name); + } + + /** + * The stems of the rules this member receives in `tool`'s rules directory + * (`ruleStemsForTool`), for reading a copy there back to its team rule. + */ + async receivedRuleStems(tool: string, teamConfig: TeamaiConfig, localConfig: LocalConfig): Promise> { + if (!ruleFormatForTool(tool)?.flat) return new Map(); + return ruleStemsForTool(tool, await this.receivedRuleNames(teamConfig, localConfig)); + } + /** * The name a delivered rule has in a tool's rules directory. It is the * team name — `fe-know/my-rule` lands at `rules/fe-know/my-rule.*` — except @@ -356,6 +402,12 @@ export class RulesHandler extends ResourceHandler { throw new Error(`Cannot read rule source ${item.sourcePath}`); } await ensureDir(destDir); + if (target.movedFrom !== undefined && await isMembersOwnFile(dest, content, ledger)) { + // The flat name of a namespaced rule may be a file the member wrote. + warnOnce(`Kept ${dest}: teamai did not write it, and it is where ${tool} would read team rule ${item.name}. ` + + 'Rename your file, then run `teamai pull --force`.'); + continue; + } if (!ledger || !await keepsEditedCopy(ledger, item, target)) { await writeFile(dest, content); if (ledger) await recordDelivered(ledger.hashes, dest); @@ -377,11 +429,13 @@ export class RulesHandler extends ResourceHandler { /** * Rewrite each delivered copy of `rules` that still holds what teamai * recorded writing there but is no longer the render: what an older CLI - * wrote before the tool got a rules format of its own (#946). For the - * "Already synced" pull, which does not run `pullItem`. A copy the member - * changed is kept, and queued on `ledger.kept` to be named when teamai - * would now deliver other bytes there; one with no record is left to the - * next full sync. Returns the names of the rules rewritten. + * wrote before the tool got a rules format of its own (#946). With no + * record, only a copy that is the team rule verbatim is rewritten. A copy + * whose path changed (`movedFrom`, OMP's flat names) is written at the new + * path while the old one is there. For the "Already synced" pull, which + * does not run `pullItem`. A copy the member changed is kept, and queued on + * `ledger.kept` to be named when teamai would now deliver other bytes there. + * Returns the names of the rules rewritten. */ async rerenderOutdatedCopies( teamConfig: TeamaiConfig, @@ -392,11 +446,28 @@ export class RulesHandler extends ResourceHandler { const rewritten = new Set(); for (const item of rules) { for (const target of await this.deliveryTargets(teamConfig, localConfig, item)) { - const { dest, content } = target; + const { dest, content, movedFrom } = target; const recorded = ledger.previous?.[dest]; const disk = await fileHash(dest); - if (content === undefined || recorded === undefined || disk === null || disk === contentHash(content)) continue; - if (disk !== recorded) { + // A copy whose file name changed (OMP's flat names): the copy at the + // old path says the rule was delivered here, so the new path is + // written, and the old copy goes unless the member changed it. + if (content !== undefined && disk === null && movedFrom !== undefined && await pathExists(movedFrom)) { + await ensureDir(path.dirname(dest)); + await writeFile(dest, content); + await recordDelivered(ledger.hashes, dest); + await reclaimMovedCopy({ ...target, movedFrom }, item, ledger, localConfig.repo.localPath); + rewritten.add(item.name); + continue; + } + if (content === undefined || disk === null || disk === contentHash(content)) continue; + if (recorded === undefined) { + // No record (a CLI from before #822 wrote none): a copy that is the + // team rule verbatim is what an older CLI wrote before the tool got + // a format of its own, and nobody edited it. Anything else is left. + const source = await readFileSafe(item.sourcePath); + if (source === null || disk !== contentHash(source)) continue; + } else if (disk !== recorded) { // Named by the caller's reportKept; a render unchanged since // delivery is the member's plain edit, which needs no word. if (contentHash(content) !== recorded) await keepsEditedCopy(ledger, item, target); @@ -436,12 +507,23 @@ export class RulesHandler extends ResourceHandler { async removeItem(name: string, teamConfig: TeamaiConfig, localConfig: LocalConfig): Promise { const removed: string[] = []; - // Remove from team repo (always `.md`) + // OMP's flat copies, judged against the team rule before it goes. const teamFile = path.join(localConfig.repo.localPath, 'rules', `${name}.md`); + const { deliveredHashes } = await import('../pull.js'); + const flatCopies = await this.ownedFlatCopies( + teamConfig, localConfig, [{ name, type: 'rules', sourcePath: teamFile, relativePath: `rules/${name}.md` }], + await deliveredHashes(localConfig), + ); + + // Remove from team repo (always `.md`) if (await pathExists(teamFile)) { await remove(teamFile); removed.push(teamFile); } + for (const { file } of flatCopies) { + await remove(file); + removed.push(file); + } // The author's own copy is at the rules root under the bare name, whatever // namespace the team file ended up in. Leaving it behind re-publishes the @@ -523,7 +605,7 @@ export class RulesHandler extends ResourceHandler { await (await import('../pull.js')).resolveCheckoutBases(localConfig, await loadStateForScope(localConfig)) ).revs; const delivered = (recorded !== undefined && recorded === await fileHash(file)) - || await isDeliveredRender(tool, file, rule, localConfig.repo.localPath, deliveredRevs); + || await isDeliveredRender([toolRender(tool)], file, rule, localConfig.repo.localPath, deliveredRevs); if (!delivered) continue; await remove(file); if (ledger) forgetDelivered(ledger.hashes, file); @@ -531,6 +613,30 @@ export class RulesHandler extends ResourceHandler { } } + /** + * The flat copies of `rules` (OMP's `fe.style.md` for `fe/style`) that are + * teamai's: on record in `previous`, or holding the render. A file there + * with neither is the member's own, whose name only happens to match. + * Read-only and public so `uninstall` removes what `remove` would. + */ + async ownedFlatCopies( + teamConfig: TeamaiConfig, + localConfig: LocalConfig, + rules: readonly ResourceItem[], + previous: DeliveredHashes | undefined, + ): Promise> { + const owned: Array<{ tool: string; file: string }> = []; + for (const rule of rules) { + for (const { tool, dest, content, movedFrom } of await this.deliveryTargets(teamConfig, localConfig, rule)) { + if (movedFrom === undefined || !await pathExists(dest)) continue; + if (previous?.[dest] !== undefined || (content !== undefined && await fileHash(dest) === contentHash(content))) { + owned.push({ tool, file: dest }); + } + } + } + return owned; + } + /** * Distribute rule files to each tool's rules/ directory, then update * CLAUDE.md with a lightweight reference list instead of inlining content. @@ -627,9 +733,10 @@ export class RulesHandler extends ResourceHandler { const replacedByName = new Map(replacedRoots.map((rule) => [rule.name, rule])); // The revisions this checkout's copies can be at: the shared lastPullRev // may be another checkout's, and HOME's copy an inherited pull's (#823). - const deliveredRevs = replacedRoots.length > 0 - ? (await (await import('../pull.js')).resolveCheckoutBases(localConfig, state)).revs - : []; + let checkoutRevs: readonly string[] | undefined; + const deliveredRevs = async (): Promise => checkoutRevs + ??= (await (await import('../pull.js')).resolveCheckoutBases(localConfig, state)).revs; + const rulesByName = new Map(rules.map((rule) => [rule.name, rule])); for (const [tool, toolPath] of Object.entries(scopedToolPaths(teamConfig, localConfig))) { if (!toolPath.rules) continue; // `pullItem` above skips excluded tools, so this pass must skip them too. @@ -642,6 +749,9 @@ export class RulesHandler extends ResourceHandler { if (!await pathExists(destDir)) continue; const ext = ruleFileExtensionForTool(tool); + // The stems this tool's copies of those rules have (flat for OMP). + const stems = ruleStemsForTool(tool, teamRuleNames); + const deliveredStems = new Set(stems.values()); const localFiles = await listFilesRecursive(destDir); for (const localFile of localFiles) { const ruleName = ruleStemFromFilename(localFile); @@ -655,20 +765,27 @@ export class RulesHandler extends ResourceHandler { // root rule's copy that is still exactly what pull wrote. Cursor is // deliberately absent — teamai owns .cursor/rules and sweeps it. if (sharesRulesDirWithMember(tool) && !tombstones.has(ruleName)) { - if (teamRuleNames.has(ruleName) || localFile !== `${ruleName}${ext}` || EXCLUDED_RULE_NAMES.has(ruleName)) continue; + if (deliveredStems.has(ruleName) || localFile !== `${ruleName}${ext}` || EXCLUDED_RULE_NAMES.has(ruleName)) continue; const replaced = replacedByName.get(ruleName); if (replaced === undefined) { const fullPath = path.join(destDir, localFile); const recorded = ledger?.previous?.[fullPath]; - if (recorded !== undefined && recorded === await fileHash(fullPath)) { + // The nested copy an older teamai wrote of a rule OMP now gets + // flat: verbatim then, so the team rule proves it with no record. + const nestedOf = ruleFormatForTool(tool)?.flat ? rulesByName.get(ruleName) : undefined; + const flatCopy = nestedOf && stems.has(ruleName) ? path.join(destDir, `${stems.get(ruleName)}${ext}`) : undefined; + if ((recorded !== undefined && recorded === await fileHash(fullPath)) + || (nestedOf && await isUneditedNestedCopy(fullPath, nestedOf, ledger?.previous, localConfig.repo.localPath, await deliveredRevs()))) { await remove(fullPath); if (ledger) forgetDelivered(ledger.hashes, fullPath); log.debug(`Removed stale rule ${localFile} from ${tool}`); + } else if (nestedOf && flatCopy) { + log.warn(keptNestedCopyMessage(tool, fullPath, ruleName, flatCopy)); } continue; } const deployed = path.join(destDir, localFile); - if (await isDeliveredRender([toolRender(tool)], deployed, replaced, localConfig.repo.localPath, deliveredRevs)) { + if (await isDeliveredRender([toolRender(tool)], deployed, replaced, localConfig.repo.localPath, await deliveredRevs())) { await remove(deployed); log.debug(`Removed ${localFile} from ${tool}: a namespace rule replaces it`); } else { @@ -694,7 +811,7 @@ export class RulesHandler extends ResourceHandler { if (!localFile.endsWith(ext)) continue; // Skip built-in and legacy rules (managed by CLI, not team repo) if (EXCLUDED_RULE_NAMES.has(ruleName)) continue; - if (!teamRuleNames.has(ruleName)) { + if (!deliveredStems.has(ruleName)) { const fullPath = path.join(destDir, localFile); // A copy the member changed since teamai delivered it stays (#822); // the tombstone cleanup names one of a rule the team removed. @@ -1076,6 +1193,80 @@ export class RulesHandler extends ResourceHandler { } } +/** + * Why a namespaced rule was not written for a tool that reads only the top + * of its rules directory: `sharers` have its flat file name. A root rule + * keeps the name; two namespaced rules both lose it. + */ +function flatNameClash(tool: string, name: string, sharers: readonly string[], ext: string): string { + const file = `${flatStem(name)}${ext}`; + const root = sharers.find((other) => !other.includes('/')); + const where = `${tool} reads only the top level of its rules directory, where`; + if (root !== undefined) { + return `Skipped rule ${name} for ${tool}: ${where} its file would be ${file}, which is the root rule ${root}. ` + + 'Rename one of them in the team repo.'; + } + const names = [name, ...sharers].sort(); + return `Skipped rules ${names.slice(0, -1).join(', ')} and ${names[names.length - 1]} for ${tool}: ${where} ` + + `${names.length === 2 ? 'both' : 'all'} would be ${file}, so neither is written. Rename one of them in the team repo.`; +} + +/** + * Remove the nested copy `target.movedFrom` names, now that `dest` holds the + * rule, while it is unedited (`isUneditedNestedCopy`); name one the member + * changed, which the tool does not read. + */ +async function reclaimMovedCopy( + target: DeliveryTarget & { movedFrom: string }, + rule: ResourceItem, + ledger: DeliveryLedger, + repoPath: string, +): Promise { + const { movedFrom } = target; + if (!await isUneditedNestedCopy(movedFrom, rule, ledger.previous, repoPath, [])) { + log.warn(keptNestedCopyMessage(target.tool, movedFrom, rule.name, target.dest)); + return; + } + await remove(movedFrom); + forgetDelivered(ledger.hashes, movedFrom); + await pruneEmptyDirs(path.dirname(movedFrom)); + log.debug(`Removed ${movedFrom}: ${target.tool} now reads it at ${target.dest}`); +} + +/** + * Whether `file`, the nested copy an older teamai wrote of `rule` before the + * tool's copies went flat (#946), is still what it delivered: what `previous` + * records, or the team rule verbatim (which is how it was written), as it is + * now or at one of `revs`. Without a record the bytes are the only proof. + */ +async function isUneditedNestedCopy( + file: string, + rule: ResourceItem, + previous: DeliveredHashes | undefined, + repoPath: string, + revs: readonly string[], +): Promise { + const recorded = previous?.[file]; + if (recorded !== undefined && recorded === await fileHash(file)) return true; + return isDeliveredRender([(raw) => raw], file, rule, repoPath, revs); +} + +/** A nested copy kept because the member changed it, which the tool never reads. */ +function keptNestedCopyMessage(tool: string, file: string, name: string, flatCopy: string): string { + return `Kept ${file}: ${tool} reads only the top level of its rules directory, so it does not read this copy of ${name}, ` + + `which teamai now delivers as ${flatCopy}. To keep your edit, copy it into that file and share it with \`teamai push\`; ` + + 'then delete this one.'; +} + +/** + * Whether `file` is there with other bytes than `content` and no record of + * teamai writing it: a file of the member's own at a path teamai now writes. + */ +async function isMembersOwnFile(file: string, content: string, ledger: DeliveryLedger | undefined): Promise { + const disk = await fileHash(file); + return disk !== null && disk !== contentHash(content) && ledger?.previous?.[file] === undefined; +} + /** What pull writes for `tool` from a team rule. */ function toolRender(tool: string): (rawTeamRule: string) => string { return (raw) => renderRuleForTool(tool, raw); diff --git a/src/types.ts b/src/types.ts index 50e74dc3e..40f2646a7 100644 --- a/src/types.ts +++ b/src/types.ts @@ -920,6 +920,14 @@ export interface DeliveryTarget { * project CodeBuddy and WorkBuddy both read `.codebuddy/rules` (#946). */ sharedWith?: string[]; + /** + * Where an older teamai delivered this copy for the same tool, before the + * tool's file name changed: OMP's `/.md`, now `..md`. + * `dest` has no record yet, so the "Already synced" pull writes it while + * the old copy is there, and reclaims that copy while it is unedited: on + * record, or the team rule verbatim (a full sync's stale sweep does too). + */ + movedFrom?: string; /** * The exact bytes `pullItem` writes at `dest`, for a handler that renders * its destination rather than copying a tree there. It is what tells a copy diff --git a/src/uninstall.ts b/src/uninstall.ts index 4782f30fb..85a8cd46f 100644 --- a/src/uninstall.ts +++ b/src/uninstall.ts @@ -746,6 +746,15 @@ async function buildRemovalPlan( if (edited.length > 0) res.keptRuleFiles.push({ files: edited, entry }); } + // (d) continued: OMP's flat copies of namespaced rules (`fe.style.md`), + // which a member's own file can share a name with: only those on record or + // holding the render go (#946). + const rulesHandler = new RulesHandler(); + const teamRules = await rulesHandler.scanTeamForPull(teamConfig, localConfig); + for (const { tool, file } of await rulesHandler.ownedFlatCopies(teamConfig, localConfig, teamRules, await deliveredHashes(localConfig))) { + perTool.get(tool)?.ruleFiles.push(file); + } + // (d) continued: OpenCode loads its rules through globs in opencode.json, // which would point at nothing once the copies go (#946). const opencodeTarget = opencodeRes diff --git a/src/utils/pre-push-sync.ts b/src/utils/pre-push-sync.ts index d98cc657f..6012e206f 100644 --- a/src/utils/pre-push-sync.ts +++ b/src/utils/pre-push-sync.ts @@ -17,7 +17,7 @@ import { } from './fs.js'; import { getFileContentAtRev, getFileContentWhenAdded } from './git.js'; import { isToolInstalledForConfig, ResourceHandler } from '../resources/base.js'; -import { ruleFileExtensionForTool, ruleFormatForTool, usesCopilotInstructions } from '../resources/rule-format.js'; +import { ruleFileExtensionForTool, ruleFormatForTool, teamRuleNameForFile, usesCopilotInstructions } from '../resources/rule-format.js'; import { EXCLUDED_RULE_NAMES } from '../builtin-rules.js'; import { log } from './logger.js'; import { placedResourcePath } from '../push-namespaces.js'; @@ -90,6 +90,8 @@ async function syncRulesToLocal( ): Promise { const teamRulesDir = path.join(repoPath, 'rules'); if (!await pathExists(teamRulesDir)) return; + const { RulesHandler } = await import('../resources/rules.js'); + const rulesHandler = new RulesHandler(); for (const [tool, toolPath] of Object.entries(scopedToolPaths(teamConfig, localConfig))) { if (!toolPath.rules) continue; @@ -106,10 +108,13 @@ async function syncRulesToLocal( const format = ruleFormatForTool(tool); const files = await listFilesRecursive(rulesDir); + const stems = await rulesHandler.receivedRuleStems(tool, teamConfig, localConfig); for (const file of files) { if (!file.endsWith(ext)) continue; - const name = file.slice(0, -ext.length); - if (EXCLUDED_RULE_NAMES.has(name)) continue; + // The same mapping as RulesHandler.scanLocalForPush: OMP's `fe.style` + // is the copy of `fe/style` (#946). + const name = teamRuleNameForFile(tool, file.slice(0, -ext.length), stems); + if (name === undefined || EXCLUDED_RULE_NAMES.has(name)) continue; const localFilePath = path.join(rulesDir, file); // The team repo always stores the tool-neutral `.md`. From a67c970bb1e11e65118e7d112cb63f410c187602 Mon Sep 17 00:00:00 2001 From: Saul Moro Date: Fri, 2 Oct 2026 01:34:48 +0200 Subject: [PATCH 07/20] fix(rules): render JoyCode project rules with unquoted globs (#946) --- CHANGELOG.md | 1 + README.ja.md | 1 + README.ko.md | 1 + README.md | 1 + README.th.md | 1 + README.zh-CN.md | 1 + docs/usage-guide.md | 10 +- docs/usage-guide.zh-CN.md | 10 +- .../core/references/contribute-member.md | 2 +- src/__tests__/doctor-rules-delivery.test.ts | 21 +++++ src/__tests__/helpers/rule-parsers.ts | 93 +++++++++++++++++++ src/__tests__/joycode.test.ts | 6 +- src/__tests__/kiro.test.ts | 4 +- src/__tests__/omp.test.ts | 4 +- src/__tests__/pull-namespace-override.test.ts | 25 +++++ .../pull-rule-format-upgrade.test.ts | 82 ++++++++++++++++ src/__tests__/qoder-cn.test.ts | 4 +- src/__tests__/qoder.test.ts | 4 +- src/__tests__/rule-parsers.test.ts | 39 +++++++- src/__tests__/rule-render-contracts.test.ts | 25 +++++ src/__tests__/rules.test.ts | 26 +++++- src/recall-toggle.ts | 2 +- src/resources/cursor-mdc.ts | 2 +- src/resources/joycode-rule.ts | 50 ++++++++++ src/resources/rule-format.ts | 9 +- src/resources/rules.ts | 43 ++++++++- 26 files changed, 431 insertions(+), 36 deletions(-) create mode 100644 src/resources/joycode-rule.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 0e6b798a3..131f46c34 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -52,6 +52,7 @@ All notable changes to this project will be documented in this file. See [standa - A project-scope `teamai pull` no longer rewrites Hermes' global `SOUL.md` rules block, and a project with no rules no longer erases it: only a user-scope pull writes it, `doctor` checks it only in user scope, and a project-scope `uninstall` leaves it. Hermes gets no project rules; in a project `init` and `doctor` say why (for [#946](https://github.com/Tencent/teamai-cli/issues/946)). - OpenCode loads the user-scope team rules again. The user `opencode.json` listed `rules/*.md`, which OpenCode resolves from the session's working directory, so it loaded the project's `rules/` instead. Pull now lists the absolute `~/.config/opencode/rules/*.md` plus one glob per namespace directory a rule lands in, replaces the old relative glob, and drops a namespace's glob once its rules no longer reach you, keeping a glob you added for a directory of your own; `doctor` checks every glob and flags a stale one, and `uninstall` removes them (for [#946](https://github.com/Tencent/teamai-cli/issues/946)). - CodeBuddy and WorkBuddy now get each rule in CodeBuddy's own format: `alwaysApply: false` with `paths:` as a block list when scoped, `alwaysApply: true` when not. CodeBuddy's frontmatter parser reads lines, so the inline `paths: ["a", "b"]` of a verbatim copy reached it as globs with the brackets in them. WorkBuddy's project rules now go into `.codebuddy/rules`, which it reads, as one copy shared with CodeBuddy: removing or excluding one tool keeps it while the other is installed, and `doctor` checks it once for both. WorkBuddy's user rules stay in `~/.workbuddy/rules`. Qoder and Qoder CN, which both read a project's `.qoder/rules` in one render, share their copies the same way. A rule file in `.codebuddy/rules` with no matching team rule is the member's own: pull no longer deletes it, and push no longer offers it as a new team rule (for [#946](https://github.com/Tencent/teamai-cli/issues/946)). +- JoyCode now gets each project rule in its own `.mdc` render instead of Cursor's. JoyCode reads the frontmatter by lines, keeps the quotes Cursor's render puts around `globs` and splits the value on every comma, so a scoped rule in `.joycode/rules` matched no file. Scoped rules now get `globs:` unquoted with `{a,b}` expanded, and `alwaysApply: false`; unscoped rules keep `alwaysApply: true`. The first pull after upgrading rewrites a copy still holding what teamai delivered, even when the team repo has not moved; a copy you edited is kept and named, and while its `globs` are still quoted pull says that JoyCode applies it to no file and how to fix it. A root rule's copy that a namespace rule replaces is now removed when it still holds what an older teamai wrote there (the team `.md` for Kiro, Qoder, CodeBuddy and Oh My Pi, Cursor's render for JoyCode), instead of being kept with a warning. `teamai doctor` compares each copy with the new render (for [#946](https://github.com/Tencent/teamai-cli/issues/946)). - `teamai pull` reclaims the rule copies earlier releases left in a project's `.workbuddy/rules`, which WorkBuddy never read, and writes them to `.codebuddy/rules`, also when the team repo has not moved. It also reclaims the team rules WorkBuddy's one-time migration copied from `~/.codebuddy/rules` into `~/.workbuddy/rules`: an unedited one is re-rendered while still delivered and removed otherwise, and an edited one is kept and named. The reclaim of old rule copies now runs on every rules sync, and its warnings say why each tool does not read the copy (for [#946](https://github.com/Tencent/teamai-cli/issues/946)). - OpenCode loads namespaced project rules. The root `opencode.json` listed `.opencode/rules/*.md`, which matched no rule in a namespace directory. Pull now lists `.opencode/rules/**/*.md` in `.opencode/opencode.json`, beside the team instructions entry, and removes the old glob from the root `opencode.json`, leaving its other keys. The first pull after upgrading moves the globs in both scopes even when the team repo has not moved; `doctor` checks `.opencode/opencode.json`, and `uninstall` removes the glob from both files, deleting a `.opencode/opencode.json` left empty (for [#946](https://github.com/Tencent/teamai-cli/issues/946)). - Team rules reach Codex, `codex-internal` and `tcodex`. Pull used to copy them to `.codex/rules/.md`, which Codex does not read, and `teamai doctor` reported them delivered. In user scope they now go into a team-rules block of the tool's own `AGENTS.md` (`~/.codex/AGENTS.md`), beside the culture, shared-instructions and recall blocks. In a project the session-start hook adds the project's rules and those blocks to each session, and to a spawned subagent through a new `SubagentStart` entry, with `additionalContextLimit: 0` so Codex keeps them whole; pull leaves the project `AGENTS.md`, which other tools read too, unchanged. Pull removes the `.md` copies earlier pulls left in the Codex rules directory at its recorded `toolRoots` location, including a publisher's bare local copy, keeping an edited one with a warning. Pull and uninstall keep a removed rule's legacy copy unless it matches its recorded delivery hash; a missing record proves nothing about local edits. A path-scoped rule is rendered without frontmatter after an `Applies to files matching: ` line, in Hermes' `SOUL.md` too. `teamai doctor` checks the user-scope block and, in a project, both hook entries' limit. The public Codex asks once to approve the changed teamai hooks (for [#938](https://github.com/Tencent/teamai-cli/issues/938)). diff --git a/README.ja.md b/README.ja.md index 4f1f24ad0..eb5ac769a 100644 --- a/README.ja.md +++ b/README.ja.md @@ -138,6 +138,7 @@ Git を基盤に、3 層の能力を構築します: Kiro✓✓✓✓✓✓✓—✓✓✓✓✓✓ ZCode✓—✓—✓✓✓—✓✓✓✓✓✓ Oh My Pi✓✓✓✓✓✓✓—✓✓✓——— + JoyCode✓✓✓✓✓———✓✓✓——— diff --git a/README.ko.md b/README.ko.md index 038c10061..245a78a85 100644 --- a/README.ko.md +++ b/README.ko.md @@ -138,6 +138,7 @@ Git을 기반으로 세 층의 역량을 구축합니다: Kiro✓✓✓✓✓✓✓—✓✓✓✓✓✓ ZCode✓—✓—✓✓✓—✓✓✓✓✓✓ Oh My Pi✓✓✓✓✓✓✓—✓✓✓——— + JoyCode✓✓✓✓✓———✓✓✓——— diff --git a/README.md b/README.md index 7aab2df3d..2727845ad 100644 --- a/README.md +++ b/README.md @@ -138,6 +138,7 @@ Three layers of capability, built on Git: Kiro✓✓✓✓✓✓✓—✓✓✓✓✓✓ ZCode✓—✓—✓✓✓—✓✓✓✓✓✓ Oh My Pi✓✓✓✓✓✓✓—✓✓✓——— + JoyCode✓✓✓✓✓———✓✓✓——— diff --git a/README.th.md b/README.th.md index e626dc08b..52afd7d60 100644 --- a/README.th.md +++ b/README.th.md @@ -138,6 +138,7 @@ teamai init https://github.com/your-org/your-repo --scope user Kiro✓✓✓✓✓✓✓—✓✓✓✓✓✓ ZCode✓—✓—✓✓✓—✓✓✓✓✓✓ Oh My Pi✓✓✓✓✓✓✓—✓✓✓——— + JoyCode✓✓✓✓✓———✓✓✓——— diff --git a/README.zh-CN.md b/README.zh-CN.md index ad4455dd8..9c4dfe0ce 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -144,6 +144,7 @@ teamai init https://github.com/your-org/your-repo --scope user Kiro✓✓✓✓✓✓✓—✓✓✓✓✓✓ ZCode✓—✓—✓✓✓—✓✓✓✓✓✓ Oh My Pi✓✓✓✓✓✓✓—✓✓✓——— + JoyCode✓✓✓✓✓———✓✓✓——— diff --git a/docs/usage-guide.md b/docs/usage-guide.md index 7ea2a3365..a795f3131 100644 --- a/docs/usage-guide.md +++ b/docs/usage-guide.md @@ -832,7 +832,7 @@ Exclusion rules take effect after role and tag filtering. When running `teamai p ### Push local resources -Before scanning, `push` refreshes unedited old rule copies from the team repo. For a tool with a rules format of its own (Cursor and JoyCode `.mdc`, Copilot `.instructions.md`, Kiro steering, Qoder and Oh My Pi rules), it compares Markdown bodies independently of the generated header and renders updates in that tool's format. Local body edits are preserved. For Copilot this applies to project rules and user rules under `COPILOT_HOME`. Each copy it refreshes is recorded as delivered, so the next `teamai pull` still updates it instead of keeping it as your change. +Before scanning, `push` refreshes unedited old rule copies from the team repo. For a tool with a rules format of its own (Cursor `.mdc`, JoyCode's own `.mdc`, Copilot `.instructions.md`, Kiro steering, Qoder and Oh My Pi rules), it compares Markdown bodies independently of the generated header and renders updates in that tool's format. Local body edits are preserved. For Copilot this applies to project rules and user rules under `COPILOT_HOME`. Each copy it refreshes is recorded as delivered, so the next `teamai pull` still updates it instead of keeping it as your change. When only the team's `paths` change, `push` also refreshes Copilot's `applyTo` if the local file still matches a recorded version's generated copy. A locally edited header is preserved in this case. @@ -2336,7 +2336,9 @@ TeamAI prints the exact absolute patch path. Add that `--patch` flag to the comm ### JoyCode -JoyCode is available as a built-in target. Skills, rules, and subagents are deployed to `.joycode/skills/`, `.joycode/rules/`, and `.joycode/agents/`. Rules use Cursor-compatible `.mdc` files, including the same derived frontmatter and body-only round-trip behavior described below. Subagents use Markdown with YAML frontmatter. +JoyCode is available as a built-in target. Skills, rules, and subagents are deployed to `.joycode/skills/`, `.joycode/rules/`, and `.joycode/agents/`. Subagents use Markdown with YAML frontmatter. + +Rules are `.mdc` files in JoyCode's own render. JoyCode reads the frontmatter line by line, not as YAML: it keeps the quotes Cursor's render puts around `globs` and splits the value on every comma, so a scoped rule in Cursor's form never applied. A rule with `paths:` gets `globs:` unquoted and comma-separated, with each `{a,b}` alternation expanded into separate globs, and `alwaysApply: false`; a rule without `paths` gets `alwaysApply: true`. On `push`, only the Markdown body flows back, as for Cursor. Copies an older teamai wrote in Cursor's form are rewritten on the next `pull` when they still hold what teamai delivered; one you edited is kept and named, and while its `globs` are still quoted, `pull` says it applies to no file and how to fix it. `doctor` compares each copy in a project's `.joycode/rules/` with this render. JoyCode rule cleanup is conservative: local `.mdc` and `.md` files absent from the team rule list are preserved unless an explicit team removal tombstone exists. This protects personal rules in the shared directory; an old team copy without a deletion record is retained rather than guessed to be stale. @@ -2348,7 +2350,7 @@ For canonical YAML agents, push compares each local file with the corresponding Cursor subagents deploy to `.cursor/agents/*.md` with YAML frontmatter carrying `agent_id` (the team agent's name), `description`, `tools`, and the agent's `model` when it declares one, plus any `tool_extras.cursor` fields; `reverseFromCursor` reads the same fields back, so a `pull` → `push` round-trip keeps the model. -Cursor project rules must live in `.cursor/rules/` as **`.mdc`** files with YAML frontmatter — a plain `.md` file there is silently ignored by Cursor. teamai therefore writes rules to Cursor as `.mdc` (every other tool still gets a plain `.md`), deriving the frontmatter from the team rule: +Cursor project rules must live in `.cursor/rules/` as **`.mdc`** files with YAML frontmatter — a plain `.md` file there is silently ignored by Cursor. teamai therefore writes rules to Cursor as `.mdc` (JoyCode, Copilot, Kiro, Qoder, Qoder CN, CodeBuddy, WorkBuddy and Oh My Pi get a format of their own, described in their sections; every other tool gets a plain `.md`), deriving the frontmatter from the team rule: - A rule scoped with a `paths:` list becomes `globs: ""` + `alwaysApply: false` (Cursor auto-attaches it when a matching file is in context). The value is quoted because a glob starting with `*` is not valid YAML unquoted. - A rule with no `paths` (a mandatory team rule) becomes `alwaysApply: true` (applied to every Cursor chat session). @@ -2384,7 +2386,7 @@ teamai remove rules --force # Skip the prompt, for scripts and CI Besides the provider, clone, config and hook checks, `doctor` verifies what reached your machine. ` is installed` fails when `enabledAgents` lists a tool that nothing would be delivered to, which is the case where a pull reports success and that tool receives nothing. It asks the same resolver the sync uses, so a tool that keeps its skills somewhere other than its tool root, as OpenClaw does with its workspace directory, is judged where the sync would actually write. It reports an installed tool as passing too, so `--json` carries one entry per enabled tool either way. The checks at the end of a pull cover the scope that pull resolved from the current directory; run `teamai doctor` in another scope to check that one. `Skills delivered to ` compares the skills your role namespaces, tag subscriptions and exclusions resolve to against what is on disk for each installed tool: it reports a skill that was never delivered separately from one that arrived unreadable — `SKILL.md` missing, its frontmatter unparseable, or its `name` not matching the directory, which keeps the agent from ever discovering it. `Team docs delivered` compares the docs you receive (a docs namespace you do not have active is left out) against `sharing.docs.localDir`, which has one destination rather than one per tool; each expected document has to be a file that can be read, so a directory or a dangling link sitting on the name counts as missing. It also reports extra non-hidden local files as stale, including when the team bundle is empty. Hidden local files are preserved and do not fail this check, and neither does a local copy of a team doc in a namespace you do not have active: pull removes it when it is unchanged and names it when you edited it. `doctor` also prints notes, which are information rather than failed checks. Each note names a namespace skill, agent, rule, shared-instructions file, env variable, hook, MCP server or team model profile that replaces a root one here (`rules: "style" from rules/checkout/style.md replaces rules/style.md`). When a namespace contributes env variables, hooks, MCP servers or team model profiles, a note also counts where that type's entries come from (`env: 3 received here (2 root, 1 checkout)`). Without roles or projects, the notes name each file the team repo defines more than once instead, and each env variable, hook or MCP server name repeated in its root file. -`Rules delivered to ` and `Agents delivered to ` do the same for the other two per-tool resources, and both ask the handler where an item lands rather than deriving a path: a rule's filename and content change per tool (`.md` verbatim, `.mdc` with derived `globs`/`alwaysApply`, `.instructions.md` with `applyTo`, Kiro's `.md` with `inclusion`/`fileMatchPattern`, Qoder's `.md` with `trigger`/`glob`, Oh My Pi's flat `.md` with `alwaysApply` or `globs`/`description`, CodeBuddy's `.md` with `alwaysApply`/`paths`; tools that read one copy, as CodeBuddy and WorkBuddy do in a project, get one check naming both), and an agent's destination comes from its render, with `targets:` deciding which tools are owed a copy at all. A delivered rule is compared with the bytes the handler renders for that tool, not merely read for the keys its tool needs: a `.mdc` whose `globs` no longer match the team rule's `paths:` applies to the wrong files while carrying a perfectly legal `alwaysApply`, and that reads here as `delivered from an older copy` — the same label as a body that drifted, because both landed successfully and are still wrong. An agent is compared with the bytes its render produces, so a copy left behind by an older spec — a plain pull skips a scope whose team repo has not changed, so it can sit there indefinitely — is reported as `delivered from an older spec` rather than passing as present. `Every team agent reaches a tool` names an agent that renders for no installed tool — usually a spec that does not parse, or a `targets:` list naming only tools you do not have. These two are `doctor`-only: they read every rule per tool and parse every agent, which would spend the budget the checks at the end of a pull run under. +`Rules delivered to ` and `Agents delivered to ` do the same for the other two per-tool resources, and both ask the handler where an item lands rather than deriving a path: a rule's filename and content change per tool (`.md` verbatim, `.mdc` with derived `globs`/`alwaysApply` (unquoted for JoyCode), `.instructions.md` with `applyTo`, Kiro's `.md` with `inclusion`/`fileMatchPattern`, Qoder's `.md` with `trigger`/`glob`, Oh My Pi's flat `.md` with `alwaysApply` or `globs`/`description`, CodeBuddy's `.md` with `alwaysApply`/`paths`; tools that read one copy, as CodeBuddy and WorkBuddy do in a project, get one check naming both), and an agent's destination comes from its render, with `targets:` deciding which tools are owed a copy at all. A delivered rule is compared with the bytes the handler renders for that tool, not merely read for the keys its tool needs: a `.mdc` whose `globs` no longer match the team rule's `paths:` applies to the wrong files while carrying a perfectly legal `alwaysApply`, and that reads here as `delivered from an older copy` — the same label as a body that drifted, because both landed successfully and are still wrong. An agent is compared with the bytes its render produces, so a copy left behind by an older spec — a plain pull skips a scope whose team repo has not changed, so it can sit there indefinitely — is reported as `delivered from an older spec` rather than passing as present. `Every team agent reaches a tool` names an agent that renders for no installed tool — usually a spec that does not parse, or a `targets:` list naming only tools you do not have. These two are `doctor`-only: they read every rule per tool and parse every agent, which would spend the budget the checks at the end of a pull run under. Three tools do not read a rules directory, so a per-file check cannot speak for them and each gets one of its own. `Team rules are active in opencode` checks that `opencode.json` (`.opencode/opencode.json` in a project) lists every glob the pull owns under `instructions`, and no stale one such as the relative `rules/*.md` an earlier release wrote: OpenCode does not auto-scan `.opencode/rules`, so without it every delivered `.md` is inert while the per-file check keeps passing. In user scope, `Team rules are inlined in Hermes SOUL.md` compares the teamai-managed block of `SOUL.md` with what the team rules inline to, since Hermes reads standing instructions from that one file rather than from a directory — a deleted block, or one left on an older rule set, is a tool reading the wrong rules with nothing on disk to show for it. In user scope, `Team rules are inlined in Codex AGENTS.md` compares the team-rules block of the tool's `AGENTS.md` with what the team rules inline to, and fails when an `AGENTS.override.md` beside it shadows the file. In a project, `Project rules and instructions reach whole through its session hooks` fails when the teamai `SessionStart` or `SubagentStart` entry in that tool's `hooks.json` is missing or does not set `additionalContextLimit: 0`, without which Codex keeps only the start and end of a large set. Codex has no `Rules delivered to ` check. diff --git a/docs/usage-guide.zh-CN.md b/docs/usage-guide.zh-CN.md index e726b498b..1c3b7a2f2 100644 --- a/docs/usage-guide.zh-CN.md +++ b/docs/usage-guide.zh-CN.md @@ -738,7 +738,7 @@ excludedSkills: ### 推送本地资源 -扫描前,`push` 会用团队仓库的新版刷新未修改的旧规则副本。对于有自有规则格式的工具(Cursor 与 JoyCode 的 `.mdc`、Copilot 的 `.instructions.md`、Kiro steering、Qoder 与 Oh My Pi rules),会单独比较 Markdown 正文,忽略自动生成的头部,并以该工具的格式写入更新;本地正文编辑会保留。对 Copilot,此行为适用于项目规则和 `COPILOT_HOME` 下的用户规则。它刷新的每份副本都会记录为 teamai 写入的内容,因此下一次 `teamai pull` 仍会更新它,而不会当作你的修改保留。 +扫描前,`push` 会用团队仓库的新版刷新未修改的旧规则副本。对于有自有规则格式的工具(Cursor 的 `.mdc`、JoyCode 自己的 `.mdc`、Copilot 的 `.instructions.md`、Kiro steering、Qoder 与 Oh My Pi rules),会单独比较 Markdown 正文,忽略自动生成的头部,并以该工具的格式写入更新;本地正文编辑会保留。对 Copilot,此行为适用于项目规则和 `COPILOT_HOME` 下的用户规则。它刷新的每份副本都会记录为 teamai 写入的内容,因此下一次 `teamai pull` 仍会更新它,而不会当作你的修改保留。 团队仅修改 `paths` 时,只要本地文件仍与某个已记录版本的生成副本一致,`push` 也会刷新 Copilot 的 `applyTo`;此时本地手动修改过的头部会保留。 @@ -2179,7 +2179,9 @@ TeamAI 会打印带绝对路径的 patch。将这个 `--patch` 参数加到启 ### JoyCode -JoyCode 已作为内置目标支持。Skills、Rules 和 Subagents 分别下发到 `.joycode/skills/`、`.joycode/rules/` 和 `.joycode/agents/`。Rules 使用与 Cursor 兼容的 `.mdc` 格式,包括下文所述的派生 frontmatter 和仅正文往返同步;Subagents 使用带 YAML frontmatter 的 Markdown 文件。 +JoyCode 已作为内置目标支持。Skills、Rules 和 Subagents 分别下发到 `.joycode/skills/`、`.joycode/rules/` 和 `.joycode/agents/`。Subagents 使用带 YAML frontmatter 的 Markdown 文件。 + +Rules 是采用 JoyCode 自有渲染的 `.mdc` 文件。JoyCode 逐行读取 frontmatter,而不是按 YAML 解析:它会保留 Cursor 渲染给 `globs` 加的引号,并按每个逗号拆分取值,因此 Cursor 形式的带 `paths:` 的 rule 从未生效。带 `paths:` 的 rule 写成不加引号、逗号分隔的 `globs:`,每个 `{a,b}` 选择项都展开为单独的 glob,并加上 `alwaysApply: false`;不带 `paths` 的 rule 写成 `alwaysApply: true`。`push` 时只有 Markdown 正文回流,与 Cursor 相同。旧版 teamai 以 Cursor 形式写入的副本,若仍是 teamai 所下发的内容,会在下一次 `pull` 时重写;你改过的副本会保留并被点名;只要其 `globs` 仍带引号,`pull` 会指出它不作用于任何文件,并说明如何修正。`doctor` 将项目 `.joycode/rules/` 中的每份副本与该渲染比对。 JoyCode 规则清理采用保守策略:不在团队规则列表中的本地 `.mdc` 和 `.md` 文件会被保留,只有团队明确记录了删除标记(tombstone)才会清理。这能保护同一目录中的个人规则;缺少删除记录的旧团队副本也会保留,不会猜测其已过期。 @@ -2191,7 +2193,7 @@ JoyCode 规则清理采用保守策略:不在团队规则列表中的本地 `. Cursor 的子代理部署到 `.cursor/agents/*.md`,YAML frontmatter 携带 `agent_id`(团队代理名)、`description`、`tools`,以及团队代理声明了的 `model`,外加所有 `tool_extras.cursor` 字段;`reverseFromCursor` 按同样字段读回,因此 `pull` → `push` 往返不会丢 model。 -Cursor 的项目规则必须以 **`.mdc`** 文件形式放在 `.cursor/rules/` 下,且带 YAML frontmatter——放在那里的纯 `.md` 会被 Cursor 直接忽略。因此 teamai 向 Cursor 写规则时用 `.mdc`(其他工具仍写纯 `.md`),并从团队规则派生 frontmatter: +Cursor 的项目规则必须以 **`.mdc`** 文件形式放在 `.cursor/rules/` 下,且带 YAML frontmatter——放在那里的纯 `.md` 会被 Cursor 直接忽略。因此 teamai 向 Cursor 写规则时用 `.mdc`(JoyCode、Copilot、Kiro、Qoder、Qoder CN、CodeBuddy、WorkBuddy 与 Oh My Pi 各有自己的格式,见各自小节;其他工具写纯 `.md`),并从团队规则派生 frontmatter: - 带 `paths:` 列表的规则会转成 `globs: "<逗号拼接>"` + `alwaysApply: false`(上下文中有匹配文件时 Cursor 自动附加该规则)。值加引号是因为以 `*` 开头的 glob 不加引号时并非合法 YAML。 - 无 `paths` 的规则(团队强制规则)会转成 `alwaysApply: true`(每个 Cursor 会话都应用)。 @@ -2227,7 +2229,7 @@ teamai remove rules --force # 跳过确认,用于脚本和 CI 除了托管平台、clone、配置和 hook 检查之外,`doctor` 还会验证落到本机上的内容。` is installed` 在 `enabledAgents` 列出了不会收到任何内容的工具时失败——这正是 pull 报告成功、而该工具什么都没收到的情况。它使用与同步相同的解析逻辑,因此像 OpenClaw 这样把 skills 放在 workspace 目录而非工具根目录的工具,会在同步真正写入的位置被判断。工具已安装时也会作为通过项报告,因此 `--json` 无论哪种情况都会为每个已启用工具给出一条记录。pull 结束时的检查只覆盖它从当前目录解析出的那个 scope;其他 scope 请在对应目录下运行 `teamai doctor`。`Skills delivered to ` 会把角色命名空间、标签订阅与排除规则解析出的 skill 集合,与每个已安装工具磁盘上的内容比对:从未送达的 skill 与送达但不可读的 skill 会分别报告——后者指 `SKILL.md` 缺失、frontmatter 无法解析,或其 `name` 与目录名不一致,导致 agent 永远发现不了它。`Team docs delivered` 将你应收到的文档(不含未激活的 docs namespace)与 `sharing.docs.localDir` 比对(它只有一个目标目录,而非每个工具一个);每个应有的文档都必须是可读取的文件,因此占用了该名字的目录或断链接也算缺失。它还会将本地多余的非隐藏文件报告为过期文档,即使团队文档已经删空也会检查;本地隐藏文件会保留,不会使检查失败,未激活 namespace 中团队文档的本地副本也不会:pull 会删除未修改的副本,并点名你修改过的副本。`doctor` 还会输出提示,它们只是信息,不是失败的检查。每条提示指出一个在本机替换了根目录条目的 namespace skill、agent、rule、共享指令文件、env 变量、hook、MCP server 或团队模型配置(`rules: "style" from rules/checkout/style.md replaces rules/style.md`)。当某个 namespace 提供了 env 变量、hook、MCP server 或团队模型配置时,还会有一条提示按来源统计该类型的条目(`env: 3 received here (2 root, 1 checkout)`)。未配置角色或项目时,提示改为列出团队仓库中重复定义的每个文件,以及在根文件中重复出现的每个 env 变量、hook 或 MCP server 名称。 -`Rules delivered to ` 与 `Agents delivered to ` 对另外两类按工具下发的资源做同样的事,并且都向 handler 询问落点,而不是自行拼路径:rule 的文件名和内容因工具而异(`.md` 原样、`.mdc` 带派生的 `globs`/`alwaysApply`、`.instructions.md` 带 `applyTo`、Kiro 的 `.md` 带 `inclusion`/`fileMatchPattern`、Qoder 的 `.md` 带 `trigger`/`glob`、Oh My Pi 平铺的 `.md` 带 `alwaysApply` 或 `globs`/`description`、CodeBuddy 的 `.md` 带 `alwaysApply`/`paths`;共用一份副本的工具,例如项目中的 CodeBuddy 与 WorkBuddy,只有一项同时点名两者的检查),agent 的落点来自渲染结果,且由 `targets:` 决定哪些工具应当收到。已送达的 rule 会与 handler 为该工具渲染出的字节逐一比对,而不只是检查该工具所需的键是否存在:`globs` 与团队 rule 的 `paths:` 不再一致的 `.mdc`,即使 `alwaysApply` 取值合法,也会作用到错误的文件上;这里会报告为 `delivered from an older copy`——正文漂移的副本同样如此,因为两者都写入成功,却都是错的。agent 会与渲染结果逐字节比对:旧版 spec 留下的副本(普通 pull 会跳过团队仓库未变化的 scope,它可能一直留在那里)报告为 `delivered from an older spec`,而不是当作已送达。`Every team agent reaches a tool` 会指出在任何已安装工具上都无法渲染的 agent,通常是 spec 解析失败,或 `targets:` 只列了本机没有的工具。这两项仅在 `doctor` 中运行:它们会按工具读取每条 rule、解析每个 agent,放进 pull 结束时的检查会耗尽其时间预算。 +`Rules delivered to ` 与 `Agents delivered to ` 对另外两类按工具下发的资源做同样的事,并且都向 handler 询问落点,而不是自行拼路径:rule 的文件名和内容因工具而异(`.md` 原样、`.mdc` 带派生的 `globs`/`alwaysApply`(JoyCode 的不加引号)、`.instructions.md` 带 `applyTo`、Kiro 的 `.md` 带 `inclusion`/`fileMatchPattern`、Qoder 的 `.md` 带 `trigger`/`glob`、Oh My Pi 平铺的 `.md` 带 `alwaysApply` 或 `globs`/`description`、CodeBuddy 的 `.md` 带 `alwaysApply`/`paths`;共用一份副本的工具,例如项目中的 CodeBuddy 与 WorkBuddy,只有一项同时点名两者的检查),agent 的落点来自渲染结果,且由 `targets:` 决定哪些工具应当收到。已送达的 rule 会与 handler 为该工具渲染出的字节逐一比对,而不只是检查该工具所需的键是否存在:`globs` 与团队 rule 的 `paths:` 不再一致的 `.mdc`,即使 `alwaysApply` 取值合法,也会作用到错误的文件上;这里会报告为 `delivered from an older copy`——正文漂移的副本同样如此,因为两者都写入成功,却都是错的。agent 会与渲染结果逐字节比对:旧版 spec 留下的副本(普通 pull 会跳过团队仓库未变化的 scope,它可能一直留在那里)报告为 `delivered from an older spec`,而不是当作已送达。`Every team agent reaches a tool` 会指出在任何已安装工具上都无法渲染的 agent,通常是 spec 解析失败,或 `targets:` 只列了本机没有的工具。这两项仅在 `doctor` 中运行:它们会按工具读取每条 rule、解析每个 agent,放进 pull 结束时的检查会耗尽其时间预算。 有三个工具并不读取 rules 目录,按文件比对的检查无法代表它们,因此各自单列一项。`Team rules are active in opencode` 检查 `opencode.json`(项目中为 `.opencode/opencode.json`)的 `instructions` 中是否列着 teamai 所拥有的每条 glob,且没有过时的条目(如旧版本写入的相对 `rules/*.md`):OpenCode 不会自动扫描 `.opencode/rules`,缺了它,已送达的每个 `.md` 都不会生效,而按文件比对的检查依旧通过。user scope 下,`Team rules are inlined in Hermes SOUL.md` 把 `SOUL.md` 中 teamai 管理的代码块与团队 rule 内联后的内容比对——Hermes 的常驻指令来自这一个文件而非某个目录,因此代码块被删除或停留在旧版规则集上,都意味着该工具读到的是错误的规则,而磁盘上看不出任何异常。user scope 下,`Team rules are inlined in Codex AGENTS.md` 把该工具 `AGENTS.md` 中的 team-rules 区块与团队 rule 内联后的内容比对,旁边有 `AGENTS.override.md` 遮蔽该文件时也会失败。在项目中,当该工具 `hooks.json` 中的 teamai `SessionStart` 或 `SubagentStart` 条目缺失或没有设置 `additionalContextLimit: 0` 时,`Project rules and instructions reach whole through its session hooks` 会失败:没有它,Codex 对较大的内容只保留开头和结尾。Codex 没有 `Rules delivered to ` 检查。 diff --git a/skill-data/core/references/contribute-member.md b/skill-data/core/references/contribute-member.md index 4f969c2ac..02960fe84 100644 --- a/skill-data/core/references/contribute-member.md +++ b/skill-data/core/references/contribute-member.md @@ -118,7 +118,7 @@ The doc lands in the team's `learnings/` and appears for teammates on their next Before listing rules, `push` refreshes copies whose bodies still match a recorded sync revision. The header teamai generates for a tool's own rules format -(Cursor and JoyCode `.mdc`, Copilot `applyTo`, Kiro `inclusion`, Qoder +(Cursor `.mdc`, JoyCode's own `.mdc`, Copilot `applyTo`, Kiro `inclusion`, Qoder `trigger`, CodeBuddy and WorkBuddy `alwaysApply`, Oh My Pi `alwaysApply`/`globs`) does not count as a local edit: unedited old copies update in that format, including under `COPILOT_HOME` in user scope. Genuine local body edits remain push candidates. Rule pre-sync leaves tools excluded by `enabledAgents` or `disabledAgents` untouched. diff --git a/src/__tests__/doctor-rules-delivery.test.ts b/src/__tests__/doctor-rules-delivery.test.ts index f9b7c8808..7284bde16 100644 --- a/src/__tests__/doctor-rules-delivery.test.ts +++ b/src/__tests__/doctor-rules-delivery.test.ts @@ -258,6 +258,27 @@ describe('doctor — rules delivered on disk', () => { expect(check.fix).toContain('rename one of them in the team repo'); }); + it('checks a project\'s .joycode/rules against JoyCode\'s render, not Cursor\'s quoted one (#946)', async () => { + const projectRoot = path.join(tempDir, 'project'); + Object.assign(localConfig, { scope: 'project', projectRoot }); + teamConfig.toolPaths = { joycode: { rules: '.joycode/rules' } }; + await writeTeamRule('reviews', '---\npaths:\n - "src/**"\n - "test/**"\n---\n'); + const dir = path.join(projectRoot, '.joycode/rules'); + await fse.outputFile(path.join(dir, 'coding-style.mdc'), '---\nalwaysApply: true\n---\n\nBody of coding-style\n'); + const reviews = path.join(dir, 'reviews.mdc'); + await fse.outputFile(reviews, '---\nglobs: src/**, test/**\nalwaysApply: false\n---\n\nBody of reviews\n'); + + expect(await (await rulesCheck('joycode')).check()).toBe(true); + + // What teamai wrote before: JoyCode matches the quotes and never applies it. + await fse.writeFile(reviews, '---\nglobs: "src/**, test/**"\nalwaysApply: false\n---\n\nBody of reviews\n'); + const check = await rulesCheck('joycode'); + expect(await check.check()).toBe(false); + expect(check.fix).toContain(dir); + expect(check.fix).toContain('delivered from an older copy: reviews'); + expect(check.fix).toContain('`globs` or `alwaysApply`'); + }); + describe('CodeBuddy and WorkBuddy (#946)', () => { const ALWAYS = '---\nalwaysApply: true\n---\n\n'; const defaults = TeamaiConfigSchema.parse({ team: 't', repo: 'owner/repo' }).toolPaths; diff --git a/src/__tests__/helpers/rule-parsers.ts b/src/__tests__/helpers/rule-parsers.ts index 4e861bf76..c39678c2f 100644 --- a/src/__tests__/helpers/rule-parsers.ts +++ b/src/__tests__/helpers/rule-parsers.ts @@ -9,6 +9,9 @@ import path from 'node:path'; * * TEAMAI_RULE_PARSER_BUNDLES=cursor=$HOME/.local/share/cursor-agent/versions/:codebuddy=/node_modules/@tencent-ai/codebuddy-code * + * and `joycode=` (an unpacked + * `.vsix` has it under `extension/`). + * * A test whose tool has no entry is skipped; CI runs the byte-exact contract * tests instead. A loader for another tool goes here beside Cursor's. */ @@ -117,3 +120,93 @@ export function loadCodebuddyRuleParser(bundle: string): CodebuddyRuleParser { ].join('\n')); return factory(fs.promises.readFile) as CodebuddyRuleParser; } + +export interface JoycodeRuleParser { + /** JoyCode's read of one `.mdc` project rule (`parseMdcRule`). */ + parse(text: string, filename: string): { globs: string; alwaysApply: boolean; body: string }; + /** Whether JoyCode applies the rule while `file` is the active editor's file, in workspace `cwd`. */ + applies(text: string, filename: string, file: string, cwd: string): boolean; +} + +/** + * A require for the webpack modules of `source`, each evaluated from its own + * source: a module runs from its `ID(e,t,n){` header to the next one. + */ +function webpackRequire(source: string): (id: string) => Record { + const next = /\},\d+\((?:e(?:,t(?:,n)?)?)?\)\{/g; + const cache = new Map }>(); + const require = (id: string): Record => { + const cached = cache.get(id); + if (cached) return cached.exports; + const head = new RegExp(`[{,]${id}\\(((?:e(?:,t(?:,n)?)?)?)\\)\\{`).exec(source); + if (!head) throw new Error(`no webpack module ${id} in the bundle`); + const start = head.index + head[0].length; + next.lastIndex = start; + const end = next.exec(source); + if (!end) throw new Error(`no end of webpack module ${id}`); + const module = { exports: {} as Record }; + cache.set(id, module); + const params = head[1] ? head[1].split(',') : []; + new Function(...params, source.slice(start, end.index + 1).slice(0, -1)).call(module.exports, module, module.exports, require); + return module.exports; + }; + return require; +} + +/** + * JoyCode's project-rule reader, out of `dist/extension.js` in the + * `JoyCoder.joycoder-fe` extension (checked against 3.8.71). `parseMdcRule` + * and its helpers are found by the getters their module exports them under, + * and the minimatch JoyCode bundles by its export list. The decision is + * JoyCode's `collectProjectRuleEntries`: an `alwaysApply` rule applies; + * otherwise `globs` is split on every comma and each part matched against + * the file's basename, absolute path and workspace-relative path. The loader + * fails if the bundle no longer splits that way. + */ +export function loadJoycodeRuleParser(bundle: string): JoycodeRuleParser { + const file = fs.statSync(bundle).isDirectory() ? path.join(bundle, 'dist', 'extension.js') : bundle; + const source = fs.readFileSync(file, 'utf8'); + + const getter = (name: string): { name: string; at: number } => { + const match = new RegExp(`get ${name}\\(\\)\\{return ([\\w$]+)\\}`).exec(source); + if (!match) throw new Error(`no ${name} in ${file}`); + return { name: match[1], at: match.index }; + }; + const declaration = ({ name, at }: { name: string; at: number }): string => { + const start = source.indexOf(`function ${name}(`, at); + if (start < 0) throw new Error(`no function ${name} after its getter in ${file}`); + return functionSource(source, start); + }; + const parse = getter('parseMdcRule'); + const factory = new Function([ + declaration(getter('truncateByChars')), + declaration(getter('extractBodySummary')), + declaration(parse), + `return ${parse.name};`, + ].join('\n')); + const parseMdcRule = factory() as (text: string, filename: string) => { globs: string; alwaysApply: boolean; body: string }; + + if (!/else if\(([\w$]+)\)\{const ([\w$]+)=\1\.split\(","\)\.map\(([\w$]+)=>\3\.trim\(\)\);/.test(source)) { + throw new Error(`JoyCode no longer splits a rule's globs on every comma in ${file}`); + } + const minimatchAt = source.indexOf('t.Minimatch=t.match=t.makeRe=t.braceExpand='); + const minimatchId = minimatchAt < 0 ? undefined + : [...source.slice(0, minimatchAt).matchAll(/[{,](\d+)\(e,t,n\)\{"use strict"/g)].pop()?.[1]; + if (!minimatchId) throw new Error(`no bundled minimatch in ${file}`); + const minimatch = webpackRequire(source)(minimatchId).minimatch as (target: string, pattern: string) => boolean; + + return { + parse: (text, filename) => { + const { globs, alwaysApply, body } = parseMdcRule(text, filename); + return { globs, alwaysApply, body }; + }, + applies: (text, filename, target, cwd) => { + const { globs, alwaysApply } = parseMdcRule(text, filename); + if (alwaysApply) return true; + if (!globs) return false; + const targets = [path.basename(target), target, path.relative(cwd, target)]; + return globs.split(',').map((glob) => glob.trim()) + .some((glob) => targets.some((candidate) => minimatch(candidate, glob))); + }, + }; +} diff --git a/src/__tests__/joycode.test.ts b/src/__tests__/joycode.test.ts index 472c3b6cc..8ba39562a 100644 --- a/src/__tests__/joycode.test.ts +++ b/src/__tests__/joycode.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest'; import { KNOWN_AGENTS } from '../known-agents.js'; import { ALL_SUPPORTED_TOOLS } from '../resources/agent-format.js'; -import { ruleFileExtensionForTool, usesCursorMdcRules } from '../resources/rule-format.js'; +import { ruleFileExtensionForTool, usesMdcRules } from '../resources/rule-format.js'; import { TeamaiConfigSchema } from '../types.js'; describe('JoyCode support', () => { @@ -23,8 +23,8 @@ describe('JoyCode support', () => { expect(ALL_SUPPORTED_TOOLS).toContain('joycode'); }); - it('uses Cursor-compatible .mdc rule files', () => { + it('uses .mdc rule files', () => { expect(ruleFileExtensionForTool('joycode')).toBe('.mdc'); - expect(usesCursorMdcRules('joycode')).toBe(true); + expect(usesMdcRules('joycode')).toBe(true); }); }); diff --git a/src/__tests__/kiro.test.ts b/src/__tests__/kiro.test.ts index 9fd4f2dae..976082c68 100644 --- a/src/__tests__/kiro.test.ts +++ b/src/__tests__/kiro.test.ts @@ -13,7 +13,7 @@ import { } from '../resources/agent-format.js'; import { AgentsHandler } from '../resources/agents.js'; import { detectMcpFormat } from '../resources/mcp-format.js'; -import { ruleFileExtensionForTool, usesCursorMdcRules } from '../resources/rule-format.js'; +import { ruleFileExtensionForTool, usesMdcRules } from '../resources/rule-format.js'; import { TeamaiConfigSchema } from '../types.js'; import type { LocalConfig } from '../types.js'; import { resolveHookCwd } from '../utils/hook-cwd.js'; @@ -48,7 +48,7 @@ describe('Kiro support', () => { expect(ALL_SUPPORTED_TOOLS).toContain('kiro'); expect(agentFileExtensionForTool('kiro')).toBe('.json'); expect(ruleFileExtensionForTool('kiro')).toBe('.md'); - expect(usesCursorMdcRules('kiro')).toBe(false); + expect(usesMdcRules('kiro')).toBe(false); }); it('renders agentSpawn session-start into Kiro JSON while preserving custom hooks', () => { diff --git a/src/__tests__/omp.test.ts b/src/__tests__/omp.test.ts index 7deae1a8a..650c8dfa7 100644 --- a/src/__tests__/omp.test.ts +++ b/src/__tests__/omp.test.ts @@ -10,7 +10,7 @@ import { } from '../resources/agent-format.js'; import { AgentsHandler } from '../resources/agents.js'; import { detectMcpFormat } from '../resources/mcp-format.js'; -import { ruleFileExtensionForTool, usesCursorMdcRules } from '../resources/rule-format.js'; +import { ruleFileExtensionForTool, usesMdcRules } from '../resources/rule-format.js'; import { RulesHandler } from '../resources/rules.js'; import { openLedger, recordDelivered, type DeliveredHashes } from '../resources/delivered-copies.js'; import { log } from '../utils/logger.js'; @@ -59,7 +59,7 @@ describe('OMP (Oh My Pi) support', () => { expect(ALL_SUPPORTED_TOOLS).toContain('omp'); expect(agentFileExtensionForTool('omp')).toBe('.md'); expect(ruleFileExtensionForTool('omp')).toBe('.md'); - expect(usesCursorMdcRules('omp')).toBe(false); + expect(usesMdcRules('omp')).toBe(false); }); it('uses the mcpServers JSON format in the OMP agent dir', () => { diff --git a/src/__tests__/pull-namespace-override.test.ts b/src/__tests__/pull-namespace-override.test.ts index 6540af054..3de53dee3 100644 --- a/src/__tests__/pull-namespace-override.test.ts +++ b/src/__tests__/pull-namespace-override.test.ts @@ -269,6 +269,31 @@ describe('pull: an active namespace item replaces the root item of the same name expect(await read('.joycode/rules/mine.mdc')).toBe('my own rule\n'); }); + // Before #946 JoyCode got Cursor's render: that copy is what teamai + // delivered, not a member edit, so it goes without a warning. + it('withdraws a replaced root rule\'s copy still in the render an older teamai wrote for the tool (#946)', async () => { + const base = await loadTeamConfig(repoPath); + if (!base) throw new Error('no team config'); + vi.mocked(loadTeamConfig).mockResolvedValue({ + ...base, + toolPaths: { ...base.toolPaths, joycode: { skills: '.joycode/skills', rules: '.joycode/rules', agents: '.joycode/agents' } }, + }); + await fse.ensureDir(path.join(homeDir, '.joycode', 'rules')); + await team('rules/scoped.md', '---\npaths: ["src/**", "test/**"]\n---\n\n# Shared scoped\n'); + await team('rules/frontend/scoped.md', '# Front scoped\n'); + await fse.outputFile( + path.join(homeDir, '.joycode/rules/scoped.mdc'), + '---\nglobs: "src/**, test/**"\nalwaysApply: false\n---\n\n# Shared scoped\n', + ); + + as(['frontend']); + await pull({}); + + expect(await exists('.joycode/rules/scoped.mdc')).toBe(false); + expect(await exists('.joycode/rules/frontend/scoped.mdc')).toBe(true); + expect(logged('warn', /scoped\.mdc/)).toBe(false); + }); + // The admin edits the root rule and adds its namespace override in one push: // the member's copy is the version of the last pull, not a member edit. // HOME's copy may come from a project pull that inherits the user scope, diff --git a/src/__tests__/pull-rule-format-upgrade.test.ts b/src/__tests__/pull-rule-format-upgrade.test.ts index 29bb9667e..c5ecaa547 100644 --- a/src/__tests__/pull-rule-format-upgrade.test.ts +++ b/src/__tests__/pull-rule-format-upgrade.test.ts @@ -227,6 +227,88 @@ describe('a pull at an unchanged team revision after OMP rules go flat (#946)', }); }); +/** + * JoyCode got Cursor's `.mdc`, whose quoted globs it never matches; a project + * whose team revision has not moved must still get JoyCode's own render (#946). + */ +describe('a pull at an unchanged team revision after JoyCode gets its own render (#946)', () => { + let tmpDir: string; + let projectRoot: string; + let saved: State; + + const SCOPED = '---\npaths:\n - "src/**"\n - "test/**"\n---\n\nUse named exports.\n'; + const CURSOR = '---\nglobs: "src/**, test/**"\nalwaysApply: false\n---\n\nUse named exports.\n'; + const JOYCODE = '---\nglobs: src/**, test/**\nalwaysApply: false\n---\n\nUse named exports.\n'; + const copy = (name: string) => path.join(projectRoot, '.joycode', 'rules', `${name}.mdc`); + const delivered = () => Object.values(saved.lastPullByWorkspace ?? {})[0]?.delivered ?? {}; + + beforeEach(async () => { + tmpDir = await fse.mkdtemp(path.join(os.tmpdir(), 'teamai-joycode-upgrade-')); + projectRoot = path.join(tmpDir, 'project'); + const repoPath = path.join(tmpDir, 'team-repo'); + await fse.ensureDir(path.join(projectRoot, '.joycode')); + for (const name of ['scoped', 'edited']) await fse.outputFile(path.join(repoPath, 'rules', `${name}.md`), SCOPED); + vi.stubEnv('HOME', path.join(tmpDir, 'home')); + saved = {} as State; + vi.mocked(saveStateForScope).mockImplementation(async (state) => { + saved = structuredClone(state); + }); + vi.mocked(loadStateForScope).mockImplementation(async () => structuredClone(saved) as never); + vi.mocked(loadTeamConfig).mockResolvedValue( + TeamaiConfigSchema.parse({ team: 'test', repo: 'https://example.invalid/x/team.git' }), + ); + vi.mocked(loadLocalConfigForScope).mockResolvedValue({ + repo: { localPath: repoPath, remote: 'https://example.invalid/x/team.git' }, + username: 'u', + updatePolicy: 'auto', + additionalRoles: [], + scope: 'project', + projectRoot, + enabledAgents: ['joycode'], + } as LocalConfig); + await pull({}); + // What an older CLI left at this revision: Cursor's render, on record as + // delivered; the member then edited one copy. + const record = Object.values(saved.lastPullByWorkspace ?? {})[0]; + for (const name of ['scoped', 'edited']) { + await fse.writeFile(copy(name), CURSOR); + record.delivered = { ...record.delivered, [copy(name)]: sha256(CURSOR) }; + } + await fse.writeFile(copy('edited'), CURSOR.replace('Use named exports.', 'My own wording.')); + vi.mocked(log.success).mockClear(); + vi.mocked(log.warn).mockClear(); + }); + + afterEach(async () => { + vi.mocked(saveStateForScope).mockReset(); + vi.mocked(loadStateForScope).mockImplementation(async () => ({}) as never); + vi.unstubAllEnvs(); + await fse.remove(tmpDir); + }); + + it('re-renders the unedited copy for JoyCode, and keeps and names the edited one', async () => { + await pull({}); + + const successes = vi.mocked(log.success).mock.calls.map(([message]) => String(message)); + expect(successes.some((message) => message.includes('Already synced at abc1234'))).toBe(true); + expect(await fse.readFile(copy('scoped'), 'utf8')).toBe(JOYCODE); + expect(delivered()[copy('scoped')]).toBe(sha256(JOYCODE)); + expect(await fse.readFile(copy('edited'), 'utf8')).toContain('My own wording.'); + const warnings = vi.mocked(log.warn).mock.calls.map(([message]) => String(message)); + expect(warnings.filter((message) => message.includes(`Kept ${copy('edited')}`))).toHaveLength(1); + expect(warnings.filter((message) => message.includes(copy('edited')) && message.includes('JoyCode never matches'))).toHaveLength(1); + expect(warnings.some((message) => message.includes(copy('scoped')))).toBe(false); + }); + + it('says why the kept copy applies to no file on a full sync too', async () => { + await pull({ force: true }); + + const warnings = vi.mocked(log.warn).mock.calls.map(([message]) => String(message)); + expect(await fse.readFile(copy('edited'), 'utf8')).toContain('My own wording.'); + expect(warnings.filter((message) => message.includes(copy('edited')) && message.includes('JoyCode never matches'))).toHaveLength(1); + }); +}); + /** * A CLI upgrade that moves OpenCode's rules globs must reach a machine whose * team revision has not moved, or OpenCode keeps loading through the old diff --git a/src/__tests__/qoder-cn.test.ts b/src/__tests__/qoder-cn.test.ts index 8c6dd5de9..6312daec7 100644 --- a/src/__tests__/qoder-cn.test.ts +++ b/src/__tests__/qoder-cn.test.ts @@ -10,7 +10,7 @@ import { renderForTool, } from '../resources/agent-format.js'; import { detectMcpFormat } from '../resources/mcp-format.js'; -import { ruleFileExtensionForTool, usesCursorMdcRules } from '../resources/rule-format.js'; +import { ruleFileExtensionForTool, usesMdcRules } from '../resources/rule-format.js'; import { TeamaiConfigSchema, scopedToolPaths } from '../types.js'; import type { LocalConfig } from '../types.js'; @@ -76,7 +76,7 @@ describe('Qoder CN support', () => { expect(ALL_SUPPORTED_TOOLS).toContain('qoder-cn'); expect(agentFileExtensionForTool('qoder-cn')).toBe('.md'); expect(ruleFileExtensionForTool('qoder-cn')).toBe('.md'); - expect(usesCursorMdcRules('qoder-cn')).toBe(false); + expect(usesMdcRules('qoder-cn')).toBe(false); }); it('renders Qoder CN subagents exactly like Qoder', () => { diff --git a/src/__tests__/qoder.test.ts b/src/__tests__/qoder.test.ts index 9a1ab7b51..33eed63fc 100644 --- a/src/__tests__/qoder.test.ts +++ b/src/__tests__/qoder.test.ts @@ -9,7 +9,7 @@ import { ALL_SUPPORTED_TOOLS, } from '../resources/agent-format.js'; import { detectMcpFormat } from '../resources/mcp-format.js'; -import { ruleFileExtensionForTool, usesCursorMdcRules } from '../resources/rule-format.js'; +import { ruleFileExtensionForTool, usesMdcRules } from '../resources/rule-format.js'; import { TeamaiConfigSchema } from '../types.js'; import type { LocalConfig } from '../types.js'; @@ -37,7 +37,7 @@ describe('Qoder support', () => { expect(ALL_SUPPORTED_TOOLS).toContain('qoder'); expect(agentFileExtensionForTool('qoder')).toBe('.md'); expect(ruleFileExtensionForTool('qoder')).toBe('.md'); - expect(usesCursorMdcRules('qoder')).toBe(false); + expect(usesMdcRules('qoder')).toBe(false); }); it('uses the mcpServers JSON format in Qoder settings', () => { diff --git a/src/__tests__/rule-parsers.test.ts b/src/__tests__/rule-parsers.test.ts index 063b7bd10..a112e85df 100644 --- a/src/__tests__/rule-parsers.test.ts +++ b/src/__tests__/rule-parsers.test.ts @@ -4,7 +4,10 @@ import path from 'node:path'; import { afterAll, describe, expect, it } from 'vitest'; import { teamRuleToCodebuddyRule } from '../resources/codebuddy-rule.js'; import { teamRuleToCursorMdc } from '../resources/cursor-mdc.js'; -import { loadCodebuddyRuleParser, loadCursorRuleParser, ruleParserBundle } from './helpers/rule-parsers.js'; +import { teamRuleToJoycodeRule } from '../resources/joycode-rule.js'; +import { + loadCodebuddyRuleParser, loadCursorRuleParser, loadJoycodeRuleParser, ruleParserBundle, +} from './helpers/rule-parsers.js'; /** * Each render read back by the tool's own parser, taken from its installed @@ -68,3 +71,37 @@ describe.skipIf(!codebuddyBundle)('CodeBuddy (and WorkBuddy) read the CodeBuddy expect((await parser!.parse(file)).globs).toEqual(['["src/**/*.ts"', '"test/**"]']); }); }); + +const joycodeBundle = ruleParserBundle('joycode'); + +describe.skipIf(!joycodeBundle)('JoyCode reads its .mdc render as intended', () => { + const parser = joycodeBundle ? loadJoycodeRuleParser(joycodeBundle) : undefined; + const cwd = '/repo'; + const applied = (mdc: string, files: string[]): string[] => + files.filter((file) => parser!.applies(mdc, 'rule.mdc', path.join(cwd, file), cwd)); + const FILES = ['src/a/x.ts', 'src/b/y.tsx', 'src/c/z.ts', 'test/t.js', 'docs/d.md']; + + it.each([ + ['an unscoped rule', BODY, true, FILES], + ['an inline list', `---\npaths: ["src/**/*.ts", "test/**"]\n---\n\n${BODY}`, false, ['src/a/x.ts', 'src/c/z.ts', 'test/t.js']], + ['a block list', `---\npaths:\n - "src/**/*.ts"\n - test/**\n---\n\n${BODY}`, false, ['src/a/x.ts', 'src/c/z.ts', 'test/t.js']], + ['a brace glob', `---\npaths:\n - "src/{a,b}/**"\n - "**/*.{md,js}"\n---\n\n${BODY}`, false, ['src/a/x.ts', 'src/b/y.tsx', 'test/t.js', 'docs/d.md']], + ['an unquoted alias-like glob', `---\npaths: **/*.ts\n---\n\n${BODY}`, false, ['src/a/x.ts', 'src/c/z.ts']], + ])('%s', (_label, source, alwaysApply, files) => { + const mdc = teamRuleToJoycodeRule(source); + const parsed = parser!.parse(mdc, 'rule.mdc'); + + expect(parsed.alwaysApply).toBe(alwaysApply); + expect(parsed.body).toBe(BODY); + expect(applied(mdc, FILES)).toEqual(files); + }); + + // Cursor's render, which teamai wrote before: JoyCode keeps the quotes and + // splits the brace group, so the rule applies to no file. + it.each([ + ['an inline list', `---\npaths: ["src/**/*.ts", "test/**"]\n---\n\n${BODY}`], + ['a brace glob', `---\npaths:\n - "src/{a,b}/**"\n---\n\n${BODY}`], + ])('applies the old Cursor render of %s to no file', (_label, source) => { + expect(applied(teamRuleToCursorMdc(source), FILES)).toEqual([]); + }); +}); diff --git a/src/__tests__/rule-render-contracts.test.ts b/src/__tests__/rule-render-contracts.test.ts index 55c00307e..dde2d6de2 100644 --- a/src/__tests__/rule-render-contracts.test.ts +++ b/src/__tests__/rule-render-contracts.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest'; import { teamRuleToCodebuddyRule } from '../resources/codebuddy-rule.js'; +import { teamRuleToJoycodeRule } from '../resources/joycode-rule.js'; import { teamRuleToKiroSteering } from '../resources/kiro-steering.js'; import { teamRuleToOmpRule } from '../resources/omp-rule.js'; import { teamRuleToQoderRule } from '../resources/qoder-rule.js'; @@ -115,6 +116,30 @@ describe('CodeBuddy rule render (CodeBuddy and WorkBuddy)', () => { }); }); +describe('JoyCode rule render', () => { + it('makes an unscoped rule always applied', () => { + expect(teamRuleToJoycodeRule(UNSCOPED)).toBe('---\nalwaysApply: true\n---\n\nUse named exports.\n'); + }); + + // JoyCode reads the globs line as it stands, quotes included, and splits it + // on every comma: the globs are written unquoted and `{a,b}` is expanded. + it.each([ + ['an inline list', INLINE], + ['a block list', BLOCK], + ])('scopes %s with unquoted, comma-joined globs and alwaysApply false', (_label, source) => { + expect(teamRuleToJoycodeRule(source)).toBe( + '---\nglobs: src/**/*.ts, test/**\nalwaysApply: false\n---\n\nUse named exports.\n', + ); + }); + + it('expands a brace glob, since the globs line is split on every comma', () => { + const source = '---\npaths: ["src/{a,b}/**", "test/**"]\n---\n\nUse named exports.\n'; + expect(teamRuleToJoycodeRule(source)).toBe( + '---\nglobs: src/a/**, src/b/**, test/**\nalwaysApply: false\n---\n\nUse named exports.\n', + ); + }); +}); + describe('team rule paths, shared by every render', () => { // gray-matter caches a parse by content, failures included: the retry that // quotes `**/*.ts` must not lose to a cached failure on the next render. diff --git a/src/__tests__/rules.test.ts b/src/__tests__/rules.test.ts index 765376ff1..eb56c309b 100644 --- a/src/__tests__/rules.test.ts +++ b/src/__tests__/rules.test.ts @@ -1191,7 +1191,7 @@ describe('RulesHandler.pullAllRules — OpenCode instructions activation', () => }); }); -describe('RulesHandler — Cursor-compatible .mdc handling', () => { +describe('RulesHandler — .mdc handling (Cursor, JoyCode)', () => { let tmpDir: string; let homeDir: string; let repoPath: string; @@ -1240,7 +1240,7 @@ describe('RulesHandler — Cursor-compatible .mdc handling', () => { await fse.remove(tmpDir); }); - it('pull writes .mdc (not .md) with derived frontmatter for Cursor and JoyCode', async () => { + it('pull writes .mdc (not .md) with derived frontmatter for Cursor, and .mdc for JoyCode', async () => { await fse.writeFile( path.join(repoPath, 'rules', 'ts-style.md'), '---\npaths:\n - "**/*.ts"\n---\n\nUse named exports.', @@ -1254,14 +1254,30 @@ describe('RulesHandler — Cursor-compatible .mdc handling', () => { const content = await fse.readFile(mdcPath, 'utf-8'); expect(content).toContain('globs: "**/*.ts"'); expect(content).toContain('alwaysApply: false'); - const joycodeMdcPath = path.join(homeDir, '.joycode/rules/ts-style.mdc'); - expect(await fse.pathExists(joycodeMdcPath)).toBe(true); + expect(await fse.pathExists(path.join(homeDir, '.joycode/rules/ts-style.mdc'))).toBe(true); expect(await fse.pathExists(path.join(homeDir, '.joycode/rules/ts-style.md'))).toBe(false); - expect(await fse.readFile(joycodeMdcPath, 'utf-8')).toBe(content); // claude still gets a plain .md copy expect(await fse.pathExists(path.join(homeDir, '.claude/rules/ts-style.md'))).toBe(true); }); + // JoyCode keeps the quotes Cursor's render puts around globs and splits on + // every comma, so it gets its own render (#946). + it('pull writes JoyCode\'s own .mdc render to a project\'s .joycode/rules', async () => { + localConfig.scope = 'project'; + localConfig.projectRoot = homeDir; + await fse.writeFile( + path.join(repoPath, 'rules', 'ts-style.md'), + '---\npaths:\n - "**/*.{ts,tsx}"\n---\n\nUse named exports.', + ); + + await handler.pullAllRules(teamConfig, localConfig); + + const joycodeMdcPath = path.join(homeDir, '.joycode/rules/ts-style.mdc'); + expect(await fse.pathExists(path.join(homeDir, '.joycode/rules/ts-style.md'))).toBe(false); + expect(await fse.readFile(joycodeMdcPath, 'utf-8')) + .toBe('---\nglobs: **/*.ts, **/*.tsx\nalwaysApply: false\n---\n\nUse named exports.\n'); + }); + it('a clean pull does not make cursor rules look modified on push', async () => { await fse.writeFile(path.join(repoPath, 'rules', 'enforced.md'), 'A mandatory rule.'); await handler.pullAllRules(teamConfig, localConfig); diff --git a/src/recall-toggle.ts b/src/recall-toggle.ts index 932efaf31..b943bca88 100644 --- a/src/recall-toggle.ts +++ b/src/recall-toggle.ts @@ -25,7 +25,7 @@ async function removeRecallArtifacts(teamConfig: TeamaiConfig, localConfig: Loca const baseDir = resolveToolBaseDir(tool, localConfig); // Remove recall rule file if (toolPath.rules) { - // Cursor-compatible copies are `.mdc`; older layouts also left `.md` files. + // Cursor and JoyCode copies are `.mdc`; older layouts also left `.md` files. const extensions = new Set([ruleFileExtensionForTool(tool), '.md']); for (const extension of extensions) { const ruleFile = path.join(baseDir, toolPath.rules, `teamai-recall${extension}`); diff --git a/src/resources/cursor-mdc.ts b/src/resources/cursor-mdc.ts index 84f01642b..51bae47b0 100644 --- a/src/resources/cursor-mdc.ts +++ b/src/resources/cursor-mdc.ts @@ -68,7 +68,7 @@ export function teamRuleToCursorMdc(rawTeamRule: string): string { return renderCursorMdc(deriveCursorFrontmatter(teamRuleData(rawTeamRule)), teamRuleBody(rawTeamRule)); } -/** Cursor's rules format, also JoyCode's (`.mdc`). */ +/** Cursor's rules format (`.mdc`). */ export const CURSOR_MDC_FORMAT: RuleFormat = { extension: '.mdc', render: teamRuleToCursorMdc, diff --git a/src/resources/joycode-rule.ts b/src/resources/joycode-rule.ts new file mode 100644 index 000000000..73437d7df --- /dev/null +++ b/src/resources/joycode-rule.ts @@ -0,0 +1,50 @@ +import type { RuleFormat } from './rule-format.js'; +import { + expandBraces, + mergeRuleBodyIntoTeamMd, + ruleBodyEqualsTeamMd, + rulePaths, + teamRuleBody, + teamRuleData, +} from './team-rule.js'; + +/** + * JoyCode project rules (`.joycode/rules/*.mdc`). Its `.mdc` looks like + * Cursor's, but JoyCode reads it by lines, not YAML (`parseMdcRule` in + * JoyCoder.joycoder-fe 3.8.71): the `globs:` value is taken as it stands, + * quotes included, and split on every comma before matching. + * + * - team `paths: [glob, ...]` → `globs: a, b` unquoted + `alwaysApply: false` + * - no `paths` → `alwaysApply: true` + * + * So the globs are written unquoted, and a `{a,b}` alternation is expanded + * into separate globs. + */ +export function teamRuleToJoycodeRule(rawTeamRule: string): string { + const globs = [...new Set(rulePaths(teamRuleData(rawTeamRule)).flatMap(expandBraces))]; + const frontmatter = globs.length > 0 + ? [`globs: ${globs.join(', ')}`, 'alwaysApply: false'] + : ['alwaysApply: true']; + return `---\n${frontmatter.join('\n')}\n---\n\n${teamRuleBody(rawTeamRule)}\n`; +} + +/** + * The warning for a copy pull kept because the member edited it, while its + * `globs` are still quoted as Cursor's render wrote them: JoyCode matches the + * quotes too, so the rule applies to no file. Null for any other copy. + */ +export function joycodeQuotedGlobsWarning(file: string, rawCopy: string): string | null { + const frontmatter = /^---\s*[\r\n]+([\s\S]*?)\r?\n---/.exec(rawCopy)?.[1] ?? ''; + if (!/^\s*globs:\s*["']/m.test(frontmatter)) return null; + return `${file} keeps the quoted \`globs\` an older teamai wrote, which JoyCode never matches, so the rule applies to no file. ` + + 'Remove the quotes and write each `{a,b}` alternative as a glob of its own, or delete the file and run `teamai pull --force` to take the team version.'; +} + +/** JoyCode's rules format. */ +export const JOYCODE_RULE_FORMAT: RuleFormat = { + extension: '.mdc', + render: teamRuleToJoycodeRule, + bodyEquals: ruleBodyEqualsTeamMd, + mergeBodyIntoTeam: mergeRuleBodyIntoTeamMd, + scopeFields: ['globs', 'alwaysApply'], +}; diff --git a/src/resources/rule-format.ts b/src/resources/rule-format.ts index 1fcabe1f0..d22aad08f 100644 --- a/src/resources/rule-format.ts +++ b/src/resources/rule-format.ts @@ -19,6 +19,7 @@ import type { Scope, TeamaiConfig } from '../types.js'; import { CODEBUDDY_RULE_FORMAT } from './codebuddy-rule.js'; import { COPILOT_INSTRUCTIONS_FORMAT } from './copilot-instructions.js'; import { CURSOR_MDC_FORMAT } from './cursor-mdc.js'; +import { JOYCODE_RULE_FORMAT } from './joycode-rule.js'; import { KIRO_STEERING_FORMAT } from './kiro-steering.js'; import { OMP_RULE_FORMAT } from './omp-rule.js'; import { QODER_RULE_FORMAT } from './qoder-rule.js'; @@ -49,7 +50,7 @@ export interface RuleFormat { */ const RULE_FORMATS: Readonly> = { cursor: CURSOR_MDC_FORMAT, - joycode: CURSOR_MDC_FORMAT, + joycode: JOYCODE_RULE_FORMAT, copilot: COPILOT_INSTRUCTIONS_FORMAT, kiro: KIRO_STEERING_FORMAT, qoder: QODER_RULE_FORMAT, @@ -151,8 +152,8 @@ export function ruleFileExtensionForTool(tool: string): RuleFormat['extension'] return ruleFormatForTool(tool)?.extension ?? '.md'; } -/** True when the tool stores rules in Cursor-compatible `.mdc` format. */ -export function usesCursorMdcRules(tool: string): boolean { +/** True when the tool stores rules as `.mdc` files (Cursor, JoyCode), so it reads no `.md` there. */ +export function usesMdcRules(tool: string): boolean { return ruleFileExtensionForTool(tool) === '.mdc'; } @@ -317,5 +318,5 @@ export function ruleStemFromFilename(filename: string): string | null { * leftover rather than an active rule. */ export function isLegacyCursorRuleFile(tool: string, filename: string): boolean { - return usesCursorMdcRules(tool) && filename.endsWith('.md'); + return usesMdcRules(tool) && filename.endsWith('.md'); } diff --git a/src/resources/rules.ts b/src/resources/rules.ts index 311acc8bf..e537c8226 100644 --- a/src/resources/rules.ts +++ b/src/resources/rules.ts @@ -9,6 +9,8 @@ import { TEAMAI_RULES_START, TEAMAI_RULES_END, TEAMAI_TEAM_RULES_START, TEAMAI_T import { EXCLUDED_RULE_NAMES, isDeployedRecallRule, TEAMAI_CONTEXT_RULE_NAME } from '../builtin-rules.js'; import { splitFrontmatter } from '../utils/frontmatter.js'; import { rulePaths } from './team-rule.js'; +import { teamRuleToCursorMdc } from './cursor-mdc.js'; +import { joycodeQuotedGlobsWarning } from './joycode-rule.js'; import type { OpencodeRulesTarget } from './opencode-config.js'; import { assertWithinRoot } from '../utils/path-safety.js'; import { loadStateForScope } from '../config.js'; @@ -273,8 +275,8 @@ export class RulesHandler extends ResourceHandler { /** * Where `item` lands for each tool that receives rules. The filename and - * bytes are tool-dependent (`RULE_FORMATS`) — `.md` verbatim, `.mdc` for - * Cursor-compatible tools, `.instructions.md` for Copilot, `.md` with its + * bytes are tool-dependent (`RULE_FORMATS`) — `.md` verbatim, Cursor's and + * JoyCode's own `.mdc`, `.instructions.md` for Copilot, `.md` with its * own frontmatter for Kiro, Qoder and CodeBuddy — so a reader cannot derive * them from the rule's name alone. Tools that read the same file in the same * render get one target, naming the others in `sharedWith`. @@ -411,6 +413,8 @@ export class RulesHandler extends ResourceHandler { if (!ledger || !await keepsEditedCopy(ledger, item, target)) { await writeFile(dest, content); if (ledger) await recordDelivered(ledger.hashes, dest); + } else { + await warnIfKeptCopyIsInert(target); } // Drop the `.md` copy left by an older layout; a tool that reads a // derived extension does not read it, and it would outlive the rule. @@ -470,7 +474,9 @@ export class RulesHandler extends ResourceHandler { } else if (disk !== recorded) { // Named by the caller's reportKept; a render unchanged since // delivery is the member's plain edit, which needs no word. - if (contentHash(content) !== recorded) await keepsEditedCopy(ledger, item, target); + if (contentHash(content) !== recorded && await keepsEditedCopy(ledger, item, target)) { + await warnIfKeptCopyIsInert(target); + } continue; } await writeFile(dest, content); @@ -785,7 +791,7 @@ export class RulesHandler extends ResourceHandler { continue; } const deployed = path.join(destDir, localFile); - if (await isDeliveredRender([toolRender(tool)], deployed, replaced, localConfig.repo.localPath, await deliveredRevs())) { + if (await isDeliveredRender(deliveredRenders(tool), deployed, replaced, localConfig.repo.localPath, await deliveredRevs())) { await remove(deployed); log.debug(`Removed ${localFile} from ${tool}: a namespace rule replaces it`); } else { @@ -1272,6 +1278,35 @@ function toolRender(tool: string): (rawTeamRule: string) => string { return (raw) => renderRuleForTool(tool, raw); } +const verbatim = (rawTeamRule: string): string => rawTeamRule; + +/** + * What an older teamai wrote for a tool before it got its own render (#946): + * the team `.md` verbatim, or Cursor's `.mdc` for JoyCode. + */ +const PREVIOUS_RULE_RENDERS: Readonly string>>> = { + kiro: [verbatim], + qoder: [verbatim], + 'qoder-cn': [verbatim], + codebuddy: [verbatim], + workbuddy: [verbatim], + omp: [verbatim], + joycode: [teamRuleToCursorMdc], +}; + +/** Every render a copy teamai delivered for `tool` may hold: the current one, then older ones. */ +function deliveredRenders(tool: string): Array<(rawTeamRule: string) => string> { + return [toolRender(tool), ...(Object.hasOwn(PREVIOUS_RULE_RENDERS, tool) ? PREVIOUS_RULE_RENDERS[tool] : [])]; +} + +/** Say so when a copy pull kept as the member edited it is one the tool cannot apply (JoyCode's quoted globs). */ +async function warnIfKeptCopyIsInert(target: DeliveryTarget): Promise { + if (target.tool !== 'joycode') return; + const copy = await readFileSafe(target.dest); + const warning = copy === null ? null : joycodeQuotedGlobsWarning(target.dest, copy); + if (warning) log.warn(warning); +} + /** sha256 of `content`, as `fileHash` and the delivery ledger spell it. */ function contentHash(content: string): string { return crypto.createHash('sha256').update(content).digest('hex'); From 1f2ac43fd836d3311a5b7bcce517a6ce05a28362 Mon Sep 17 00:00:00 2001 From: Saul Moro Date: Fri, 2 Oct 2026 01:34:48 +0200 Subject: [PATCH 08/20] fix(rules): deliver user rules through each tool's own file (#946) --- CHANGELOG.md | 1 + README.ja.md | 8 +- README.ko.md | 8 +- README.md | 8 +- README.th.md | 8 +- README.zh-CN.md | 8 +- docs/usage-guide.md | 14 +- docs/usage-guide.zh-CN.md | 14 +- skill-data/setup/references/uninstall.md | 9 +- src/__tests__/instruction-targets.test.ts | 5 +- src/__tests__/joycode.test.ts | 5 +- src/__tests__/pi-adapter.test.ts | 5 +- src/__tests__/pull-namespace-override.test.ts | 6 + src/__tests__/rules.test.ts | 37 +- src/__tests__/types.test.ts | 1 - src/__tests__/user-rules-files.test.ts | 531 ++++++++++++++++++ src/doctor-delivery.ts | 64 ++- src/dsh-hooks.ts | 11 +- src/instruction-targets.ts | 125 ++++- src/local-agent.ts | 6 +- src/pull.ts | 11 +- src/resources/rule-format.ts | 72 ++- src/resources/rules.ts | 68 ++- src/types.ts | 30 +- src/uninstall.ts | 31 +- 25 files changed, 897 insertions(+), 189 deletions(-) create mode 100644 src/__tests__/user-rules-files.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 131f46c34..e4134ef77 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -55,6 +55,7 @@ All notable changes to this project will be documented in this file. See [standa - JoyCode now gets each project rule in its own `.mdc` render instead of Cursor's. JoyCode reads the frontmatter by lines, keeps the quotes Cursor's render puts around `globs` and splits the value on every comma, so a scoped rule in `.joycode/rules` matched no file. Scoped rules now get `globs:` unquoted with `{a,b}` expanded, and `alwaysApply: false`; unscoped rules keep `alwaysApply: true`. The first pull after upgrading rewrites a copy still holding what teamai delivered, even when the team repo has not moved; a copy you edited is kept and named, and while its `globs` are still quoted pull says that JoyCode applies it to no file and how to fix it. A root rule's copy that a namespace rule replaces is now removed when it still holds what an older teamai wrote there (the team `.md` for Kiro, Qoder, CodeBuddy and Oh My Pi, Cursor's render for JoyCode), instead of being kept with a warning. `teamai doctor` compares each copy with the new render (for [#946](https://github.com/Tencent/teamai-cli/issues/946)). - `teamai pull` reclaims the rule copies earlier releases left in a project's `.workbuddy/rules`, which WorkBuddy never read, and writes them to `.codebuddy/rules`, also when the team repo has not moved. It also reclaims the team rules WorkBuddy's one-time migration copied from `~/.codebuddy/rules` into `~/.workbuddy/rules`: an unedited one is re-rendered while still delivered and removed otherwise, and an edited one is kept and named. The reclaim of old rule copies now runs on every rules sync, and its warnings say why each tool does not read the copy (for [#946](https://github.com/Tencent/teamai-cli/issues/946)). - OpenCode loads namespaced project rules. The root `opencode.json` listed `.opencode/rules/*.md`, which matched no rule in a namespace directory. Pull now lists `.opencode/rules/**/*.md` in `.opencode/opencode.json`, beside the team instructions entry, and removes the old glob from the root `opencode.json`, leaving its other keys. The first pull after upgrading moves the globs in both scopes even when the team repo has not moved; `doctor` checks `.opencode/opencode.json`, and `uninstall` removes the glob from both files, deleting a `.opencode/opencode.json` left empty (for [#946](https://github.com/Tencent/teamai-cli/issues/946)). +- ZCode, DeepSeek Harness, OpenClaw, Pi and JoyCode get the team rules in user scope, in a file only that tool reads: a team-rules block in `~/.zcode/AGENTS.md`, `$DSH_HOME/AGENTS.md` (`~/.dsh` by default), the OpenClaw workspace `AGENTS.md` (resolved as OpenClaw's hook resolves it), `~/.pi/agent/AGENTS.md` and `~/.joycode/rules.txt`, the always-on text Codex already gets in `~/.codex/AGENTS.md`. ZCode and DeepSeek Harness got no rules before, and OpenClaw, Pi and JoyCode got copies in `.openclaw/rules`, `~/.pi/agent/rules` and `~/.joycode/rules`, which they never read. A pull, also at an unchanged team revision, removes those copies when unedited and names the edited ones, and `uninstall` removes the block and the copies. A project pull writes none of these files; OpenClaw gets no project rules, and its culture and instruction blocks now follow the resolved workspace too, so a project's `.openclaw/workspace/AGENTS.md` is no longer written. `doctor` checks the block in each tool's file, and init and doctor say why OpenClaw gets no project rules (for #946). - Team rules reach Codex, `codex-internal` and `tcodex`. Pull used to copy them to `.codex/rules/.md`, which Codex does not read, and `teamai doctor` reported them delivered. In user scope they now go into a team-rules block of the tool's own `AGENTS.md` (`~/.codex/AGENTS.md`), beside the culture, shared-instructions and recall blocks. In a project the session-start hook adds the project's rules and those blocks to each session, and to a spawned subagent through a new `SubagentStart` entry, with `additionalContextLimit: 0` so Codex keeps them whole; pull leaves the project `AGENTS.md`, which other tools read too, unchanged. Pull removes the `.md` copies earlier pulls left in the Codex rules directory at its recorded `toolRoots` location, including a publisher's bare local copy, keeping an edited one with a warning. Pull and uninstall keep a removed rule's legacy copy unless it matches its recorded delivery hash; a missing record proves nothing about local edits. A path-scoped rule is rendered without frontmatter after an `Applies to files matching: ` line, in Hermes' `SOUL.md` too. `teamai doctor` checks the user-scope block and, in a project, both hook entries' limit. The public Codex asks once to approve the changed teamai hooks (for [#938](https://github.com/Tencent/teamai-cli/issues/938)). - `teamai pull` names the skills it removes because they are no longer delivered here, in one line, instead of a `debug` line calling them excluded and a summary that says `No resources to sync`. Picking a role or project, or an admin adding `manifest/projects.yaml`, takes root skills away, since the root `skills/` is then the tag catalog; the line then says that `teamai tags subscribe ` brings one back. A namespace skill removed because its namespace is no longer active is named too. The usage guide and the multi-project design no longer say a member with no project still gets `common` or every root item (for [#911](https://github.com/Tencent/teamai-cli/issues/911)). - `teamai pull` keeps the `teamai tags subscribe ` recovery line when the skill directory it removes is byte-identical to an inactive namespace copy: that copy made the namespace cleanup phase remove the directory first, and the hint was lost, because pull inferred whether a root skill had left from which phase did the removing. The hint now follows the repo — it appears exactly when the team repo holds the removed skill at the root, the copy a tag delivers — so a namespace-only skill removed on deactivation is still named without the hint. The usage guide no longer says a member with no role gets no skills at all, in English or Chinese: root skills still arrive through a tag (review of [#917](https://github.com/Tencent/teamai-cli/pull/917)). diff --git a/README.ja.md b/README.ja.md index eb5ac769a..8db94b067 100644 --- a/README.ja.md +++ b/README.ja.md @@ -130,19 +130,19 @@ Git を基盤に、3 層の能力を構築します: WorkBuddy✓✓✓✓—✓✓✓✓✓✓✓✓✓ OpenCode✓✓*✓✓✓✓✓✓✓✓✓——— Pi Coding Agent✓✓✓——✓✓✓✓✓✓——— - OpenClaw✓✓✓✓————✓✓✓——— + OpenClaw✓✓*✓✓————✓✓✓——— Hermes✓✓*✓✓————✓✓✓——— - DeepSeek Harness✓—✓—————✓✓✓——— + DeepSeek Harness✓✓*✓—————✓✓✓——— Qoder✓✓✓✓✓✓✓—✓✓✓✓✓✓ Qoder CN✓✓✓✓✓✓✓—✓✓✓✓✓✓ Kiro✓✓✓✓✓✓✓—✓✓✓✓✓✓ - ZCode✓—✓—✓✓✓—✓✓✓✓✓✓ + ZCode✓✓*✓—✓✓✓—✓✓✓✓✓✓ Oh My Pi✓✓✓✓✓✓✓—✓✓✓——— JoyCode✓✓✓✓✓———✓✓✓——— -✓* rules はツールに届きますが常に有効です。パスによるスコープは適用されません。Hermes はユーザースコープでのみ受け取ります。 +✓* rules はツールに届きますが常に有効です。パスによるスコープは適用されません。Hermes、OpenClaw、ZCode、DeepSeek Harness はユーザースコープでのみ受け取ります。 ## 詳細情報 diff --git a/README.ko.md b/README.ko.md index 245a78a85..7708ba383 100644 --- a/README.ko.md +++ b/README.ko.md @@ -130,19 +130,19 @@ Git을 기반으로 세 층의 역량을 구축합니다: WorkBuddy✓✓✓✓—✓✓✓✓✓✓✓✓✓ OpenCode✓✓*✓✓✓✓✓✓✓✓✓——— Pi Coding Agent✓✓✓——✓✓✓✓✓✓——— - OpenClaw✓✓✓✓————✓✓✓——— + OpenClaw✓✓*✓✓————✓✓✓——— Hermes✓✓*✓✓————✓✓✓——— - DeepSeek Harness✓—✓—————✓✓✓——— + DeepSeek Harness✓✓*✓—————✓✓✓——— Qoder✓✓✓✓✓✓✓—✓✓✓✓✓✓ Qoder CN✓✓✓✓✓✓✓—✓✓✓✓✓✓ Kiro✓✓✓✓✓✓✓—✓✓✓✓✓✓ - ZCode✓—✓—✓✓✓—✓✓✓✓✓✓ + ZCode✓✓*✓—✓✓✓—✓✓✓✓✓✓ Oh My Pi✓✓✓✓✓✓✓—✓✓✓——— JoyCode✓✓✓✓✓———✓✓✓——— -✓* rules가 도구에 전달되지만 항상 적용됩니다. 경로별로 범위를 한정하지 않습니다. Hermes는 사용자 스코프에서만 받습니다. +✓* rules가 도구에 전달되지만 항상 적용됩니다. 경로별로 범위를 한정하지 않습니다. Hermes, OpenClaw, ZCode, DeepSeek Harness는 사용자 스코프에서만 받습니다. ## 자세히 알아보기 diff --git a/README.md b/README.md index 2727845ad..a5e46383b 100644 --- a/README.md +++ b/README.md @@ -130,19 +130,19 @@ Three layers of capability, built on Git: WorkBuddy✓✓✓✓—✓✓✓✓✓✓✓✓✓ OpenCode✓✓*✓✓✓✓✓✓✓✓✓——— Pi Coding Agent✓✓✓——✓✓✓✓✓✓——— - OpenClaw✓✓✓✓————✓✓✓——— + OpenClaw✓✓*✓✓————✓✓✓——— Hermes✓✓*✓✓————✓✓✓——— - DeepSeek Harness✓—✓—————✓✓✓——— + DeepSeek Harness✓✓*✓—————✓✓✓——— Qoder✓✓✓✓✓✓✓—✓✓✓✓✓✓ Qoder CN✓✓✓✓✓✓✓—✓✓✓✓✓✓ Kiro✓✓✓✓✓✓✓—✓✓✓✓✓✓ - ZCode✓—✓—✓✓✓—✓✓✓✓✓✓ + ZCode✓✓*✓—✓✓✓—✓✓✓✓✓✓ Oh My Pi✓✓✓✓✓✓✓—✓✓✓——— JoyCode✓✓✓✓✓———✓✓✓——— -✓* The rules reach the tool, but always on: it does not scope them by path. Hermes gets them in user scope only. +✓* The rules reach the tool, but always on: it does not scope them by path. Hermes, OpenClaw, ZCode and DeepSeek Harness get them in user scope only. ## Learn More diff --git a/README.th.md b/README.th.md index 52afd7d60..755e01895 100644 --- a/README.th.md +++ b/README.th.md @@ -130,19 +130,19 @@ teamai init https://github.com/your-org/your-repo --scope user WorkBuddy✓✓✓✓—✓✓✓✓✓✓✓✓✓ OpenCode✓✓*✓✓✓✓✓✓✓✓✓——— Pi Coding Agent✓✓✓——✓✓✓✓✓✓——— - OpenClaw✓✓✓✓————✓✓✓——— + OpenClaw✓✓*✓✓————✓✓✓——— Hermes✓✓*✓✓————✓✓✓——— - DeepSeek Harness✓—✓—————✓✓✓——— + DeepSeek Harness✓✓*✓—————✓✓✓——— Qoder✓✓✓✓✓✓✓—✓✓✓✓✓✓ Qoder CN✓✓✓✓✓✓✓—✓✓✓✓✓✓ Kiro✓✓✓✓✓✓✓—✓✓✓✓✓✓ - ZCode✓—✓—✓✓✓—✓✓✓✓✓✓ + ZCode✓✓*✓—✓✓✓—✓✓✓✓✓✓ Oh My Pi✓✓✓✓✓✓✓—✓✓✓——— JoyCode✓✓✓✓✓———✓✓✓——— -✓* rules ส่งถึงเครื่องมือ แต่มีผลเสมอ: เครื่องมือไม่จำกัดขอบเขตตาม path Hermes ได้รับเฉพาะใน user scope +✓* rules ส่งถึงเครื่องมือ แต่มีผลเสมอ: เครื่องมือไม่จำกัดขอบเขตตาม path Hermes, OpenClaw, ZCode และ DeepSeek Harness ได้รับเฉพาะใน user scope ## เรียนรู้เพิ่มเติม diff --git a/README.zh-CN.md b/README.zh-CN.md index 9c4dfe0ce..d9c12a059 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -136,19 +136,19 @@ teamai init https://github.com/your-org/your-repo --scope user WorkBuddy✓✓✓✓—✓✓✓✓✓✓✓✓✓ OpenCode✓✓*✓✓✓✓✓✓✓✓✓——— Pi Coding Agent✓✓✓——✓✓✓✓✓✓——— - OpenClaw✓✓✓✓————✓✓✓——— + OpenClaw✓✓*✓✓————✓✓✓——— Hermes✓✓*✓✓————✓✓✓——— - DeepSeek Harness✓—✓—————✓✓✓——— + DeepSeek Harness✓✓*✓—————✓✓✓——— Qoder✓✓✓✓✓✓✓—✓✓✓✓✓✓ Qoder CN✓✓✓✓✓✓✓—✓✓✓✓✓✓ Kiro✓✓✓✓✓✓✓—✓✓✓✓✓✓ - ZCode✓—✓—✓✓✓—✓✓✓✓✓✓ + ZCode✓✓*✓—✓✓✓—✓✓✓✓✓✓ Oh My Pi✓✓✓✓✓✓✓—✓✓✓——— JoyCode✓✓✓✓✓———✓✓✓——— -✓* rules 能送达该工具,但始终生效:它不按路径限定作用范围。Hermes 只在 user scope 下得到它们。 +✓* rules 能送达该工具,但始终生效:它不按路径限定作用范围。Hermes、OpenClaw、ZCode 和 DeepSeek Harness 只在 user scope 下得到它们。 ## 了解更多 diff --git a/docs/usage-guide.md b/docs/usage-guide.md index a795f3131..14455fc1b 100644 --- a/docs/usage-guide.md +++ b/docs/usage-guide.md @@ -1022,7 +1022,7 @@ teamai push > Admins can set enforced rules in `teamai.yaml` (`sharing.rules.enforced`), which members cannot delete. -Most tools get one file per rule in their rules directory. Codex, `codex-internal` and `tcodex` read no rules directory (`.codex/rules/` holds Codex's own `*.rules` command policies), so `pull` writes no rule file for them. In user scope the team rules go into a `` block of the tool's own `AGENTS.md` (`~/.codex/AGENTS.md`, `~/.codex-internal/AGENTS.md`, `~/.tcodex/AGENTS.md`; a `toolRoots` entry moves it), which only that tool reads. In a project their session-start hook adds the project's team rules to each session instead: the project `AGENTS.md` is the owners' file, and other tools with a rules format of their own read it too. Hermes gets the same text in its `SOUL.md` block, from a user-scope pull only: `SOUL.md` is global, so a project pull leaves it as the user-scope pull wrote it. Hermes gets no project rules, and in a project `init` and `doctor` say why: `.hermes.md` would hide the project `AGENTS.md`, a `pre_llm_call` hook repeats the rules on every turn, and the one plugin prompt section (at most 4,000 characters) already carries the team instructions. Frontmatter is dropped, so a rule with `paths:` applies everywhere there, led by an `Applies to files matching: ` line. Codex runs the hook again after a compaction or a clear, and adds nothing when it resumes a session, which already holds the rules. A subagent Codex spawns gets them through the `SubagentStart` hook. The public Codex runs only trusted hooks. teamai trusts the hooks it writes automatically; if automatic trust is disabled or fails, approve them in `/hooks` to receive the project rules. +Most tools get one file per rule in their rules directory. Codex, `codex-internal` and `tcodex` read no rules directory (`.codex/rules/` holds Codex's own `*.rules` command policies), so `pull` writes no rule file for them. In user scope the team rules go into a `` block of the tool's own `AGENTS.md` (`~/.codex/AGENTS.md`, `~/.codex-internal/AGENTS.md`, `~/.tcodex/AGENTS.md`; a `toolRoots` entry moves it), which only that tool reads. In a project their session-start hook adds the project's team rules to each session instead: the project `AGENTS.md` is the owners' file, and other tools with a rules format of their own read it too. ZCode, DeepSeek Harness, OpenClaw, Pi and JoyCode have no rules format either, and in user scope get the same block in a file only that tool reads: `~/.zcode/AGENTS.md`, `$DSH_HOME/AGENTS.md` (`~/.dsh/AGENTS.md` when `DSH_HOME` is unset), the OpenClaw workspace `AGENTS.md` (found the way its hook finds it), `~/.pi/agent/AGENTS.md` beside the instruction blocks, and `~/.joycode/rules.txt`. Pull writes it only for an installed tool, and a project pull writes none of these files. OpenClaw gets no project rules, since its only project file is the `AGENTS.md` other tools read too; in a project `init` and `doctor` say so. Earlier releases copied rules to `.openclaw/rules`, `~/.pi/agent/rules` and `~/.joycode/rules`, which these tools never read: the next pull, even at an unchanged team revision, removes the copies that still hold what teamai delivered and names the ones you edited. Hermes gets the same text in its `SOUL.md` block, from a user-scope pull only: `SOUL.md` is global, so a project pull leaves it as the user-scope pull wrote it. Hermes gets no project rules, and in a project `init` and `doctor` say why: `.hermes.md` would hide the project `AGENTS.md`, a `pre_llm_call` hook repeats the rules on every turn, and the one plugin prompt section (at most 4,000 characters) already carries the team instructions. Frontmatter is dropped, so a rule with `paths:` applies everywhere there, led by an `Applies to files matching: ` line. Codex runs the hook again after a compaction or a clear, and adds nothing when it resumes a session, which already holds the rules. A subagent Codex spawns gets them through the `SubagentStart` hook. The public Codex runs only trusted hooks. teamai trusts the hooks it writes automatically; if automatic trust is disabled or fails, approve them in `/hooks` to receive the project rules. The culture, shared-instructions and recall blocks follow the same split. In user scope they go to that same `AGENTS.md`, and your own content outside the markers is kept. In a project the session-start hook adds them with the rules, and `pull` leaves the project `AGENTS.md` unchanged. @@ -1724,6 +1724,7 @@ Two members of the same project can have different roles, so their shared instru | OpenCode | `~/.config/opencode/teamai-context.md`, listed by absolute path in `instructions` of `~/.config/opencode/opencode.json` | `.opencode/teamai-context.md`, listed in `instructions` of `.opencode/opencode.json` | | Oh My Pi | `~/.omp/agent/RULES.md` | Added to each turn's system prompt by teamai's OMP extension | | Pi | `~/.pi/agent/AGENTS.md` | Added to each run's system prompt by teamai's Pi extension | +| OpenClaw | The workspace `AGENTS.md`, found the way its hook finds it (`agents.defaults.workspace`, `OPENCLAW_WORKSPACE_DIR`, or `/workspace`), beside the team rules (unverified) | Nothing: its only project file is the shared `AGENTS.md` | | Hermes | A block in `$HERMES_HOME/SOUL.md`, beside the team rules block (unverified) | A system prompt section from teamai's Hermes plugin (unverified) | A team `toolPaths` entry without `rules` keeps its configured `claudemd` for Claude Code, Cursor, CodeBuddy and WorkBuddy, which have no rules directory to take a `teamai-context` file. An entry with only `claudemd` counts as installed when that file's directory exists, and always for a bare file such as `AGENTS.md`. @@ -1756,6 +1757,7 @@ The pull names each file it changes: - Hermes: `~/AGENTS.md` - Oh My Pi: `~/.omp/agent/AGENTS.md` and `.omp/AGENTS.md`. Oh My Pi reads one context file per level, so these hid `~/.agents/AGENTS.md` and the project's `AGENTS.md`. - Pi: the project `AGENTS.md` +- OpenClaw, project scope: `.openclaw/workspace/AGENTS.md`, which OpenClaw never reads - Codex family: the project `AGENTS.md`, when a team's `toolPaths` or an earlier build pointed Codex there - Any tool whose file changed: the `claudemd` path the team's `toolPaths` sets for it, unless another tool's blocks go there now @@ -2284,7 +2286,7 @@ Team hooks still come from the team's `hooks/hooks.yaml`: edit that source in th [Pi](https://github.com/badlogic/pi-mono/tree/main/packages/coding-agent) is supported through its documented skills, instruction, and extension surfaces: -- **Scopes.** Project skills and TeamAI-managed rules are written to `.pi/skills/` and `.pi/rules/`. User-scope copies use `~/.pi/agent/skills/` and `~/.pi/agent/rules/`. +- **Scopes.** Project skills and TeamAI-managed rules are written to `.pi/skills/` and `.pi/rules/`. User-scope skills use `~/.pi/agent/skills/`. Pi reads no user rules directory, so the user-scope team rules are a block in `~/.pi/agent/AGENTS.md`, without path scoping; a pull removes the unedited copies earlier releases left in `~/.pi/agent/rules/` and names the ones you edited. - **Instructions.** Pi reads the project's own `AGENTS.md` (or `CLAUDE.md`); TeamAI leaves it unchanged. User-scope team instructions go to `~/.pi/agent/AGENTS.md`. In a project, the TeamAI Pi extension asks `teamai` for the member's team instructions when the session starts and adds them to the system prompt of each run. - **Hooks.** TeamAI generates one user-scoped `teamai-hooks.ts` under `~/.pi/agent/extensions/`. It maps `session_start` → session-start, `before_agent_start` → prompt-submit, and `agent_settled` → stop; `tool_execution_start` caches the tool's input, and `tool_execution_end` dispatches post-tool-use forwarding that cached input as `tool_input`, plus the result's text as `tool_response` and a `tool_status` from its error flag. Every event carries the Pi session id (`ctx.sessionManager.getSessionId()`), the same id Pi's bash tool exports as `PI_SESSION_ID`, so a `teamai recall` run there joins the session its hooks carry and upvote **adoption** runs for Pi. Pi loads both user and project extension roots, so TeamAI never creates a project copy — a second copy would double-dispatch every event, the same single-copy policy as the OMP adapter. An older TeamAI-managed project copy is removed during the next sync, and injection never overwrites a same-named file that lacks the TeamAI marker. Pi has no settings file for self mode to commit, so a fresh clone still needs one `teamai init`/`pull` on that machine before Pi hooks are active there. The explicit `teamai hooks remove` command and user-scope `teamai uninstall --agent pi` delete this shared extension. Project uninstall preserves it for other projects and removes any legacy project copy; files without the TeamAI marker are never removed. `teamai hooks list` always reports this global path. Pi profile overrides (`PI_CODING_AGENT_DIR` / `PI_CONFIG_DIR`), which relocate the agent directory, are not supported for hooks — same as the OMP adapter — and the default `~/.pi/agent/` layout is used. Model profiles are separate and do read `PI_CODING_AGENT_DIR`. The shared extension remains installed after project uninstall; instruction dispatch checks the project's tool exclusion before adding its instructions. - **Team hooks boundary.** The Pi adapter installs only the built-in lifecycle bridge. Custom team hooks and built-in hook overrides declared in `hooks/hooks.yaml` are skipped with a warning. Full team-hook and per-project ownership semantics require a separate cross-adapter design and are deferred to a follow-up PR. @@ -2320,7 +2322,7 @@ ZCode is available as a built-in target. Skills deploy to `.zcode/skills/` (ZCod - On Windows, hook entries launch through a hidden **wscript VBS launcher** (`wscript.exe `): wscript is a GUI-subsystem binary, so hook runs never flash a console window, and the launcher spools STDIN to a temp file so the payload reaches `hook-dispatch`. Timeouts are network-scale per event (180s session start, 60s stop / prompt submit, 30s post-tool-use) so a session-start dispatch carrying a repo pull is not killed mid-flight. Payloads containing multi-byte text may degrade at the launcher's ANSI-codepage spool step — identity fields are salvaged so degraded dispatches stay linked to the session; uninstall removes both the entries and the script. - On POSIX, entries are plain `bash -lc ` argv vectors and the launcher is not written; on both platforms the command tail is stored verbatim as the entry's last argv element, which is what managed-entry detection and the managed-hooks manifest match against. -These paths are verified against the ZCode desktop app: profiles created in its Subagents settings page land in `~/.zcode/agents/*.md`, and files placed there (e.g. by TeamAI) show up in the page's installed list. MCP servers deploy to `~/.agents/mcp.json` (user scope, Claude `mcpServers` shape — the same file ZCode's own MCP settings page reads). Project scope is not wired: ZCode stores workspace MCP under a different key (`mcp.servers` inside `.zcode/config.json`), which the Claude writer cannot emit. ZCode has no user-level rules directory convention, so rules are not synced. +These paths are verified against the ZCode desktop app: profiles created in its Subagents settings page land in `~/.zcode/agents/*.md`, and files placed there (e.g. by TeamAI) show up in the page's installed list. MCP servers deploy to `~/.agents/mcp.json` (user scope, Claude `mcpServers` shape — the same file ZCode's own MCP settings page reads). Project scope is not wired: ZCode stores workspace MCP under a different key (`mcp.servers` inside `.zcode/config.json`), which the Claude writer cannot emit. ZCode reads no rules directory: in user scope the team rules are a block in `~/.zcode/AGENTS.md`, which ZCode reads as its user context, without path scoping. A project gets no team rules. ### Oh My Pi @@ -2332,6 +2334,8 @@ Rules are written in OMP's own frontmatter, in `.omp/rules/` and `~/.omp/agent/r DeepSeek Harness (`dsh`) is supported for TeamAI skills and shared resources. DSH's official Claude-hook bridge is a profile plugin rather than a settings-file hook surface, so when a user-level `~/.dsh/` installation is present, `teamai init`, `teamai pull`, or `teamai hooks inject` writes a Claude-compatible hook config and a Cordis patch under `~/.teamai/dsh/`. +dsh reads no rules directory. In user scope the team rules are a block in `$DSH_HOME/AGENTS.md` (`~/.dsh/AGENTS.md` when `DSH_HOME` is unset), which dsh puts in its first request, without path scoping. As for skills and hooks, teamai writes it only when `~/.dsh/` exists. A project gets no team rules. + TeamAI prints the exact absolute patch path. Add that `--patch` flag to the command that starts your DSH profile, for example `dsh tui --patch ""`. This is a one-time launcher opt-in; `teamai hooks remove` and `teamai uninstall` remove the TeamAI patch while preserving other hook entries in the generated config. ### JoyCode @@ -2340,6 +2344,8 @@ JoyCode is available as a built-in target. Skills, rules, and subagents are depl Rules are `.mdc` files in JoyCode's own render. JoyCode reads the frontmatter line by line, not as YAML: it keeps the quotes Cursor's render puts around `globs` and splits the value on every comma, so a scoped rule in Cursor's form never applied. A rule with `paths:` gets `globs:` unquoted and comma-separated, with each `{a,b}` alternation expanded into separate globs, and `alwaysApply: false`; a rule without `paths` gets `alwaysApply: true`. On `push`, only the Markdown body flows back, as for Cursor. Copies an older teamai wrote in Cursor's form are rewritten on the next `pull` when they still hold what teamai delivered; one you edited is kept and named, and while its `globs` are still quoted, `pull` says it applies to no file and how to fix it. `doctor` compares each copy in a project's `.joycode/rules/` with this render. +In user scope JoyCode reads no rules directory: the team rules are a block in `~/.joycode/rules.txt`, without path scoping. A pull removes the unedited `.mdc` copies earlier releases left in `~/.joycode/rules/` and names the ones you edited. + JoyCode rule cleanup is conservative: local `.mdc` and `.md` files absent from the team rule list are preserved unless an explicit team removal tombstone exists. This protects personal rules in the shared directory; an old team copy without a deletion record is retained rather than guessed to be stale. For canonical YAML agents, push compares each local file with the corresponding tool rendering and merges only actual edits back into the original spec. Deployment `targets`, other tools' metadata, and fields absent from a tool's native format are preserved. Conflicting or unparseable edits are skipped rather than replacing the canonical agent. @@ -2388,7 +2394,7 @@ Besides the provider, clone, config and hook checks, `doctor` verifies what reac `Rules delivered to ` and `Agents delivered to ` do the same for the other two per-tool resources, and both ask the handler where an item lands rather than deriving a path: a rule's filename and content change per tool (`.md` verbatim, `.mdc` with derived `globs`/`alwaysApply` (unquoted for JoyCode), `.instructions.md` with `applyTo`, Kiro's `.md` with `inclusion`/`fileMatchPattern`, Qoder's `.md` with `trigger`/`glob`, Oh My Pi's flat `.md` with `alwaysApply` or `globs`/`description`, CodeBuddy's `.md` with `alwaysApply`/`paths`; tools that read one copy, as CodeBuddy and WorkBuddy do in a project, get one check naming both), and an agent's destination comes from its render, with `targets:` deciding which tools are owed a copy at all. A delivered rule is compared with the bytes the handler renders for that tool, not merely read for the keys its tool needs: a `.mdc` whose `globs` no longer match the team rule's `paths:` applies to the wrong files while carrying a perfectly legal `alwaysApply`, and that reads here as `delivered from an older copy` — the same label as a body that drifted, because both landed successfully and are still wrong. An agent is compared with the bytes its render produces, so a copy left behind by an older spec — a plain pull skips a scope whose team repo has not changed, so it can sit there indefinitely — is reported as `delivered from an older spec` rather than passing as present. `Every team agent reaches a tool` names an agent that renders for no installed tool — usually a spec that does not parse, or a `targets:` list naming only tools you do not have. These two are `doctor`-only: they read every rule per tool and parse every agent, which would spend the budget the checks at the end of a pull run under. -Three tools do not read a rules directory, so a per-file check cannot speak for them and each gets one of its own. `Team rules are active in opencode` checks that `opencode.json` (`.opencode/opencode.json` in a project) lists every glob the pull owns under `instructions`, and no stale one such as the relative `rules/*.md` an earlier release wrote: OpenCode does not auto-scan `.opencode/rules`, so without it every delivered `.md` is inert while the per-file check keeps passing. In user scope, `Team rules are inlined in Hermes SOUL.md` compares the teamai-managed block of `SOUL.md` with what the team rules inline to, since Hermes reads standing instructions from that one file rather than from a directory — a deleted block, or one left on an older rule set, is a tool reading the wrong rules with nothing on disk to show for it. In user scope, `Team rules are inlined in Codex AGENTS.md` compares the team-rules block of the tool's `AGENTS.md` with what the team rules inline to, and fails when an `AGENTS.override.md` beside it shadows the file. In a project, `Project rules and instructions reach whole through its session hooks` fails when the teamai `SessionStart` or `SubagentStart` entry in that tool's `hooks.json` is missing or does not set `additionalContextLimit: 0`, without which Codex keeps only the start and end of a large set. Codex has no `Rules delivered to ` check. +Several tools do not read a rules directory, so a per-file check cannot speak for them and each gets one of its own. `Team rules are active in opencode` checks that `opencode.json` (`.opencode/opencode.json` in a project) lists every glob the pull owns under `instructions`, and no stale one such as the relative `rules/*.md` an earlier release wrote: OpenCode does not auto-scan `.opencode/rules`, so without it every delivered `.md` is inert while the per-file check keeps passing. In user scope, `Team rules are inlined in Hermes SOUL.md` compares the teamai-managed block of `SOUL.md` with what the team rules inline to, since Hermes reads standing instructions from that one file rather than from a directory — a deleted block, or one left on an older rule set, is a tool reading the wrong rules with nothing on disk to show for it. In user scope, `Team rules are inlined in ` (`Codex AGENTS.md`, `ZCode AGENTS.md`, `DeepSeek Harness AGENTS.md`, `OpenClaw workspace AGENTS.md`, `Pi AGENTS.md`, `JoyCode rules.txt`) compares the team-rules block of the file that tool reads with what the team rules inline to; for Codex it also fails when an `AGENTS.override.md` beside it shadows the file. In a project, `Project rules and instructions reach whole through its session hooks` fails when the teamai `SessionStart` or `SubagentStart` entry in that tool's `hooks.json` is missing or does not set `additionalContextLimit: 0`, without which Codex keeps only the start and end of a large set. Codex has no `Rules delivered to ` check. `MCP servers delivered to ` compares each server the team's `mcp.yaml` resolves for that tool against the entry in the tool's own config, and names any the reconcile skipped with its reason. The comparison is the entry, not the name: reconciliation leaves an entry teamai does not own alone, so a server of your own under a team name holds the key while the team's definition never arrives, and a stale copy is just as undelivered. Both are reported as `not the team's definition`, and only `teamai pull --force` replaces an entry teamai did not write. An unresolved `${VAR}` is reported here with the variable's name, which is otherwise said once during a pull and never again. A declared secret with no value is not a failure: doctor prints it as a note (`notes` in `--json`) with the command that sets it, and the exit code stays as it would be without it; a note also says when an entry kept for it may hold an old value, and when a key is declared as a secret and also set in `env.yaml`. An `mcp.yaml` that does not parse is not a team without MCP: it is reported as `Team MCP servers can be read` with the parse error, since it injects nothing into any tool and every run after the first is silent about it. Team hooks and team model profiles that cannot be resolved (a file that does not parse, a name defined twice in one file, or one name in two active namespaces) fail `Team hooks can be resolved` and `Team model profiles can be resolved` with the reason pull logs once; `teamai status` points here when it counts them as 0. `Env variables injected in shell profile` no longer stops at finding the marker comment: it checks that `env/env.yaml` parses and declares its variables under the `variables:` key (a plain `KEY: value` mapping parses as none, while an explicit `variables: []` is a configuration with nothing to deliver and fails nothing), that each one reached `env.sh` with the value `env.yaml` declares, or your value for this team (one set with `--from-env` is not written there) — a key left over from an older value exports it to every shell and MCP server until the next pull, and the comparison reads `env.sh` back through the generator's own inverse, so a multiline value quoted across several lines is matched rather than called stale — and that this scope's injected block (the one sourcing its own `env.sh`, since a profile can also carry another scope's) would actually load it — an unquoted Windows path degrades to something a POSIX shell cannot read, so `source` never runs and nothing says so. `No stale env blocks left behind` is a separate check: which file `pull` prefers has changed over time (Windows Git Bash's login shell reads `.bash_profile`/`.bash_login`/`.profile`, never `.bashrc`), and a pull only ever adds a block, never migrates an old one away, so a dead block from an earlier install or platform change can sit in another candidate file indefinitely. It names every such file (checking `.zshrc`, `.bashrc`, `.bash_profile`, `.bash_login` and `.profile`, current and legacy spellings alike) and points at `teamai uninstall` to remove them — separately from delivery, so a working env block never reads as broken just because an old one is still lying around. diff --git a/docs/usage-guide.zh-CN.md b/docs/usage-guide.zh-CN.md index 1c3b7a2f2..93dc6bdf7 100644 --- a/docs/usage-guide.zh-CN.md +++ b/docs/usage-guide.zh-CN.md @@ -923,7 +923,7 @@ teamai push > 管理员可在 `teamai.yaml` 中设置强制规则(`sharing.rules.enforced`),成员不可删除。 -大多数工具在自己的 rules 目录中为每条 rule 得到一个文件。Codex、`codex-internal` 和 `tcodex` 不读取 rules 目录(`.codex/rules/` 存放的是 Codex 自己的 `*.rules` 命令策略文件),因此 `pull` 不为它们写任何 rule 文件。user scope 下,团队 rule 写入该工具自己的 `AGENTS.md`(`~/.codex/AGENTS.md`、`~/.codex-internal/AGENTS.md`、`~/.tcodex/AGENTS.md`;`toolRoots` 条目可改变其位置)中的 `` 区块,只有该工具读取这个文件。在项目中,改由它们的 session-start hook 把项目的团队 rule 加入每个会话:项目 `AGENTS.md` 属于项目维护者,其他拥有自己 rules 格式的工具也会读取它。Hermes 的 `SOUL.md` 区块得到同样的内容,且只由 user scope 的 pull 写入:`SOUL.md` 是全局文件,项目 pull 会让它保持 user scope pull 写入时的样子。Hermes 不会得到项目 rule,在项目中 `init` 和 `doctor` 会说明原因:`.hermes.md` 会遮蔽项目 `AGENTS.md`,`pre_llm_call` hook 会在每一轮重复添加 rule,而唯一的插件提示段落(最多 4,000 个字符)已用于团队指令。frontmatter 会被去掉,所以带 `paths:` 的 rule 在这里对所有文件生效,并以一行 `Applies to files matching: ` 开头。Codex 在压缩上下文或 clear 之后会再次运行该 hook;恢复会话时不添加任何内容,因为会话中已包含这些 rule。Codex 启动的子 agent 通过 `SubagentStart` hook 获得它们。公开版 Codex 只运行已信任的 hook。teamai 会自动信任它写入的 hooks;如果自动信任被禁用或失败,请在 `/hooks` 中批准它们以获得项目的 rule。 +大多数工具在自己的 rules 目录中为每条 rule 得到一个文件。Codex、`codex-internal` 和 `tcodex` 不读取 rules 目录(`.codex/rules/` 存放的是 Codex 自己的 `*.rules` 命令策略文件),因此 `pull` 不为它们写任何 rule 文件。user scope 下,团队 rule 写入该工具自己的 `AGENTS.md`(`~/.codex/AGENTS.md`、`~/.codex-internal/AGENTS.md`、`~/.tcodex/AGENTS.md`;`toolRoots` 条目可改变其位置)中的 `` 区块,只有该工具读取这个文件。在项目中,改由它们的 session-start hook 把项目的团队 rule 加入每个会话:项目 `AGENTS.md` 属于项目维护者,其他拥有自己 rules 格式的工具也会读取它。ZCode、DeepSeek Harness、OpenClaw、Pi 和 JoyCode 同样没有 rules 格式,在 user scope 下把同样的区块写入只有该工具读取的文件:`~/.zcode/AGENTS.md`、`$DSH_HOME/AGENTS.md`(未设置 `DSH_HOME` 时为 `~/.dsh/AGENTS.md`)、OpenClaw 工作区的 `AGENTS.md`(按其 hook 的方式查找)、与指令区块并列的 `~/.pi/agent/AGENTS.md`,以及 `~/.joycode/rules.txt`。pull 只为已安装的工具写入,项目 pull 不写这些文件。OpenClaw 不会得到项目 rule,因为它唯一的项目文件是其他工具也会读取的 `AGENTS.md`;在项目中 `init` 和 `doctor` 会说明这一点。旧版本把 rule 复制到这些工具从不读取的 `.openclaw/rules`、`~/.pi/agent/rules` 和 `~/.joycode/rules`:下一次 pull(即使团队版本未变)会删除仍是 teamai 所下发内容的副本,并点名你改过的副本。Hermes 的 `SOUL.md` 区块得到同样的内容,且只由 user scope 的 pull 写入:`SOUL.md` 是全局文件,项目 pull 会让它保持 user scope pull 写入时的样子。Hermes 不会得到项目 rule,在项目中 `init` 和 `doctor` 会说明原因:`.hermes.md` 会遮蔽项目 `AGENTS.md`,`pre_llm_call` hook 会在每一轮重复添加 rule,而唯一的插件提示段落(最多 4,000 个字符)已用于团队指令。frontmatter 会被去掉,所以带 `paths:` 的 rule 在这里对所有文件生效,并以一行 `Applies to files matching: ` 开头。Codex 在压缩上下文或 clear 之后会再次运行该 hook;恢复会话时不添加任何内容,因为会话中已包含这些 rule。Codex 启动的子 agent 通过 `SubagentStart` hook 获得它们。公开版 Codex 只运行已信任的 hook。teamai 会自动信任它写入的 hooks;如果自动信任被禁用或失败,请在 `/hooks` 中批准它们以获得项目的 rule。 culture、共享指令和 recall 区块采用同样的划分。user scope 下它们写入同一个 `AGENTS.md`,标记之外你自己的内容保持不变。在项目中,session-start hook 把它们与 rule 一起加入会话,`pull` 不改动项目 `AGENTS.md`。 @@ -1582,6 +1582,7 @@ pull 只把团队文化、共享指令和 recall 块写入已安装 AI 工具的 | OpenCode | `~/.config/opencode/teamai-context.md`,以绝对路径列在 `~/.config/opencode/opencode.json` 的 `instructions` 中 | `.opencode/teamai-context.md`,列在 `.opencode/opencode.json` 的 `instructions` 中 | | Oh My Pi | `~/.omp/agent/RULES.md` | 由 teamai 的 OMP 扩展加入每轮的系统提示 | | Pi | `~/.pi/agent/AGENTS.md` | 由 teamai 的 Pi 扩展加入每次运行的系统提示 | +| OpenClaw | 工作区的 `AGENTS.md`,按其 hook 的方式查找(`agents.defaults.workspace`、`OPENCLAW_WORKSPACE_DIR` 或 `/workspace`),与团队 rule 并列(未验证) | 无:它唯一的项目文件是共享的 `AGENTS.md` | | Hermes | `$HERMES_HOME/SOUL.md` 中的一个块,位于团队规则块旁(未验证) | teamai 的 Hermes 插件提供的系统提示段落(未验证) | 团队 `toolPaths` 中没有 `rules` 的条目,Claude Code、Cursor、CodeBuddy 和 WorkBuddy 继续使用其配置的 `claudemd`,因为它们没有可放置 `teamai-context` 文件的 rules 目录。只有 `claudemd` 的条目在该文件所在目录存在时视为已安装;对 `AGENTS.md` 这类不在目录中的文件则始终视为已安装。 @@ -1614,6 +1615,7 @@ pull 会列出所修改的每个文件: - Hermes:`~/AGENTS.md` - Oh My Pi:`~/.omp/agent/AGENTS.md` 和 `.omp/AGENTS.md`。Oh My Pi 每一层只读取一个上下文文件,因此它们会遮蔽 `~/.agents/AGENTS.md` 和项目的 `AGENTS.md`。 - Pi:项目 `AGENTS.md` +- OpenClaw,项目 scope:OpenClaw 从不读取的 `.openclaw/workspace/AGENTS.md` - Codex 系列:项目 `AGENTS.md`(当团队的 `toolPaths` 或早期构建把 Codex 指向那里时) - 目标文件已改变的任一工具:团队 `toolPaths` 为它设置的 `claudemd` 路径,除非现在另一个工具的块写在那里 @@ -2127,7 +2129,7 @@ GitHub Copilot CLI 已支持其官方自定义指令、Rules、Skills、自定 [Pi](https://github.com/badlogic/pi-mono/tree/main/packages/coding-agent) 通过其公开的 Skills、指令文件和扩展机制接入: -- **作用域。** 项目级 Skills 和 TeamAI 管理的 Rules 写入 `.pi/skills/`、`.pi/rules/`;用户级副本写入 `~/.pi/agent/skills/`、`~/.pi/agent/rules/`。 +- **作用域。** 项目级 Skills 和 TeamAI 管理的 Rules 写入 `.pi/skills/`、`.pi/rules/`;用户级 Skills 写入 `~/.pi/agent/skills/`。Pi 不读取用户级 rules 目录,因此 user scope 的团队 rule 是 `~/.pi/agent/AGENTS.md` 中的一个区块,不按路径限定作用范围;pull 会删除旧版本留在 `~/.pi/agent/rules/` 中未修改的副本,并点名你改过的副本。 - **指令文件。** Pi 读取项目自己的 `AGENTS.md`(或 `CLAUDE.md`),TeamAI 不修改它。用户范围的团队指令写入 `~/.pi/agent/AGENTS.md`。在项目中,TeamAI 的 Pi 扩展在会话开始时向 `teamai` 获取成员的团队指令,并加入每次运行的系统提示。 - **Hooks。** TeamAI 只在用户级 `~/.pi/agent/extensions/` 生成一份 `teamai-hooks.ts`,把 `session_start` 映射为 session-start、`before_agent_start` 映射为 prompt-submit、`agent_settled` 映射为 stop;`tool_execution_start` 缓存工具输入,`tool_execution_end` 派发 post-tool-use 时把缓存的输入转发为 `tool_input`,并附上结果文本 `tool_response` 和根据错误标志得出的 `tool_status`。每个事件都携带 Pi 会话 id(`ctx.sessionManager.getSessionId()`),与 Pi 的 bash 工具导出的 `PI_SESSION_ID` 相同,因此在其中运行的 `teamai recall` 会归入其 hooks 携带的同一会话,upvote **采纳(adoption)**在 Pi 上同样生效。Pi 会同时加载用户级与项目级扩展目录,因此 TeamAI 不创建项目副本——第二份副本会导致每个事件被派发两次,这与 OMP 适配器的单副本策略一致。早期版本遗留且带 TeamAI 标记的项目副本会在下次同步时移除,注入逻辑也不会覆盖没有 TeamAI 标记的同名文件。Pi 没有可供 self mode 提交的设置文件,所以 fresh clone 仍需在该机器上手动跑一次 `teamai init`/`pull` 才能激活 Pi hooks。显式执行 `teamai hooks remove` 或用户级 `teamai uninstall --agent pi` 会删除这份共享扩展。项目级卸载为其他项目保留它,并移除旧的项目副本;没有 TeamAI 标记的同名文件不会被删除。`teamai hooks list` 始终显示这个全局路径。Pi 的 profile 覆盖项(`PI_CODING_AGENT_DIR` / `PI_CONFIG_DIR`,会迁移 agent 目录)在 hooks 中暂不支持,与 OMP 适配器一致,使用默认的 `~/.pi/agent/` 布局。模型配置是另一回事,会读取 `PI_CODING_AGENT_DIR`。项目级卸载后共享扩展仍已安装;指令派发会先检查此项目对该工具的排除设置。 - **团队 Hooks 边界。** Pi 适配器只安装内置生命周期桥接。`hooks/hooks.yaml` 声明的自定义团队 Hooks 和内置 Hook 覆盖会被跳过并给出警告。完整团队 Hooks 与逐项目归属语义需要单独的跨适配器设计,留待后续 PR。 @@ -2163,7 +2165,7 @@ ZCode 已作为内置目标支持。Skills 下发到 `.zcode/skills/`(ZCode - Windows 上,钩子条目通过隐藏的 **wscript VBS 启动器**执行(`wscript.exe <分发命令尾段>`):wscript 属 GUI 子系统,钩子运行绝不弹控制台黑框;启动器把 STDIN 暂存为临时文件再转发,保证 payload 完整到达 `hook-dispatch`。超时按事件放宽(会话启动 180 秒、stop / prompt 提交 60 秒、工具调用后 30 秒),避免会话启动时携带仓库拉取的分发被中途掐断。含多字节文本(如中文)的 payload 在启动器的 ANSI 代码页暂存环节可能降级——身份字段会被抢救,降级分发仍能正确关联到会话;卸载时会同时清除条目与脚本文件。 - POSIX 上条目就是普通的 `bash -lc <分发命令尾段>` argv 向量,不写入启动器;两个平台上,命令尾段都以 argv 末位元素原样存储——这正是托管条目识别与托管清单比对的依据。 -以上路径已对照 ZCode 桌面端实测验证:设置页「新建子智能体」写入的就是 `~/.zcode/agents/*.md`,反向放入的文件也会出现在页面的已安装列表中。MCP Server 下发到 `~/.agents/mcp.json`(用户级,Claude 的 `mcpServers` 结构——正是 ZCode 自己的 MCP 设置页读取的文件)。项目级暂未接入:ZCode 的工作区 MCP 使用不同的键(`.zcode/config.json` 内的 `mcp.servers`),Claude 写入器无法生成该结构。ZCode 暂无用户级 Rules 目录约定,因此 Rules 不同步。 +以上路径已对照 ZCode 桌面端实测验证:设置页「新建子智能体」写入的就是 `~/.zcode/agents/*.md`,反向放入的文件也会出现在页面的已安装列表中。MCP Server 下发到 `~/.agents/mcp.json`(用户级,Claude 的 `mcpServers` 结构——正是 ZCode 自己的 MCP 设置页读取的文件)。项目级暂未接入:ZCode 的工作区 MCP 使用不同的键(`.zcode/config.json` 内的 `mcp.servers`),Claude 写入器无法生成该结构。ZCode 不读取 rules 目录:user scope 下团队 rule 是 `~/.zcode/AGENTS.md` 中的一个区块,ZCode 把它作为用户上下文读取,不按路径限定作用范围。项目不会得到团队 rule。 ### Oh My Pi @@ -2175,6 +2177,8 @@ Rules 以 OMP 自己的 frontmatter 写入 `.omp/rules/` 与 `~/.omp/agent/rules DeepSeek Harness(`dsh`)支持 TeamAI Skills 和共享资源。DSH 官方的 Claude Hook Bridge 是通过 profile 插件加载的,并不是设置文件中的 Hooks;因此检测到用户级 `~/.dsh/` 安装后,`teamai init`、`teamai pull` 或 `teamai hooks inject` 会在 `~/.teamai/dsh/` 下生成兼容 Claude 的 Hook 配置和 Cordis patch。 +dsh 不读取 rules 目录。user scope 下团队 rule 是 `$DSH_HOME/AGENTS.md`(未设置 `DSH_HOME` 时为 `~/.dsh/AGENTS.md`)中的一个区块,dsh 会把它放进第一次请求,不按路径限定作用范围。与 Skills 和 Hook 一样,只有 `~/.dsh/` 存在时 teamai 才写入它。项目不会得到团队 rule。 + TeamAI 会打印带绝对路径的 patch。将这个 `--patch` 参数加到启动 DSH profile 的命令中,例如 `dsh tui --patch "<打印出的路径>"`。这是一次性的启动器选择;`teamai hooks remove` 和 `teamai uninstall` 会移除 TeamAI patch,同时保留生成配置中的其他 Hook 条目。 ### JoyCode @@ -2183,6 +2187,8 @@ JoyCode 已作为内置目标支持。Skills、Rules 和 Subagents 分别下发 Rules 是采用 JoyCode 自有渲染的 `.mdc` 文件。JoyCode 逐行读取 frontmatter,而不是按 YAML 解析:它会保留 Cursor 渲染给 `globs` 加的引号,并按每个逗号拆分取值,因此 Cursor 形式的带 `paths:` 的 rule 从未生效。带 `paths:` 的 rule 写成不加引号、逗号分隔的 `globs:`,每个 `{a,b}` 选择项都展开为单独的 glob,并加上 `alwaysApply: false`;不带 `paths` 的 rule 写成 `alwaysApply: true`。`push` 时只有 Markdown 正文回流,与 Cursor 相同。旧版 teamai 以 Cursor 形式写入的副本,若仍是 teamai 所下发的内容,会在下一次 `pull` 时重写;你改过的副本会保留并被点名;只要其 `globs` 仍带引号,`pull` 会指出它不作用于任何文件,并说明如何修正。`doctor` 将项目 `.joycode/rules/` 中的每份副本与该渲染比对。 +user scope 下 JoyCode 不读取 rules 目录:团队 rule 是 `~/.joycode/rules.txt` 中的一个区块,不按路径限定作用范围。pull 会删除旧版本留在 `~/.joycode/rules/` 中未修改的 `.mdc` 副本,并点名你改过的副本。 + JoyCode 规则清理采用保守策略:不在团队规则列表中的本地 `.mdc` 和 `.md` 文件会被保留,只有团队明确记录了删除标记(tombstone)才会清理。这能保护同一目录中的个人规则;缺少删除记录的旧团队副本也会保留,不会猜测其已过期。 对于以 YAML 保存的团队 Agent,push 会将本地文件与对应工具的渲染结果比较,只将真实编辑合并回原始配置。部署范围 `targets`、其他工具的元数据,以及本地格式未输出的字段都会保留。遇到冲突或无法解析的编辑时跳过回写,不会替换团队源文件。 @@ -2231,7 +2237,7 @@ teamai remove rules --force # 跳过确认,用于脚本和 CI `Rules delivered to ` 与 `Agents delivered to ` 对另外两类按工具下发的资源做同样的事,并且都向 handler 询问落点,而不是自行拼路径:rule 的文件名和内容因工具而异(`.md` 原样、`.mdc` 带派生的 `globs`/`alwaysApply`(JoyCode 的不加引号)、`.instructions.md` 带 `applyTo`、Kiro 的 `.md` 带 `inclusion`/`fileMatchPattern`、Qoder 的 `.md` 带 `trigger`/`glob`、Oh My Pi 平铺的 `.md` 带 `alwaysApply` 或 `globs`/`description`、CodeBuddy 的 `.md` 带 `alwaysApply`/`paths`;共用一份副本的工具,例如项目中的 CodeBuddy 与 WorkBuddy,只有一项同时点名两者的检查),agent 的落点来自渲染结果,且由 `targets:` 决定哪些工具应当收到。已送达的 rule 会与 handler 为该工具渲染出的字节逐一比对,而不只是检查该工具所需的键是否存在:`globs` 与团队 rule 的 `paths:` 不再一致的 `.mdc`,即使 `alwaysApply` 取值合法,也会作用到错误的文件上;这里会报告为 `delivered from an older copy`——正文漂移的副本同样如此,因为两者都写入成功,却都是错的。agent 会与渲染结果逐字节比对:旧版 spec 留下的副本(普通 pull 会跳过团队仓库未变化的 scope,它可能一直留在那里)报告为 `delivered from an older spec`,而不是当作已送达。`Every team agent reaches a tool` 会指出在任何已安装工具上都无法渲染的 agent,通常是 spec 解析失败,或 `targets:` 只列了本机没有的工具。这两项仅在 `doctor` 中运行:它们会按工具读取每条 rule、解析每个 agent,放进 pull 结束时的检查会耗尽其时间预算。 -有三个工具并不读取 rules 目录,按文件比对的检查无法代表它们,因此各自单列一项。`Team rules are active in opencode` 检查 `opencode.json`(项目中为 `.opencode/opencode.json`)的 `instructions` 中是否列着 teamai 所拥有的每条 glob,且没有过时的条目(如旧版本写入的相对 `rules/*.md`):OpenCode 不会自动扫描 `.opencode/rules`,缺了它,已送达的每个 `.md` 都不会生效,而按文件比对的检查依旧通过。user scope 下,`Team rules are inlined in Hermes SOUL.md` 把 `SOUL.md` 中 teamai 管理的代码块与团队 rule 内联后的内容比对——Hermes 的常驻指令来自这一个文件而非某个目录,因此代码块被删除或停留在旧版规则集上,都意味着该工具读到的是错误的规则,而磁盘上看不出任何异常。user scope 下,`Team rules are inlined in Codex AGENTS.md` 把该工具 `AGENTS.md` 中的 team-rules 区块与团队 rule 内联后的内容比对,旁边有 `AGENTS.override.md` 遮蔽该文件时也会失败。在项目中,当该工具 `hooks.json` 中的 teamai `SessionStart` 或 `SubagentStart` 条目缺失或没有设置 `additionalContextLimit: 0` 时,`Project rules and instructions reach whole through its session hooks` 会失败:没有它,Codex 对较大的内容只保留开头和结尾。Codex 没有 `Rules delivered to ` 检查。 +有几个工具并不读取 rules 目录,按文件比对的检查无法代表它们,因此各自单列一项。`Team rules are active in opencode` 检查 `opencode.json`(项目中为 `.opencode/opencode.json`)的 `instructions` 中是否列着 teamai 所拥有的每条 glob,且没有过时的条目(如旧版本写入的相对 `rules/*.md`):OpenCode 不会自动扫描 `.opencode/rules`,缺了它,已送达的每个 `.md` 都不会生效,而按文件比对的检查依旧通过。user scope 下,`Team rules are inlined in Hermes SOUL.md` 把 `SOUL.md` 中 teamai 管理的代码块与团队 rule 内联后的内容比对——Hermes 的常驻指令来自这一个文件而非某个目录,因此代码块被删除或停留在旧版规则集上,都意味着该工具读到的是错误的规则,而磁盘上看不出任何异常。user scope 下,`Team rules are inlined in `(`Codex AGENTS.md`、`ZCode AGENTS.md`、`DeepSeek Harness AGENTS.md`、`OpenClaw workspace AGENTS.md`、`Pi AGENTS.md`、`JoyCode rules.txt`)把该工具所读文件中的 team-rules 区块与团队 rule 内联后的内容比对;对 Codex,旁边有 `AGENTS.override.md` 遮蔽该文件时也会失败。在项目中,当该工具 `hooks.json` 中的 teamai `SessionStart` 或 `SubagentStart` 条目缺失或没有设置 `additionalContextLimit: 0` 时,`Project rules and instructions reach whole through its session hooks` 会失败:没有它,Codex 对较大的内容只保留开头和结尾。Codex 没有 `Rules delivered to ` 检查。 `MCP servers delivered to ` 将团队 `mcp.yaml` 为该工具解析出的每个 server 与该工具自己配置文件中的条目逐一比对,并列出 reconcile 跳过的 server 及原因。比对的是条目内容而非名字:reconcile 不会覆盖不属于 teamai 的条目,因此你自己写的同名 server 会占住这个名字,团队的定义从未真正送达;过期的旧副本同样等于没送达。两者都报告为 `not the team's definition`,而覆盖非 teamai 写入的条目只有 `teamai pull --force` 能做到。未解析的 `${VAR}` 会在这里连同变量名一起报告——否则它只在 pull 时出现一次,之后再无提示。没有值的已声明密钥不算失败:doctor 把它作为备注打印(`--json` 中的 `notes`),并附上设置它的命令,退出码与没有它时相同;备注还会说明为它保留的条目可能含有旧值,以及某个 key 既声明为密钥、又在 `env.yaml` 中设置的情况。无法解析的 `mcp.yaml` 并不等于团队没有 MCP:它会作为 `Team MCP servers can be read` 连同解析错误一起报告,因为这种文件不会向任何工具注入内容,而且除第一次之外的每次运行都对此保持沉默。无法解析的团队 hooks 与团队模型配置(文件无法解析、同一文件内重复的名字,或两个活动 namespace 中的同名条目)会让 `Team hooks can be resolved` 与 `Team model profiles can be resolved` 失败,并给出 pull 只记录一次的原因;`teamai status` 把它们计为 0 时会指向这里。`Env variables injected in shell profile` 不再只查标记注释:它会检查 `env/env.yaml` 能否解析、以及是否在 `variables:` 键下声明了变量(写成普通的 `KEY: value` 映射等于没有声明;而显式写成 `variables: []` 属于没有内容要下发的配置,不会判为失败)、每个变量是否以 `env.yaml` 声明的值(或你为该团队设置的值;用 `--from-env` 设置的不会写入)写进了 `env.sh`(残留的旧值会一直被导出到每个 shell 和 MCP server,直到下次 pull;比对时会用生成器自身的逆运算读回 `env.sh`,因此跨多行引用的多行值能够正确匹配,而不会被误判为过期),以及本作用域注入的代码块(即 source 本作用域 `env.sh` 的那一块,因为同一个 profile 里还可能有其他作用域的代码块)是否真的能加载它——未加引号的 Windows 路径在 POSIX shell 中会被转义破坏,`source` 从不执行,而且没有任何提示。`No stale env blocks left behind` 是独立的一项检查:pull 优先选用哪个文件会随时间变化(Windows 上 Git Bash 的登录 shell 读取的是 `.bash_profile`/`.bash_login`/`.profile`,从不读取 `.bashrc`),而 pull 只会新增代码块,从不迁移旧的,因此早期安装或平台变化留下的失效代码块可能一直留在另一个候选文件里。它会列出每一个这样的文件(检查 `.zshrc`、`.bashrc`、`.bash_profile`、`.bash_login` 和 `.profile`,新旧写法都算),并指向 `teamai uninstall` 来清除它们——这与投递检查分开进行,因此不会因为还留着一个旧副本,就让一个正常工作的 env 代码块被判成故障。 diff --git a/skill-data/setup/references/uninstall.md b/skill-data/setup/references/uninstall.md index 325a41fb1..dda5726bf 100644 --- a/skill-data/setup/references/uninstall.md +++ b/skill-data/setup/references/uninstall.md @@ -67,9 +67,16 @@ and give it your team repo URL."* `.opencode/rules/*.md` an earlier release wrote to the root `opencode.json`, and deletes `.opencode/opencode.json` when nothing else is left in it. The user's own entries stay. +- Uninstall removes the team-rules block from the file a tool with no rules + format reads in user scope (`~/.codex/AGENTS.md`, `~/.zcode/AGENTS.md`, + `$DSH_HOME/AGENTS.md`, the OpenClaw workspace `AGENTS.md`, + `~/.pi/agent/AGENTS.md`, `~/.joycode/rules.txt`), and the file when teamai + created it for the block alone. - Uninstall cleans legacy Codex rule copies at the recorded `toolRoots` location, including publishers' bare local filenames, and the copies earlier - releases left in a project's `.workbuddy/rules`. It keeps edited copies. + releases left in a project's `.workbuddy/rules`, in `.openclaw/rules`, + `~/.pi/agent/rules` and `~/.joycode/rules`. It keeps edited copies and + names them. For a rule the team has removed, it deletes the copy only if its hash matches the recorded delivery. Without that record, it keeps the copy and names it in a warning. Save any diff --git a/src/__tests__/instruction-targets.test.ts b/src/__tests__/instruction-targets.test.ts index 1e3705920..4e1b25b78 100644 --- a/src/__tests__/instruction-targets.test.ts +++ b/src/__tests__/instruction-targets.test.ts @@ -757,13 +757,16 @@ describe('every tool and toolPaths shape keeps its instructions (#945)', () => { for (const value of Object.values(paths)) { if (typeof value === 'string') fs.mkdirSync(path.join(base, toolInstallRoot(value)), { recursive: true }); } + // OpenClaw is installed where its workspace resolves (#946). + if (tool === 'openclaw') fs.mkdirSync(path.join(process.env.HOME, '.openclaw', 'workspace'), { recursive: true }); const teamConfig = TeamaiConfigSchema.parse({ team: 't', repo: 'https://example.invalid/t.git', toolPaths: { [tool]: paths } }); const { targets, hooks, stale } = await resolveInstructionTargets(teamConfig, localConfig); const targetPaths = new Set(targets.map((t) => t.path)); expect(stale.filter((t) => targetPaths.has(t.path))).toEqual([]); - if (paths.claudemd !== undefined) { + // OpenClaw reads no project file of its own, only the shared AGENTS.md (#946). + if (paths.claudemd !== undefined && !(scope === 'project' && tool === 'openclaw')) { expect([...targets.flatMap((t) => t.tools), ...hooks.map((h) => h.tool)]).toContain(tool); } } finally { diff --git a/src/__tests__/joycode.test.ts b/src/__tests__/joycode.test.ts index 8ba39562a..ad5f286ff 100644 --- a/src/__tests__/joycode.test.ts +++ b/src/__tests__/joycode.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest'; import { KNOWN_AGENTS } from '../known-agents.js'; import { ALL_SUPPORTED_TOOLS } from '../resources/agent-format.js'; import { ruleFileExtensionForTool, usesMdcRules } from '../resources/rule-format.js'; -import { TeamaiConfigSchema } from '../types.js'; +import { scopedToolPaths, TeamaiConfigSchema } from '../types.js'; describe('JoyCode support', () => { it('ships the standard .joycode resource paths', () => { @@ -12,7 +12,10 @@ describe('JoyCode support', () => { skills: '.joycode/skills', rules: '.joycode/rules', agents: '.joycode/agents', + // User rules are a block in ~/.joycode/rules.txt (#946). + userScope: { rules: null }, }); + expect(scopedToolPaths(config, { scope: 'user' }).joycode).not.toHaveProperty('rules'); }); it('registers JoyCode for discovery and native agent rendering', () => { diff --git a/src/__tests__/pi-adapter.test.ts b/src/__tests__/pi-adapter.test.ts index d145121a2..0f6ecf4ec 100644 --- a/src/__tests__/pi-adapter.test.ts +++ b/src/__tests__/pi-adapter.test.ts @@ -13,7 +13,8 @@ describe('Pi adapter configuration', () => { claudemd: 'AGENTS.md', userScope: { skills: '.pi/agent/skills', - rules: '.pi/agent/rules', + // User rules are a block in ~/.pi/agent/AGENTS.md (#946). + rules: null, claudemd: '.pi/agent/AGENTS.md', }, }); @@ -21,9 +22,9 @@ describe('Pi adapter configuration', () => { expect(scopedToolPaths(config, { scope: 'project' }).pi).toEqual(config.toolPaths.pi); expect(scopedToolPaths(config, { scope: 'user' }).pi).toMatchObject({ skills: '.pi/agent/skills', - rules: '.pi/agent/rules', claudemd: '.pi/agent/AGENTS.md', }); + expect(scopedToolPaths(config, { scope: 'user' }).pi).not.toHaveProperty('rules'); }); it('registers Pi for discovery and single-repo selection', () => { diff --git a/src/__tests__/pull-namespace-override.test.ts b/src/__tests__/pull-namespace-override.test.ts index 3de53dee3..4151ad329 100644 --- a/src/__tests__/pull-namespace-override.test.ts +++ b/src/__tests__/pull-namespace-override.test.ts @@ -245,6 +245,8 @@ describe('pull: an active namespace item replaces the root item of the same name if (!base) throw new Error('no team config'); vi.mocked(loadTeamConfig).mockResolvedValue({ ...base, + // A team entry that keeps a user rules directory for JoyCode, shared + // with the member's own rules; the default reads none since #946. toolPaths: { ...base.toolPaths, joycode: { skills: '.joycode/skills', rules: '.joycode/rules', agents: '.joycode/agents' } }, }); await fse.ensureDir(path.join(homeDir, '.joycode', 'rules')); @@ -276,6 +278,8 @@ describe('pull: an active namespace item replaces the root item of the same name if (!base) throw new Error('no team config'); vi.mocked(loadTeamConfig).mockResolvedValue({ ...base, + // A team entry that keeps a user rules directory for JoyCode, shared + // with the member's own rules; the default reads none since #946. toolPaths: { ...base.toolPaths, joycode: { skills: '.joycode/skills', rules: '.joycode/rules', agents: '.joycode/agents' } }, }); await fse.ensureDir(path.join(homeDir, '.joycode', 'rules')); @@ -306,6 +310,8 @@ describe('pull: an active namespace item replaces the root item of the same name if (!base) throw new Error('no team config'); vi.mocked(loadTeamConfig).mockResolvedValue({ ...base, + // A team entry that keeps a user rules directory for JoyCode, shared + // with the member's own rules; the default reads none since #946. toolPaths: { ...base.toolPaths, joycode: { skills: '.joycode/skills', rules: '.joycode/rules', agents: '.joycode/agents' } }, }); await fse.ensureDir(path.join(homeDir, '.joycode', 'rules')); diff --git a/src/__tests__/rules.test.ts b/src/__tests__/rules.test.ts index eb56c309b..74ccc2a88 100644 --- a/src/__tests__/rules.test.ts +++ b/src/__tests__/rules.test.ts @@ -618,6 +618,8 @@ scope: 'user', }); it('reclaims delivered copies from a rule directory shared with user rules (JoyCode)', async () => { + // A team entry that keeps a user rules directory for JoyCode; the + // default one reads none in user scope since #946. teamConfig.toolPaths.joycode = { rules: '.joycode/rules' }; await fse.ensureDir(path.join(homeDir, '.joycode', 'rules')); const teamRulesDir = path.join(localConfig.repo.localPath, 'rules'); @@ -1222,7 +1224,8 @@ describe('RulesHandler — .mdc handling (Cursor, JoyCode)', () => { toolPaths: { claude: { skills: '.claude/skills', rules: '.claude/rules', settings: '.claude/settings.json', claudemd: '.claude/CLAUDE.md' }, cursor: { skills: '.cursor/skills', rules: '.cursor/rules', settings: '.cursor/hooks.json' }, - joycode: { skills: '.joycode/skills', rules: '.joycode/rules' }, + // The default shape: in user scope JoyCode reads no rules directory (#946). + joycode: { skills: '.joycode/skills', rules: '.joycode/rules', userScope: { rules: null } }, }, }; @@ -1240,7 +1243,7 @@ describe('RulesHandler — .mdc handling (Cursor, JoyCode)', () => { await fse.remove(tmpDir); }); - it('pull writes .mdc (not .md) with derived frontmatter for Cursor, and .mdc for JoyCode', async () => { + it('pull writes .mdc (not .md) with derived frontmatter for Cursor, and JoyCode\'s user rules to rules.txt', async () => { await fse.writeFile( path.join(repoPath, 'rules', 'ts-style.md'), '---\npaths:\n - "**/*.ts"\n---\n\nUse named exports.', @@ -1254,8 +1257,11 @@ describe('RulesHandler — .mdc handling (Cursor, JoyCode)', () => { const content = await fse.readFile(mdcPath, 'utf-8'); expect(content).toContain('globs: "**/*.ts"'); expect(content).toContain('alwaysApply: false'); - expect(await fse.pathExists(path.join(homeDir, '.joycode/rules/ts-style.mdc'))).toBe(true); + // JoyCode reads its user rules from one text file, not ~/.joycode/rules (#946). + expect(await fse.pathExists(path.join(homeDir, '.joycode/rules/ts-style.mdc'))).toBe(false); expect(await fse.pathExists(path.join(homeDir, '.joycode/rules/ts-style.md'))).toBe(false); + expect(await fse.readFile(path.join(homeDir, '.joycode/rules.txt'), 'utf-8')) + .toContain('Applies to files matching: **/*.ts\nUse named exports.'); // claude still gets a plain .md copy expect(await fse.pathExists(path.join(homeDir, '.claude/rules/ts-style.md'))).toBe(true); }); @@ -1302,8 +1308,10 @@ describe('RulesHandler — .mdc handling (Cursor, JoyCode)', () => { expect(await fse.readFile(path.join(homeDir, '.joycode/rules', file), 'utf-8')) .toBe(`Personal content: ${file}`); } - expect(await fse.readFile(path.join(homeDir, '.joycode/rules/team.mdc'), 'utf-8')) - .toContain('Team rule.'); + // In user scope the team rule reaches JoyCode through rules.txt (#946). + const delivered = scope === 'user' ? '.joycode/rules.txt' : '.joycode/rules/team.mdc'; + expect(await fse.readFile(path.join(homeDir, delivered), 'utf-8')).toContain('Team rule.'); + if (scope === 'user') expect(await fse.pathExists(path.join(homeDir, '.joycode/rules/team.mdc'))).toBe(false); }); it.each(['user', 'project'] as const)('cleans only explicitly removed JoyCode rules in %s scope', async (scope) => { @@ -1311,19 +1319,28 @@ describe('RulesHandler — .mdc handling (Cursor, JoyCode)', () => { if (scope === 'project') localConfig.projectRoot = homeDir; await fse.writeFile(path.join(repoPath, 'rules', 'keep.md'), 'Current team rule.'); await fse.writeFile(path.join(repoPath, 'rules', '.removed'), 'nested/removed\n'); - for (const ext of ['.mdc', '.md']) { - await fse.outputFile(path.join(homeDir, '.joycode/rules/nested', `removed${ext}`), 'Former team rule.'); + // Older layouts left `.md` copies in the project's `.mdc` directory; the + // user-scope ~/.joycode/rules only ever held teamai's `.mdc` copies. + const extensions = scope === 'user' ? ['.mdc'] : ['.mdc', '.md']; + const previous: DeliveredHashes = {}; + for (const ext of extensions) { + const removed = path.join(homeDir, '.joycode/rules/nested', `removed${ext}`); + await fse.outputFile(removed, 'Former team rule.'); + await recordDelivered(previous, removed); } const personalPath = path.join(homeDir, '.joycode/rules/nested/personal.mdc'); await fse.outputFile(personalPath, 'Personal rule.'); - await handler.pullAllRules(teamConfig, localConfig); + // In user scope ~/.joycode/rules is a directory JoyCode no longer gets + // rules in: a removed rule's copy goes on the record of its delivery (#946). + await handler.pullAllRules(teamConfig, localConfig, undefined, [], openLedger(previous)); - for (const ext of ['.mdc', '.md']) { + for (const ext of extensions) { expect(await fse.pathExists(path.join(homeDir, '.joycode/rules/nested', `removed${ext}`))).toBe(false); } expect(await fse.readFile(personalPath, 'utf-8')).toBe('Personal rule.'); - expect(await fse.pathExists(path.join(homeDir, '.joycode/rules/keep.mdc'))).toBe(true); + const kept = scope === 'user' ? '.joycode/rules.txt' : '.joycode/rules/keep.mdc'; + expect(await fse.pathExists(path.join(homeDir, kept))).toBe(true); }); it('detects a genuine edit to a cursor .mdc body as modified', async () => { diff --git a/src/__tests__/types.test.ts b/src/__tests__/types.test.ts index 52fb04230..5404d57c8 100644 --- a/src/__tests__/types.test.ts +++ b/src/__tests__/types.test.ts @@ -128,7 +128,6 @@ describe('TeamaiConfigSchema', () => { expect(result.toolPaths).toHaveProperty('openclaw'); expect(result.toolPaths.openclaw).toEqual({ skills: '.openclaw/skills', - rules: '.openclaw/rules', claudemd: '.openclaw/workspace/AGENTS.md', }); }); diff --git a/src/__tests__/user-rules-files.test.ts b/src/__tests__/user-rules-files.test.ts new file mode 100644 index 000000000..4a46c34fc --- /dev/null +++ b/src/__tests__/user-rules-files.test.ts @@ -0,0 +1,531 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import path from 'node:path'; +import os from 'node:os'; +import fse from 'fs-extra'; + +vi.mock('../config.js', async (importOriginal) => ({ + ...(await importOriginal()), + requireInit: vi.fn(), + loadState: vi.fn().mockResolvedValue({ lastPull: null, lastPullRev: null }), + saveState: vi.fn(), + loadStateForScope: vi.fn(async () => ({})), + saveStateForScope: vi.fn(), + loadLocalConfigForScope: vi.fn(), + loadLocalConfig: vi.fn(), + loadTeamConfig: vi.fn(), + detectProjectConfig: vi.fn().mockResolvedValue(null), + autoDetectInit: vi.fn(), +})); + +vi.mock('../utils/git.js', async (importOriginal) => ({ + ...(await importOriginal()), + pullRepo: vi.fn().mockResolvedValue('already up to date'), + getHeadRev: vi.fn().mockResolvedValue('abc1234'), + createGit: vi.fn(), +})); + +// pull() takes a real ~/.teamai/.sync-lock; parallel workers would race on it. +vi.mock('../update.js', () => ({ + acquireLock: vi.fn().mockResolvedValue(true), + releaseLock: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock('../utils/logger.js', () => ({ + log: { + persist: vi.fn(), + info: vi.fn(), + success: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + dim: vi.fn(), + }, + setStderrOnly: vi.fn(), + spinner: vi.fn(() => ({ + start: vi.fn().mockReturnThis(), + succeed: vi.fn().mockReturnThis(), + fail: vi.fn().mockReturnThis(), + warn: vi.fn().mockReturnThis(), + info: vi.fn().mockReturnThis(), + stop: vi.fn().mockReturnThis(), + })), +})); + +import { RulesHandler, ruleChannelNotes } from '../resources/rules.js'; +import { teamRuleToCursorMdc } from '../resources/cursor-mdc.js'; +import { teamRuleToJoycodeRule } from '../resources/joycode-rule.js'; +import { pull } from '../pull.js'; +import { uninstall } from '../uninstall.js'; +import { buildChecks, resolveDoctorContext } from '../doctor.js'; +import { log } from '../utils/logger.js'; +import { + autoDetectInit, detectProjectConfig, loadLocalConfig, loadLocalConfigForScope, loadStateForScope, loadTeamConfig, saveStateForScope, +} from '../config.js'; +import { TeamaiConfigSchema } from '../types.js'; +import type { LocalConfig, TeamaiConfig } from '../types.js'; + +// What a user-scope pull writes for the two team rules below, markers included (#946). +const BLOCK = '\n' + + '\n\n' + + 'The team codeword is PELICAN-42.\n\n' + + 'Applies to files matching: src/**\nPrefer named exports.\n\n' + + ''; + +const CODEWORD = 'The team codeword is PELICAN-42.\n'; +const SCOPED = '---\npaths:\n - "src/**"\n---\nPrefer named exports.\n'; + +let tmpDir: string; +let homeDir: string; +let projectRoot: string; +let repoPath: string; + +/** Each tool with no rules format, the home dir that says it is installed, and the file only it reads. */ +const TOOLS: ReadonlyArray<{ tool: string; root: string; file: string }> = [ + { tool: 'zcode', root: '.zcode', file: '.zcode/AGENTS.md' }, + { tool: 'dsh', root: '.dsh', file: '.dsh/AGENTS.md' }, + { tool: 'openclaw', root: '.openclaw/workspace', file: '.openclaw/workspace/AGENTS.md' }, + { tool: 'pi', root: '.pi/agent', file: '.pi/agent/AGENTS.md' }, + { tool: 'joycode', root: '.joycode', file: '.joycode/rules.txt' }, +]; + +const teamConfig = (): TeamaiConfig => TeamaiConfigSchema.parse({ team: 'test', repo: 'https://example.invalid/x/team.git' }); + +function config(scope: 'user' | 'project', enabledAgents: string[]): LocalConfig { + return { + repo: { localPath: repoPath, remote: 'https://example.invalid/x/team.git' }, + username: 'u', + updatePolicy: 'auto', + additionalRoles: [], + scope, + ...(scope === 'project' ? { projectRoot } : {}), + enabledAgents, + } as LocalConfig; +} + +const home = (rel: string) => path.join(homeDir, rel); + +beforeEach(async () => { + tmpDir = await fse.mkdtemp(path.join(os.tmpdir(), 'teamai-user-rules-')); + homeDir = path.join(tmpDir, 'home'); + projectRoot = path.join(tmpDir, 'project'); + repoPath = path.join(tmpDir, 'team-repo'); + await fse.ensureDir(homeDir); + await fse.ensureDir(projectRoot); + await fse.ensureDir(path.join(repoPath, 'rules')); + await fse.writeFile(path.join(repoPath, 'rules', 'codeword.md'), CODEWORD); + await fse.writeFile(path.join(repoPath, 'rules', 'scoped.md'), SCOPED); + vi.stubEnv('HOME', homeDir); + for (const name of ['DSH_HOME', 'OPENCLAW_STATE_DIR', 'OPENCLAW_PROFILE', 'OPENCLAW_CONFIG_PATH', 'OPENCLAW_WORKSPACE_DIR']) { + vi.stubEnv(name, ''); + } + vi.mocked(log.warn).mockClear(); +}); + +afterEach(async () => { + vi.unstubAllEnvs(); + await fse.remove(tmpDir); +}); + +describe('a user-scope rules sync puts the team rules in a file only the tool reads (#946)', () => { + it.each(TOOLS)('writes the team-rules block for $tool to ~/$file, beside the member\'s text', async ({ tool, root, file }) => { + await fse.ensureDir(home(root)); + await fse.writeFile(home(file), '# My notes\n'); + + await new RulesHandler().pullAllRules(teamConfig(), config('user', [tool])); + + expect(await fse.readFile(home(file), 'utf8')).toBe(`# My notes\n\n${BLOCK}\n`); + }); + + it.each(TOOLS)('writes no rules directory for $tool in user scope', async ({ tool, root }) => { + await fse.ensureDir(home(root)); + + await new RulesHandler().pullAllRules(teamConfig(), config('user', [tool])); + + for (const dir of ['.openclaw/rules', '.pi/agent/rules', '.pi/rules', '.joycode/rules']) { + expect(await fse.pathExists(home(dir))).toBe(false); + } + }); + + it.each(TOOLS)('a project-scope sync leaves ~/$file unchanged for $tool', async ({ tool, root, file }) => { + await fse.ensureDir(home(root)); + await fse.writeFile(home(file), '# My notes\n'); + + await new RulesHandler().pullAllRules(teamConfig(), config('project', [tool])); + + expect(await fse.readFile(home(file), 'utf8')).toBe('# My notes\n'); + }); + + it.each(TOOLS)('creates nothing for $tool when it is not installed', async ({ tool, file }) => { + await new RulesHandler().pullAllRules(teamConfig(), config('user', [tool])); + + expect(await fse.pathExists(home(file))).toBe(false); + }); + + it.each(TOOLS)('removes the block, and a file that held only it, once $tool is excluded', async ({ tool, root, file }) => { + await fse.ensureDir(home(root)); + await new RulesHandler().pullAllRules(teamConfig(), config('user', [tool])); + expect(await fse.readFile(home(file), 'utf8')).toBe(`${BLOCK}\n`); + + await new RulesHandler().pullAllRules(teamConfig(), config('user', ['claude'])); + + expect(await fse.pathExists(home(file))).toBe(false); + }); + + it.each(TOOLS)('removes the block once the member disables $tool', async ({ tool, root, file }) => { + await fse.ensureDir(home(root)); + await new RulesHandler().pullAllRules(teamConfig(), config('user', [tool])); + + await new RulesHandler().pullAllRules(teamConfig(), { ...config('user', [tool]), disabledAgents: [tool] } as LocalConfig); + + expect(await fse.pathExists(home(file))).toBe(false); + }); + + it('writes DeepSeek Harness\'s block to $DSH_HOME/AGENTS.md', async () => { + // ~/.dsh says dsh is installed, as for its skills and hooks. + await fse.ensureDir(home('.dsh')); + const dshHome = path.join(tmpDir, 'dsh-home'); + await fse.ensureDir(dshHome); + vi.stubEnv('DSH_HOME', dshHome); + + await new RulesHandler().pullAllRules(teamConfig(), config('user', ['dsh'])); + + expect(await fse.readFile(path.join(dshHome, 'AGENTS.md'), 'utf8')).toBe(`${BLOCK}\n`); + expect(await fse.pathExists(home('.dsh/AGENTS.md'))).toBe(false); + }); + + it('writes the other tools\' files when one cannot be written, and names that one', async () => { + await fse.ensureDir(home('.zcode/AGENTS.md')); // a directory where the file should be + await fse.ensureDir(home('.pi/agent')); + + await new RulesHandler().pullAllRules(teamConfig(), config('user', ['zcode', 'pi'])); + + expect(await fse.readFile(home('.pi/agent/AGENTS.md'), 'utf8')).toBe(`${BLOCK}\n`); + const warnings = vi.mocked(log.warn).mock.calls.map(([message]) => String(message)); + expect(warnings.some((m) => m.startsWith(`Could not write the team-rules block to ${home('.zcode/AGENTS.md')}`))).toBe(true); + }); + + it('writes nothing for DeepSeek Harness when only $DSH_HOME exists, as its skills and hooks do', async () => { + const dshHome = path.join(tmpDir, 'dsh-home'); + await fse.ensureDir(dshHome); + vi.stubEnv('DSH_HOME', dshHome); + + await new RulesHandler().pullAllRules(teamConfig(), config('user', ['dsh'])); + + expect(await fse.pathExists(path.join(dshHome, 'AGENTS.md'))).toBe(false); + }); + + describe('OpenClaw reads the workspace AGENTS.md its hooks resolve', () => { + it('follows agents.defaults.workspace in openclaw.json ahead of OPENCLAW_WORKSPACE_DIR', async () => { + const configured = path.join(tmpDir, 'configured-ws'); + const fromEnv = path.join(tmpDir, 'env-ws'); + await fse.ensureDir(configured); + await fse.ensureDir(fromEnv); + await fse.outputJson(home('.openclaw/openclaw.json'), { agents: { defaults: { workspace: configured } } }); + vi.stubEnv('OPENCLAW_WORKSPACE_DIR', fromEnv); + + await new RulesHandler().pullAllRules(teamConfig(), config('user', ['openclaw'])); + + expect(await fse.readFile(path.join(configured, 'AGENTS.md'), 'utf8')).toBe(`${BLOCK}\n`); + expect(await fse.pathExists(path.join(fromEnv, 'AGENTS.md'))).toBe(false); + }); + + it('follows OPENCLAW_WORKSPACE_DIR', async () => { + const fromEnv = path.join(tmpDir, 'env-ws'); + await fse.ensureDir(fromEnv); + await fse.ensureDir(home('.openclaw/workspace')); + vi.stubEnv('OPENCLAW_WORKSPACE_DIR', fromEnv); + + await new RulesHandler().pullAllRules(teamConfig(), config('user', ['openclaw'])); + + expect(await fse.readFile(path.join(fromEnv, 'AGENTS.md'), 'utf8')).toBe(`${BLOCK}\n`); + expect(await fse.pathExists(home('.openclaw/workspace/AGENTS.md'))).toBe(false); + }); + + it('follows the profile\'s state dir', async () => { + vi.stubEnv('OPENCLAW_PROFILE', 'work'); + await fse.ensureDir(home('.openclaw-work/workspace')); + await fse.ensureDir(home('.openclaw/workspace')); + + await new RulesHandler().pullAllRules(teamConfig(), config('user', ['openclaw'])); + + expect(await fse.readFile(home('.openclaw-work/workspace/AGENTS.md'), 'utf8')).toBe(`${BLOCK}\n`); + expect(await fse.pathExists(home('.openclaw/workspace/AGENTS.md'))).toBe(false); + }); + + it('follows OPENCLAW_STATE_DIR', async () => { + const stateDir = path.join(tmpDir, 'oc-state'); + await fse.ensureDir(path.join(stateDir, 'workspace')); + vi.stubEnv('OPENCLAW_STATE_DIR', stateDir); + + await new RulesHandler().pullAllRules(teamConfig(), config('user', ['openclaw'])); + + expect(await fse.readFile(path.join(stateDir, 'workspace', 'AGENTS.md'), 'utf8')).toBe(`${BLOCK}\n`); + }); + }); +}); + +const LEGACY: ReadonlyArray<{ tool: string; scope: 'user' | 'project'; root: string; dir: string; copy: (raw: string) => string; ext: string }> = [ + { tool: 'openclaw', scope: 'user', root: '.openclaw/workspace', dir: '.openclaw/rules', copy: (raw) => raw, ext: '.md' }, + { tool: 'openclaw', scope: 'project', root: '.openclaw', dir: '.openclaw/rules', copy: (raw) => raw, ext: '.md' }, + { tool: 'pi', scope: 'user', root: '.pi/agent', dir: '.pi/agent/rules', copy: (raw) => raw, ext: '.md' }, + { tool: 'joycode', scope: 'user', root: '.joycode', dir: '.joycode/rules', copy: teamRuleToCursorMdc, ext: '.mdc' }, + // A release between JoyCode's own render (#946) and this one rewrote user copies in it. + { tool: 'joycode', scope: 'user', root: '.joycode', dir: '.joycode/rules', copy: teamRuleToJoycodeRule, ext: '.mdc' }, +]; +const base = (scope: 'user' | 'project') => (scope === 'user' ? homeDir : projectRoot); + +describe('pull reclaims the rule copies left where these tools never read them (#946)', () => { + it.each(LEGACY)('$tool ($scope): removes an unedited copy in $dir and keeps an edited one, named', async ({ tool, scope, root, dir, copy, ext }) => { + await fse.ensureDir(path.join(base(scope), root)); + const unedited = path.join(base(scope), dir, `scoped${ext}`); + const edited = path.join(base(scope), dir, `codeword${ext}`); + await fse.outputFile(unedited, copy(SCOPED)); + await fse.outputFile(edited, 'The team codeword is PELICAN-42. My own addition.\n'); + + await new RulesHandler().pullAllRules(teamConfig(), config(scope, [tool])); + + expect(await fse.pathExists(unedited)).toBe(false); + expect(await fse.readFile(edited, 'utf8')).toBe('The team codeword is PELICAN-42. My own addition.\n'); + const warnings = vi.mocked(log.warn).mock.calls.map(([message]) => String(message)); + expect(warnings.some((message) => message.includes(edited))).toBe(true); + }); +}); + +describe('a pull at an unchanged team revision after a CLI upgrade (#946)', () => { + let saved: Record; + + beforeEach(() => { + saved = {}; + vi.mocked(saveStateForScope).mockImplementation(async (state) => { + saved = structuredClone(state) as Record; + }); + vi.mocked(loadStateForScope).mockImplementation(async () => structuredClone(saved) as never); + vi.mocked(loadTeamConfig).mockResolvedValue(teamConfig()); + }); + + afterEach(() => { + vi.mocked(saveStateForScope).mockReset(); + vi.mocked(loadStateForScope).mockImplementation(async () => ({}) as never); + vi.mocked(loadLocalConfigForScope).mockReset(); + }); + + it.each(TOOLS)('writes $tool\'s missing block and reclaims nothing it should keep', async ({ tool, root, file }) => { + await fse.ensureDir(home(root)); + vi.mocked(loadLocalConfigForScope).mockImplementation(async (scope) => (scope === 'user' ? config('user', [tool]) : null) as never); + await pull({}); + // What an older CLI left at this revision: no block. + await fse.writeFile(home(file), 'My own notes.\n'); + vi.mocked(log.success).mockClear(); + + await pull({}); + + expect(vi.mocked(log.success).mock.calls.some(([message]) => String(message).includes('Already synced at abc1234'))).toBe(true); + expect(await fse.readFile(home(file), 'utf8')).toBe(`My own notes.\n\n${BLOCK}\n`); + }); + + it.each(LEGACY.filter(({ scope }) => scope === 'user'))('reclaims $tool\'s unedited copy in ~/$dir', async ({ tool, root, dir, copy, ext }) => { + await fse.ensureDir(home(root)); + vi.mocked(loadLocalConfigForScope).mockImplementation(async (scope) => (scope === 'user' ? config('user', [tool]) : null) as never); + await pull({}); + // What an older CLI left at this revision. + const unedited = home(path.join(dir, `scoped${ext}`)); + await fse.outputFile(unedited, copy(SCOPED)); + vi.mocked(log.success).mockClear(); + + await pull({}); + + expect(vi.mocked(log.success).mock.calls.some(([message]) => String(message).includes('Already synced at abc1234'))).toBe(true); + expect(await fse.pathExists(unedited)).toBe(false); + }); +}); + +describe('uninstall removes the team-rules block (#946)', () => { + afterEach(() => { + vi.mocked(autoDetectInit).mockReset(); + }); + + it.each(TOOLS)('uninstall --agent $tool removes the block, and the file teamai created for it alone', async ({ tool, root, file }) => { + await fse.ensureDir(home(root)); + const localConfig = config('user', [tool, 'claude']); + await new RulesHandler().pullAllRules(teamConfig(), localConfig); + expect(await fse.pathExists(home(file))).toBe(true); + vi.mocked(autoDetectInit).mockResolvedValue({ localConfig, teamConfig: teamConfig() } as never); + + await uninstall({ force: true, agent: tool }); + + expect(await fse.pathExists(home(file))).toBe(false); + }); + + it.each(TOOLS)('uninstall --agent $tool keeps the member\'s text in ~/$file', async ({ tool, root, file }) => { + await fse.ensureDir(home(root)); + await fse.writeFile(home(file), '# My notes\n'); + const localConfig = config('user', [tool, 'claude']); + await new RulesHandler().pullAllRules(teamConfig(), localConfig); + vi.mocked(autoDetectInit).mockResolvedValue({ localConfig, teamConfig: teamConfig() } as never); + + await uninstall({ force: true, agent: tool }); + + expect(await fse.readFile(home(file), 'utf8')).toBe('# My notes\n'); + }); +}); + +describe('uninstall reclaims the same legacy copies (#946)', () => { + afterEach(() => { + vi.mocked(autoDetectInit).mockReset(); + }); + + it.each(LEGACY)('$tool ($scope): uninstall --agent removes an unedited copy in $dir and keeps an edited one, named', async ({ tool, scope, root, dir, copy, ext }) => { + await fse.ensureDir(path.join(base(scope), root)); + const unedited = path.join(base(scope), dir, `scoped${ext}`); + const edited = path.join(base(scope), dir, `codeword${ext}`); + await fse.outputFile(unedited, copy(SCOPED)); + await fse.outputFile(edited, 'The team codeword is PELICAN-42. My own addition.\n'); + const localConfig = config(scope, [tool, 'claude']); + vi.mocked(autoDetectInit).mockResolvedValue({ localConfig, teamConfig: teamConfig() } as never); + + await uninstall({ force: true, agent: tool }); + + expect(await fse.pathExists(unedited)).toBe(false); + expect(await fse.pathExists(edited)).toBe(true); + const warnings = vi.mocked(log.warn).mock.calls.map(([message]) => String(message)); + expect(warnings.some((message) => message.includes(edited))).toBe(true); + }); +}); + +describe('doctor checks the block in the file each tool reads (#946)', () => { + const NAMES: Record = { + zcode: 'Team rules are inlined in ZCode AGENTS.md', + dsh: 'Team rules are inlined in DeepSeek Harness AGENTS.md', + openclaw: 'Team rules are inlined in OpenClaw workspace AGENTS.md', + pi: 'Team rules are inlined in Pi AGENTS.md', + joycode: 'Team rules are inlined in JoyCode rules.txt', + }; + + async function check(tool: string) { + vi.mocked(loadLocalConfig).mockResolvedValue(config('user', [tool])); + vi.mocked(loadTeamConfig).mockResolvedValue(teamConfig()); + const ctx = await resolveDoctorContext(); + if (!ctx) throw new Error('expected a resolved doctor context'); + return (await buildChecks(ctx)).find((c) => c.name === NAMES[tool]); + } + + it.each(TOOLS)('passes for $tool after a pull, and fails naming ~/$file after a hand edit', async ({ tool, root, file }) => { + await fse.ensureDir(home(root)); + await new RulesHandler().pullAllRules(teamConfig(), config('user', [tool])); + + const passing = await check(tool); + expect(passing).toBeDefined(); + expect(await passing!.check()).toBe(true); + + await fse.writeFile(home(file), (await fse.readFile(home(file), 'utf8')).replace('PELICAN-42', 'PELICAN-43')); + const failing = (await check(tool))!; + expect(await failing.check()).toBe(false); + expect(failing.fix).toContain(home(file)); + expect(failing.fix).toContain('Run `teamai pull` to rewrite it.'); + expect(failing.fix).not.toContain('--force'); + }); + + it.each(TOOLS)('fails for $tool when ~/$file carries no block', async ({ tool, root, file }) => { + await fse.ensureDir(home(root)); + await fse.writeFile(home(file), '# My notes\n'); + + const failing = (await check(tool))!; + expect(await failing.check()).toBe(false); + expect(failing.fix).toContain(home(file)); + expect(failing.fix).toContain('Run `teamai pull` to restore it.'); + }); + + it.each(TOOLS)('asks nothing of $tool when it is not installed', async ({ tool }) => { + expect(await check(tool)).toBeUndefined(); + }); + + it('asks nothing of these tools in project scope', async () => { + for (const { root } of TOOLS) await fse.ensureDir(home(root)); + vi.mocked(loadLocalConfig).mockResolvedValue(config('project', TOOLS.map(({ tool }) => tool))); + vi.mocked(detectProjectConfig).mockResolvedValue(config('project', TOOLS.map(({ tool }) => tool))); + vi.mocked(loadTeamConfig).mockResolvedValue(teamConfig()); + try { + const ctx = await resolveDoctorContext(); + const names = (await buildChecks(ctx!)).map((c) => c.name); + for (const name of Object.values(NAMES)) expect(names).not.toContain(name); + } finally { + vi.mocked(detectProjectConfig).mockResolvedValue(null); + } + }); +}); + +describe('OpenClaw\'s instruction blocks follow the same workspace, in user scope only (#946)', () => { + beforeEach(async () => { + await fse.writeFile(path.join(repoPath, 'culture.md'), '---\ncompany:\n name: Acme\n---\n\nBe kind to teammates.\n'); + vi.mocked(loadTeamConfig).mockResolvedValue(teamConfig()); + }); + + afterEach(() => { + vi.mocked(loadLocalConfigForScope).mockReset(); + vi.mocked(detectProjectConfig).mockResolvedValue(null); + }); + + it('a user-scope pull writes culture and the team rules to the profile\'s workspace AGENTS.md, and leaves the default one, which the default profile reads', async () => { + vi.stubEnv('OPENCLAW_PROFILE', 'work'); + await fse.ensureDir(home('.openclaw-work/workspace')); + await fse.outputFile(home('.openclaw/workspace/AGENTS.md'), '# Default workspace\n\n\nold\n\n'); + vi.mocked(loadLocalConfigForScope).mockImplementation(async (scope) => (scope === 'user' ? config('user', ['openclaw']) : null) as never); + + await pull({}); + + const content = await fse.readFile(home('.openclaw-work/workspace/AGENTS.md'), 'utf8'); + expect(content).toContain('Be kind to teammates.'); + expect(content).toContain(BLOCK); + expect(await fse.readFile(home('.openclaw/workspace/AGENTS.md'), 'utf8')) + .toBe('# Default workspace\n\n\nold\n\n'); + }); + + it('a user-scope pull keeps Pi\'s team-rules block beside the instruction blocks, each once, over two pulls', async () => { + await fse.ensureDir(home('.pi/agent')); + vi.mocked(loadLocalConfigForScope).mockImplementation(async (scope) => (scope === 'user' ? config('user', ['pi']) : null) as never); + + await pull({ force: true }); + await pull({ force: true }); + + const content = await fse.readFile(home('.pi/agent/AGENTS.md'), 'utf8'); + expect(content.split('').length - 1).toBe(1); + expect(content.split('').length - 1).toBe(1); + expect(content).toContain('Be kind to teammates.'); + expect(content).toContain(BLOCK); + }); + + it('a project-scope pull writes no blocks into the project for OpenClaw, and strips an older release\'s', async () => { + await fse.ensureDir(home('.openclaw/workspace')); + const projectFile = path.join(projectRoot, '.openclaw', 'workspace', 'AGENTS.md'); + await fse.outputFile(projectFile, '# Project notes\n\n\nold\n\n'); + vi.mocked(detectProjectConfig).mockResolvedValue(config('project', ['openclaw'])); + + await pull({}); + + expect(await fse.readFile(projectFile, 'utf8')).toBe('# Project notes\n'); + expect(await fse.pathExists(path.join(projectRoot, 'AGENTS.md'))).toBe(false); + expect(await fse.pathExists(home('.openclaw/workspace/AGENTS.md'))).toBe(false); + }); +}); + +describe('init and doctor say why OpenClaw gets no project rules (#946)', () => { + it('notes it in project scope while OpenClaw is installed', async () => { + await fse.ensureDir(home('.openclaw/workspace')); + + const notes = await ruleChannelNotes(config('project', ['openclaw'])); + + expect(notes.some((note) => note.startsWith('OpenClaw gets no project rules') && note.includes('workspace AGENTS.md'))).toBe(true); + }); + + it.each([ + ['in user scope', 'user' as const, ['openclaw'], true], + ['when OpenClaw is excluded', 'project' as const, ['claude'], true], + ['when OpenClaw is not installed', 'project' as const, ['openclaw'], false], + ])('says nothing %s', async (_label, scope, enabled, installed) => { + if (installed) await fse.ensureDir(home('.openclaw/workspace')); + + const notes = await ruleChannelNotes(config(scope, enabled)); + + expect(notes.some((note) => note.startsWith('OpenClaw'))).toBe(false); + }); +}); diff --git a/src/doctor-delivery.ts b/src/doctor-delivery.ts index b257df3b5..2e0a88a81 100644 --- a/src/doctor-delivery.ts +++ b/src/doctor-delivery.ts @@ -267,10 +267,10 @@ export async function buildRulesDeliveryChecks(ctx: DoctorContext): Promise { +async function buildUserRulesFileChecks(ctx: DoctorContext, items: ResourceItem[]): Promise { const { localConfig, teamConfig } = ctx; if (!teamConfig || localConfig.scope !== 'user') return []; const { teamRulesBlock } = await import('./resources/rules.js'); - const { getsRulesFromSessionHook, instructionFileInstallProbe, writesInstructionBlock } = await import('./resources/rule-format.js'); - const { isToolInstalledForConfig } = await import('./resources/base.js'); - const { TEAMAI_TEAM_RULES_START, TEAMAI_TEAM_RULES_END, resolveToolBaseDir, scopedToolPaths } = await import('./types.js'); + const { getsRulesFromSessionHook } = await import('./resources/rule-format.js'); + const { userRulesFile } = await import('./instruction-targets.js'); + const { TEAMAI_TEAM_RULES_START, TEAMAI_TEAM_RULES_END, scopedToolPaths } = await import('./types.js'); const expected = await teamRulesBlock(items); const checks: Check[] = []; for (const [tool, toolPath] of Object.entries(scopedToolPaths(teamConfig, localConfig))) { - if (!getsRulesFromSessionHook(tool) || isAgentExcluded(localConfig, tool)) continue; - const probe = instructionFileInstallProbe(tool, toolPath); - if (probe !== undefined && !await isToolInstalledForConfig(tool, probe, localConfig)) continue; - const name = tool === 'codex' - ? 'Team rules are inlined in Codex AGENTS.md' - : `Team rules are inlined in Codex AGENTS.md (${tool})`; - if (!writesInstructionBlock(tool, toolPath, 'team-rules')) { - // A team `toolPaths` entry replaces the default one whole, so an entry - // written before #938 leaves this tool nowhere to read user rules from. + if (isAgentExcluded(localConfig, tool)) continue; + const target = await userRulesFile(tool, toolPath, localConfig); + if (!target?.installed) continue; + const codexFamily = getsRulesFromSessionHook(tool); + const name = codexFamily && tool !== 'codex' + ? `Team rules are inlined in ${target.label} (${tool})` + : `Team rules are inlined in ${target.label}`; + const { file } = target; + if (file === undefined) { + // A team `toolPaths` entry replaces the default one whole, so a Codex + // entry written before #938 leaves it nowhere to read user rules from. // One with no `rules` path delivers no rules to it on purpose; with no // team rules it misses none. - if (items.length === 0 || !toolPath.rules) continue; + if (!codexFamily || items.length === 0 || !toolPath.rules) continue; checks.push({ name, source: 'local', @@ -462,28 +467,27 @@ async function buildCodexUserRulesChecks(ctx: DoctorContext, items: ResourceItem }); continue; } - const file = path.join(resolveToolBaseDir(tool, localConfig), toolPath.claudemd); const content = await readFileSafe(file); const start = content?.indexOf(TEAMAI_TEAM_RULES_START) ?? -1; const end = content?.indexOf(TEAMAI_TEAM_RULES_END) ?? -1; const delivered = content !== null && start !== -1 && end > start ? content.slice(start, end + TEAMAI_TEAM_RULES_END.length) : null; - // Codex reads AGENTS.override.md instead of AGENTS.md in the same - // directory, so a current block there is never seen. An empty or - // whitespace-only override shadows it too (checked with `codex exec`). - const override = path.join(path.dirname(file), 'AGENTS.override.md'); const problems: string[] = []; // With no rule body to inline (`expected === null`), pull writes no block. if (delivered === null && expected !== null) { problems.push(`${file} carries no team-rules block, so ${tool} reads none of the team ` + 'rules. Run `teamai pull` to restore it.'); } else if (delivered !== expected) { - problems.push(`The team-rules block in ${file} is not what the team rules inline to: Codex reads ` - + 'standing instructions from this file rather than a rules directory, so a stale block ' + problems.push(`The team-rules block in ${file} is not what the team rules inline to: ${tool} reads ` + + 'the team rules from this file rather than a rules directory, so a stale block ' + 'is a stale rule set. Run `teamai pull` to rewrite it.'); } - if (expected !== null && await isReadableFile(override)) { + // Codex reads AGENTS.override.md instead of AGENTS.md in the same + // directory, so a current block there is never seen. An empty or + // whitespace-only override shadows it too (checked with `codex exec`). + const override = path.join(path.dirname(file), 'AGENTS.override.md'); + if (codexFamily && expected !== null && await isReadableFile(override)) { problems.push(`${override} exists, so Codex reads it instead of ${file} and never sees the ` + 'team rules. Move its content into AGENTS.md, or delete it.'); } @@ -1332,7 +1336,7 @@ export async function buildInstructionDeliveryChecks(ctx: DoctorContext): Promis } const opencodePaths = scopedToolPaths(teamConfig, localConfig).opencode; - const opencodeFile = opencodePaths && instructionTargetPath('opencode', opencodePaths, localConfig); + const opencodeFile = opencodePaths && await instructionTargetPath('opencode', opencodePaths, localConfig); // Only a file holding the blocks needs listing; pull registers it once it writes them. if (opencodeFile && targets.some((t) => t.path === opencodeFile) && await holdsInstructionBlocks(opencodeFile)) { const { config, entry } = opencodeContextReference(opencodeFile, localConfig.scope, resolveToolBaseDir('opencode', localConfig)); diff --git a/src/dsh-hooks.ts b/src/dsh-hooks.ts index 92fefe5b6..a27ea733c 100644 --- a/src/dsh-hooks.ts +++ b/src/dsh-hooks.ts @@ -11,7 +11,7 @@ import path from 'node:path'; import { reconcileHooks } from './hooks.js'; import type { BuiltinHookOverride } from './builtin-hooks.js'; import type { HookDef } from './types.js'; -import { getUserHome } from './utils/home.js'; +import { expandHome, getUserHome } from './utils/home.js'; import { pathExists, remove, writeIfChanged } from './utils/fs.js'; import { log } from './utils/logger.js'; @@ -20,6 +20,15 @@ export const DSH_PATCH_FILE = 'cordis.patch.yml'; export const DSH_HOOK_PLUGIN_ID = 'teamai-hooks-claude-code'; export const DSH_HOOK_PLUGIN_PACKAGE = '@deepseek-ai/dsh-hooks-claude-code'; +/** + * DeepSeek Harness's own home, where it reads the user `AGENTS.md`: + * `DSH_HOME`, else `~/.dsh` (dsh `config.ts` `dshHome`). + */ +export function resolveDshHome(): string { + const configured = process.env.DSH_HOME?.trim(); + return configured ? path.resolve(expandHome(configured)) : path.join(getUserHome(), '.dsh'); +} + /** The TeamAI-managed DSH bridge directory under the resolved user home. */ export function resolveDshHooksDir(): string { return path.join(getUserHome(), '.teamai', 'dsh'); diff --git a/src/instruction-targets.ts b/src/instruction-targets.ts index 83892970d..62fa55aa4 100644 --- a/src/instruction-targets.ts +++ b/src/instruction-targets.ts @@ -39,13 +39,21 @@ import { type ToolPaths = TeamaiConfig['toolPaths'][string]; +/** + * A file of a tool, relative to the tool's base dir for the scope + * (`resolveToolBaseDir`) or absolute; undefined when it has none. + */ +type ToolFile = (paths: ToolPaths) => string | undefined | Promise; + interface TargetEntry { + /** The file this tool reads the blocks from in this scope. */ + readonly file: ToolFile; /** - * The file this tool reads the blocks from, relative to the tool's base dir - * for the scope (`resolveToolBaseDir`) or absolute. Undefined when the tool - * takes no file in this scope. + * For a tool with no rules format, in user scope: the file it reads the + * team rules from (#938, #946), the blocks' `file` unless `file` is given, + * and how doctor names it. */ - readonly file: (paths: ToolPaths) => string | undefined; + readonly teamRules?: { readonly label: string; readonly file?: ToolFile }; /** The tool gets this scope's blocks from teamai's session hook or extension instead of a file. */ readonly hook?: boolean; /** The most characters the hook channel takes; the tool drops a larger text whole. */ @@ -66,6 +74,25 @@ interface TargetEntry { /** The tool's `claudemd` path from the team's `toolPaths` (honors `toolRoots`). */ const configured = (paths: ToolPaths): string | undefined => paths.claudemd; +/** + * OpenClaw's workspace AGENTS.md, in the workspace its hooks resolve + * (`resolveOpenclawWorkspaceDir`); none when no workspace resolves, which is + * also what says OpenClaw is not installed here (`isInstructionToolInstalled`). + */ +const openclawWorkspace = async (): Promise => { + const { resolveOpenclawWorkspaceDir } = await import('./openclaw-hooks.js'); + const workspace = await resolveOpenclawWorkspaceDir(); + return workspace === null ? undefined : path.join(workspace, 'AGENTS.md'); +}; + +/** DeepSeek Harness reads `$DSH_HOME/AGENTS.md` (`~/.dsh` by default) in its first request. */ +const dshAgentsMd = async (): Promise => { + const { resolveDshHome } = await import('./dsh-hooks.js'); + return path.join(resolveDshHome(), 'AGENTS.md'); +}; + +const codexUser: TargetEntry = { file: configured, teamRules: { label: 'Codex AGENTS.md' }, retired: [] }; + /** * teamai's own always-applied file in the tool's rules directory. A team's * `toolPaths` entry without `rules` keeps its configured `claudemd`, which is @@ -105,11 +132,24 @@ const USER_TARGETS: Readonly> = { // RULES.md is an always-applied rule beside OMP's single user context file, // which ~/.omp/agent/AGENTS.md would take from ~/.agents/AGENTS.md. omp: { file: () => '.omp/agent/RULES.md', retired: ['.omp/agent/AGENTS.md'] }, - pi: { file: configured, retired: [] }, + // Pi reads no user rules directory: the team rules sit beside the blocks. + pi: { file: configured, teamRules: { label: 'Pi AGENTS.md' }, retired: [] }, + // $CODEX_HOME/AGENTS.md (a `toolRoots` entry moves the claudemd path). + codex: codexUser, + 'codex-internal': codexUser, + tcodex: codexUser, + // ZCode reads ~/.zcode/AGENTS.md as its user context; teamai writes it only the team rules. + zcode: { file: () => undefined, teamRules: { label: 'ZCode AGENTS.md', file: () => '.zcode/AGENTS.md' }, retired: [] }, + dsh: { file: () => undefined, teamRules: { label: 'DeepSeek Harness AGENTS.md', file: dshAgentsMd }, retired: [] }, + // JoyCode reads its user rules from one plain text file. + joycode: { file: () => undefined, teamRules: { label: 'JoyCode rules.txt', file: () => '.joycode/rules.txt' }, retired: [] }, // WorkBuddy reads user rules from ~/.workbuddy/rules; nothing else reads them. workbuddy: { file: contextRule('.md'), header: ALWAYS_APPLY, owned: true, retired: ['AGENTS.md'] }, codebuddy: { file: configured, retired: [] }, - openclaw: { file: configured, retired: [] }, + // A profile or OPENCLAW_WORKSPACE_DIR moves the workspace off the default + // path. The default workspace is not retired: OpenClaw's default profile + // still reads it. + openclaw: { file: openclawWorkspace, teamRules: { label: 'OpenClaw workspace AGENTS.md' }, retired: [] }, // Registered in the user opencode.json `instructions`; AGENTS.md beside it stays the member's. opencode: { file: () => '.config/opencode/teamai-context.md', owned: true, retired: [] }, }; @@ -135,7 +175,9 @@ const PROJECT_TARGETS: Readonly> = { pi: { file: () => undefined, hook: true, retired: ['AGENTS.md'] }, workbuddy: { file: codebuddyProjectRule, header: ALWAYS_APPLY, owned: true, retired: ['AGENTS.md'] }, codebuddy: { file: codebuddyProjectRule, header: ALWAYS_APPLY, owned: true, retired: ['.codebuddy/CODEBUDDY.md'] }, - openclaw: { file: configured, retired: [] }, + // OpenClaw's only project file is the shared AGENTS.md; it reads no + // project .openclaw/workspace (#946). + openclaw: { file: () => undefined, retired: ['.openclaw/workspace/AGENTS.md'] }, // Codex reads no project file only it reads; its session-start and // subagent-start hooks add the blocks (#938, #940). codex: codexHook, @@ -215,7 +257,7 @@ export interface InstructionBlocks { * dir or absolute (see `TargetEntry.file`). Tools absent from the table keep * their configured `claudemd` path. */ -export function instructionTargetFile(tool: string, paths: ToolPaths, scope: Scope): string | undefined { +export async function instructionTargetFile(tool: string, paths: ToolPaths, scope: Scope): Promise { return (entryFor(tool, scope)?.file ?? configured)(paths); } @@ -224,10 +266,10 @@ export function instructionTargetFile(tool: string, paths: ToolPaths, scope: Sco * `tool`'s blocks to in `scope`: the defaults, and the `claudemd` the team's * `toolPaths` gives a tool whose target moved off it. */ -export function retiredInstructionFiles(tool: string, paths: ToolPaths, scope: Scope): readonly string[] { +export async function retiredInstructionFiles(tool: string, paths: ToolPaths, scope: Scope): Promise { const entry = entryFor(tool, scope); if (!entry) return []; - const previous = paths.claudemd === instructionTargetFile(tool, paths, scope) ? undefined : paths.claudemd; + const previous = paths.claudemd === await instructionTargetFile(tool, paths, scope) ? undefined : paths.claudemd; return previous === undefined || entry.retired.includes(previous) ? entry.retired : [...entry.retired, previous]; } @@ -386,26 +428,58 @@ function managedBlockBody(block: string): string { } /** Absolute instruction file of `tool` in the active scope, or undefined when it takes none. */ -export function instructionTargetPath( +export async function instructionTargetPath( tool: string, paths: ToolPaths, localConfig: LocalConfig, -): string | undefined { - const file = instructionTargetFile(tool, paths, localConfig.scope); +): Promise { + const file = await instructionTargetFile(tool, paths, localConfig.scope); return file === undefined ? undefined : path.resolve(resolveToolBaseDir(tool, localConfig), file); } +/** The file a tool with no rules format reads the team rules from in user scope (`userRulesFile`). */ +export interface UserRulesFile { + /** Absolute; undefined when the tool has none here (the team's `toolPaths` gives it no `claudemd`, or no OpenClaw workspace resolves). */ + readonly file: string | undefined; + /** Whether the tool is installed, probed the way its instruction blocks are, so pull writes the block. */ + readonly installed: boolean; + /** How doctor names the file: "Team rules are inlined in