From dd562c0bbfa449c90bb9a187c3f4fcfe3081efbd Mon Sep 17 00:00:00 2001 From: Saul Moro Date: Thu, 1 Oct 2026 11:55:35 +0200 Subject: [PATCH 01/41] fix(pull): write instruction blocks only for installed tools (#945) Add src/instruction-targets.ts, one resolver for where the culture, claudemd and recall blocks go per tool and scope. Pull, recall enable/disable, local-agent and uninstall read targets from it. A pull now skips tools that are not installed, whether or not they have a rules path, so Hermes no longer writes ~/AGENTS.md when it is absent. It also strips teamai blocks from known targets no installed tool reads, and deletes the file when nothing else is left. Targets are unchanged. --- docs/usage-guide.md | 2 + docs/usage-guide.zh-CN.md | 2 + src/__tests__/e2e/instruction-targets.test.ts | 157 ++++++++++++++ src/instruction-targets.ts | 191 ++++++++++++++++++ src/local-agent.ts | 14 +- src/pull.ts | 49 +++-- src/recall-toggle.ts | 36 ++-- src/uninstall.ts | 6 +- 8 files changed, 408 insertions(+), 49 deletions(-) create mode 100644 src/__tests__/e2e/instruction-targets.test.ts create mode 100644 src/instruction-targets.ts diff --git a/docs/usage-guide.md b/docs/usage-guide.md index 871991b75..cd565b4b5 100644 --- a/docs/usage-guide.md +++ b/docs/usage-guide.md @@ -1642,6 +1642,8 @@ teamai pull The injected content sits between the `` and `` markers, is automatically updated on every `pull`, and does not affect any other content in the file. +A pull writes the culture, shared-instructions and recall blocks only to the files of AI tools that are installed. When no installed tool reads a file that an earlier pull wrote these blocks to (for example `~/AGENTS.md` after Hermes and WorkBuddy are gone), the next pull removes the teamai blocks from it, and deletes the file when nothing else is left. + ### Viewing the result After pulling, you can view the AI tool's CLAUDE.md directly: diff --git a/docs/usage-guide.zh-CN.md b/docs/usage-guide.zh-CN.md index 2d3f47adf..66ad8668f 100644 --- a/docs/usage-guide.zh-CN.md +++ b/docs/usage-guide.zh-CN.md @@ -1520,6 +1520,8 @@ teamai pull 注入的内容位于 `` 和 `` 标记之间,每次 pull 时自动更新,不会影响文件中的其他内容。 +pull 只把团队文化、共享指令和 recall 块写入已安装 AI 工具的文件。若之前某次 pull 写过这些块的文件已没有任何已安装工具读取(例如 Hermes 和 WorkBuddy 都已移除后的 `~/AGENTS.md`),下一次 pull 会移除其中的 teamai 块;文件中没有其他内容时一并删除该文件。 + ### 查看效果 pull 后可以直接查看 AI 工具的 CLAUDE.md: diff --git a/src/__tests__/e2e/instruction-targets.test.ts b/src/__tests__/e2e/instruction-targets.test.ts new file mode 100644 index 000000000..95748b2aa --- /dev/null +++ b/src/__tests__/e2e/instruction-targets.test.ts @@ -0,0 +1,157 @@ +import { afterEach, beforeAll, describe, expect, it } from 'vitest'; +import { execFileSync, spawn } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +// Real-CLI coverage for #945: a pull writes the instruction blocks (culture, +// claudemd, recall) only where an installed tool reads them, and strips the +// blocks an earlier pull left in a target no installed tool uses anymore. + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const ROOT = path.resolve(__dirname, '..', '..', '..'); +const CLI = path.join(ROOT, 'dist', 'index.js'); + +const GIT_ENV = { + GIT_AUTHOR_NAME: 'TeamAI CI', + GIT_AUTHOR_EMAIL: 'ci@teamai.test', + GIT_COMMITTER_NAME: 'TeamAI CI', + GIT_COMMITTER_EMAIL: 'ci@teamai.test', +}; + +const CULTURE_START = ''; +const CULTURE_END = ''; +const CLAUDEMD_START = ''; +const CLAUDEMD_END = ''; +const RECALL_START = ''; + +interface RunResult { + code: number | null; + output: string; +} + +function runCLI(args: string[], env: Record, cwd: string): Promise { + return new Promise((resolve) => { + const child = spawn('node', [CLI, ...args], { + env: { ...process.env, FORCE_COLOR: '0', ...env }, + stdio: ['pipe', 'pipe', 'pipe'], + cwd, + }); + let output = ''; + child.stdout.on('data', (data: Buffer) => { output += data.toString(); }); + child.stderr.on('data', (data: Buffer) => { output += data.toString(); }); + child.stdin.end(); + child.on('close', (code) => resolve({ code, output })); + }); +} + +function git(args: string[], cwd: string): void { + execFileSync('git', args, { cwd, stdio: 'pipe', env: { ...process.env, ...GIT_ENV } }); +} + +/** A user-scope sandbox HOME with the given tool directories installed. */ +function makeUserSandbox(toolDirs: string[]): { sandbox: string; home: string } { + const sandbox = fs.mkdtempSync(path.join(os.tmpdir(), 'teamai-945-e2e-')); + const home = path.join(sandbox, 'home'); + const remote = path.join(sandbox, 'team-remote'); + const localRepo = path.join(home, '.teamai', 'team-repo'); + + fs.mkdirSync(path.join(remote, 'claudemd', 'common'), { recursive: true }); + fs.writeFileSync( + path.join(remote, 'teamai.yaml'), + ['team: issue-945-e2e', `repo: ${remote}`, 'provider: git', 'sharing:', ' recall:', ' enabled: true'].join('\n'), + ); + fs.writeFileSync(path.join(remote, 'culture.md'), '---\ncompany:\n name: Acme\n---\n\nBe kind.\n'); + fs.writeFileSync(path.join(remote, 'claudemd', 'common', 'note.md'), 'Shared team instructions.\n'); + git(['init', '-q'], remote); + git(['add', '-A'], remote); + git(['commit', '-q', '-m', 'fixture'], remote); + + fs.mkdirSync(home, { recursive: true }); + git(['clone', '-q', remote, localRepo], sandbox); + for (const dir of toolDirs) fs.mkdirSync(path.join(home, dir), { recursive: true }); + fs.writeFileSync( + path.join(home, '.teamai', 'config.yaml'), + [ + 'repo:', + ` localPath: ${localRepo}`, + ` remote: ${remote}`, + 'username: ci-user', + 'updatePolicy: auto', + 'scope: user', + 'recallEnabled: true', + ].join('\n'), + ); + return { sandbox, home }; +} + +describe('instruction block targets on real CLI pull (#945)', () => { + const sandboxes: string[] = []; + + beforeAll(() => { + if (!fs.existsSync(CLI)) { + throw new Error(`CLI binary not found at ${CLI}. Run "npm run build" first.`); + } + }); + + afterEach(() => { + for (const dir of sandboxes.splice(0)) fs.rmSync(dir, { recursive: true, force: true }); + }); + + it('writes no ~/AGENTS.md when only Claude is installed', async () => { + const { sandbox, home } = makeUserSandbox(['.claude']); + sandboxes.push(sandbox); + + const result = await runCLI(['pull'], { HOME: home }, sandbox); + expect(result.code, result.output).toBe(0); + + const claudeMd = fs.readFileSync(path.join(home, '.claude', 'CLAUDE.md'), 'utf8'); + expect(claudeMd).toContain(CULTURE_START); + expect(claudeMd).toContain(CLAUDEMD_START); + expect(claudeMd).toContain(RECALL_START); + expect(fs.existsSync(path.join(home, 'AGENTS.md'))).toBe(false); + }); + + it('writes ~/AGENTS.md while Hermes is installed and deletes it once no installed tool reads it', async () => { + const { sandbox, home } = makeUserSandbox(['.claude', '.hermes']); + sandboxes.push(sandbox); + const agentsMd = path.join(home, 'AGENTS.md'); + + const withHermes = await runCLI(['pull'], { HOME: home }, sandbox); + expect(withHermes.code, withHermes.output).toBe(0); + const written = fs.readFileSync(agentsMd, 'utf8'); + expect(written).toContain(CULTURE_START); + expect(written).toContain(CLAUDEMD_START); + + fs.rmSync(path.join(home, '.hermes'), { recursive: true, force: true }); + const withoutHermes = await runCLI(['pull'], { HOME: home }, sandbox); + expect(withoutHermes.code, withoutHermes.output).toBe(0); + + expect(fs.existsSync(agentsMd)).toBe(false); + expect(fs.readFileSync(path.join(home, '.claude', 'CLAUDE.md'), 'utf8')).toContain(CLAUDEMD_START); + }); + + it('keeps only the hand-written text of a ~/AGENTS.md no installed tool reads', async () => { + const { sandbox, home } = makeUserSandbox(['.claude']); + sandboxes.push(sandbox); + const agentsMd = path.join(home, 'AGENTS.md'); + fs.writeFileSync(agentsMd, [ + '# My notes', + '', + CULTURE_START, + 'old culture', + CULTURE_END, + '', + CLAUDEMD_START, + 'old shared instructions', + CLAUDEMD_END, + '', + ].join('\n')); + + const result = await runCLI(['pull'], { HOME: home }, sandbox); + expect(result.code, result.output).toBe(0); + + expect(fs.readFileSync(agentsMd, 'utf8')).toBe('# My notes\n'); + }); +}); diff --git a/src/instruction-targets.ts b/src/instruction-targets.ts new file mode 100644 index 000000000..b3ef14409 --- /dev/null +++ b/src/instruction-targets.ts @@ -0,0 +1,191 @@ +import path from 'node:path'; +import { isToolInstalledForConfig } from './resources/base.js'; +import { readFileSafe, remove } from './utils/fs.js'; +import { removeClaudeMdSection } from './utils/claudemd.js'; +import { + isAgentExcluded, + resolveToolBaseDir, + scopedToolPaths, + TEAMAI_CLAUDEMD_END, + TEAMAI_CLAUDEMD_START, + TEAMAI_CULTURE_END, + TEAMAI_CULTURE_START, + TEAMAI_RECALL_RULES_END, + TEAMAI_RECALL_RULES_START, + TEAMAI_RULES_END, + TEAMAI_RULES_START, + type LocalConfig, + type Scope, + type TeamaiConfig, +} from './types.js'; + +/** + * Where teamai's instruction blocks (culture, claudemd, recall) go: one file + * per tool and scope, written only for tools that are installed (#945). + */ + +type ToolPaths = TeamaiConfig['toolPaths'][string]; + +interface TargetEntry { + /** + * 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 blocks in this scope. + */ + readonly file: (paths: ToolPaths) => string | undefined; + /** + * Files an earlier release wrote this tool's blocks to, relative to the same + * base dir. A pull strips teamai blocks from them once no installed tool + * targets them. + */ + readonly retired: readonly string[]; +} + +/** The tool's `claudemd` path from the team's `toolPaths` (honors `toolRoots`). */ +const configured = (paths: ToolPaths): string | undefined => paths.claudemd; + +// One line per tool, so a change to one tool's target edits one line. +const USER_TARGETS: Readonly> = { + claude: { file: configured, retired: [] }, + 'claude-internal': { file: configured, retired: [] }, + tclaude: { file: configured, retired: [] }, + hermes: { file: configured, retired: [] }, + copilot: { file: configured, retired: [] }, + omp: { file: configured, retired: [] }, + pi: { file: configured, retired: [] }, + workbuddy: { file: configured, retired: [] }, + codebuddy: { file: configured, retired: [] }, + openclaw: { file: configured, retired: [] }, +}; + +const PROJECT_TARGETS: Readonly> = { + claude: { file: configured, retired: [] }, + 'claude-internal': { file: configured, retired: [] }, + tclaude: { file: configured, retired: [] }, + hermes: { file: configured, retired: [] }, + copilot: { file: configured, retired: [] }, + omp: { file: configured, retired: [] }, + pi: { file: configured, retired: [] }, + workbuddy: { file: configured, retired: [] }, + codebuddy: { file: configured, retired: [] }, + openclaw: { file: configured, retired: [] }, +}; + +/** Every teamai block a stale target can hold, including the legacy rules block. */ +const TEAMAI_BLOCK_MARKERS: ReadonlyArray = [ + [TEAMAI_CULTURE_START, TEAMAI_CULTURE_END], + [TEAMAI_CLAUDEMD_START, TEAMAI_CLAUDEMD_END], + [TEAMAI_RECALL_RULES_START, TEAMAI_RECALL_RULES_END], + [TEAMAI_RULES_START, TEAMAI_RULES_END], +]; + +function entryFor(tool: string, scope: Scope): TargetEntry | undefined { + return (scope === 'user' ? USER_TARGETS : PROJECT_TARGETS)[tool]; +} + +/** One instruction file and the tools that read it. */ +export interface InstructionTarget { + /** Absolute path. */ + path: string; + tools: string[]; +} + +export interface InstructionTargets { + /** Culture and claudemd targets of installed, non-excluded tools, one per file. */ + targets: InstructionTarget[]; + /** The recall-block subset: tools with an `agents` path, which get the `teamai-recall` subagent. */ + recallTargets: InstructionTarget[]; + /** Known targets no installed tool reads: a pull strips teamai blocks from them. */ + stale: string[]; +} + +/** + * The instruction file `tool` reads in the active scope, relative to its base + * 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 { + return (entryFor(tool, scope)?.file ?? configured)(paths); +} + +/** Absolute instruction file of `tool` in the active scope, or undefined when it takes none. */ +export function instructionTargetPath( + tool: string, + paths: ToolPaths, + localConfig: LocalConfig, +): string | undefined { + const file = instructionTargetFile(tool, paths, localConfig.scope); + return file === undefined ? undefined : path.resolve(resolveToolBaseDir(tool, localConfig), file); +} + +/** + * Every file teamai may have written instruction blocks to in the active + * scope: each tool's current target plus the targets earlier releases used. + */ +export function knownInstructionTargets(teamConfig: TeamaiConfig, localConfig: LocalConfig): string[] { + const known = new Set(); + for (const [tool, paths] of Object.entries(scopedToolPaths(teamConfig, localConfig))) { + const target = instructionTargetPath(tool, paths, localConfig); + if (target) known.add(target); + } + const table = localConfig.scope === 'user' ? USER_TARGETS : PROJECT_TARGETS; + for (const [tool, entry] of Object.entries(table)) { + const baseDir = resolveToolBaseDir(tool, localConfig); + for (const file of entry.retired) known.add(path.resolve(baseDir, file)); + } + return [...known]; +} + +/** + * Whether `tool` is installed, probed through a path under its own root. The + * instruction file is never the probe: a bare `AGENTS.md` is shared by several + * tools and says nothing about any one of them. + */ +async function isInstalled(tool: string, paths: ToolPaths, localConfig: LocalConfig): Promise { + const probe = paths.skills ?? paths.rules ?? paths.agents ?? paths.settings; + return probe !== undefined && isToolInstalledForConfig(tool, probe, localConfig); +} + +function addTarget(targets: Map, file: string, tool: string): void { + const existing = targets.get(file); + if (existing) existing.tools.push(tool); + else targets.set(file, { path: file, tools: [tool] }); +} + +/** Resolve where this scope's instruction blocks go, and which files to clean. */ +export async function resolveInstructionTargets( + teamConfig: TeamaiConfig, + localConfig: LocalConfig, +): Promise { + const targets = new Map(); + const recallTargets = new Map(); + // Files an installed tool reads, excluded or not: an excluded tool's file is + // left alone, not cleaned. + const owned = new Set(); + for (const [tool, paths] of Object.entries(scopedToolPaths(teamConfig, localConfig))) { + const file = instructionTargetPath(tool, paths, localConfig); + if (!file || !await isInstalled(tool, paths, localConfig)) continue; + owned.add(file); + if (isAgentExcluded(localConfig, tool)) continue; + addTarget(targets, file, tool); + if (paths.agents) addTarget(recallTargets, file, tool); + } + const stale = knownInstructionTargets(teamConfig, localConfig).filter((file) => !owned.has(file)); + return { targets: [...targets.values()], recallTargets: [...recallTargets.values()], stale }; +} + +/** + * Remove every teamai block from `file` and delete the file when nothing else + * is left. Returns true when the file changed. + */ +export async function stripInstructionBlocks(file: string): Promise { + const before = await readFileSafe(file); + if (before === null) return false; + for (const [start, end] of TEAMAI_BLOCK_MARKERS) { + await removeClaudeMdSection(file, start, end); + } + const after = await readFileSafe(file); + if (after === null || after === before) return false; + if (after.trim() === '') await remove(file); + return true; +} diff --git a/src/local-agent.ts b/src/local-agent.ts index 5729273af..227dfb904 100644 --- a/src/local-agent.ts +++ b/src/local-agent.ts @@ -53,6 +53,7 @@ import { import { normalizeAgentType } from './utils/tool-names.js'; import { logHttpRequest, logHttpResponse } from './utils/http-log.js'; import { injectClaudeMdSection, removeClaudeMdSection } from './utils/claudemd.js'; +import { instructionTargetFile } from './instruction-targets.js'; import { reconcilePlugins, teardownAllPlugins, parseGetConfig, substituteVars, unresolvedPlaceholders, type ReconcileDeps, type PluginState } from './plugin-lifecycle.js'; import { resolveBaseDir, @@ -2094,7 +2095,8 @@ async function syncClaudemd( let syncedAny = false; for (const [tool, toolPath] of Object.entries(scopedToolPaths(teamConfig, localConfig))) { - if (!toolPath.claudemd) continue; + const targetFile = instructionTargetFile(tool, toolPath, localConfig.scope); + if (!targetFile) continue; let baseDir = resolveToolBaseDir(tool, localConfig); let resolvedAbsPath: string | null = null; @@ -2102,7 +2104,7 @@ async function syncClaudemd( if (tool === 'openclaw' && localConfig.scope !== 'project') { const openclawWs = await resolveOpenclawWorkspaceDir(workspacePath); if (openclawWs) { - resolvedAbsPath = path.join(openclawWs, path.basename(toolPath.claudemd)); + resolvedAbsPath = path.join(openclawWs, path.basename(targetFile)); } } else if (tool === 'hermes' && localConfig.scope !== 'project') { const hermesBase = workspacePath ?? await resolveHermesUserBaseDir(); @@ -2115,16 +2117,16 @@ async function syncClaudemd( const toolInstalled = resolvedAbsPath ? await pathExists(resolvedAbsPath) : tool === COPILOT_TOOL_ID && localConfig.scope === 'user' - ? await isToolInstalledForConfig(tool, toolPath.claudemd, localConfig) - : toolPath.claudemd.includes('/') - ? await ResourceHandler.isToolInstalled(toolPath.claudemd, baseDir) + ? await isToolInstalledForConfig(tool, targetFile, localConfig) + : targetFile.includes('/') + ? await ResourceHandler.isToolInstalled(targetFile, baseDir) : await pathExists(path.join(baseDir, `.${tool}`)); if (!toolInstalled) { log.debug(`Skipped CLAUDE.md sync for ${tool}: target not found`); continue; } - const claudeMdPath = resolvedAbsPath ?? path.join(baseDir, toolPath.claudemd); + const claudeMdPath = resolvedAbsPath ?? path.resolve(baseDir, targetFile); try { if (block) { await injectClaudeMdSection(claudeMdPath, TEAMAI_CLAUDEMD_START, TEAMAI_CLAUDEMD_END, block); diff --git a/src/pull.ts b/src/pull.ts index f3021a361..3f6834ae2 100644 --- a/src/pull.ts +++ b/src/pull.ts @@ -15,6 +15,7 @@ import { log, spinner } from './utils/logger.js'; import { pathExists, remove, listFiles, listDirs, listFilesRecursive, readFileSafe, dirContentEqual, hasVcsMetadataRecursive } from './utils/fs.js'; import { reconcilePlacementRecords } from './utils/pending-push.js'; import { injectClaudeMdSection, removeClaudeMdSection } from './utils/claudemd.js'; +import { resolveInstructionTargets, stripInstructionBlocks } from './instruction-targets.js'; import { getHandler, RulesHandler, DocsHandler, EnvHandler, AgentsHandler } from './resources/index.js'; import { reportHeldAgents, type RedeployedCopy } from './resources/agents.js'; import { listStaleDocDirectories, resolveDesiredDocs, resolveDocsDestination } from './resources/docs.js'; @@ -1865,13 +1866,19 @@ async function syncManagedInstructions( } } - if (compiledCulture !== undefined) { - for (const [tool, toolPath] of Object.entries(scopedToolPaths(config, localConfig))) { - if (isAgentExcluded(localConfig, tool) || !writesInstructionBlock(tool, toolPath, 'culture')) continue; - const installProbe = instructionFileInstallProbe(tool, toolPath); - if (installProbe && !await isToolInstalledForConfig(tool, installProbe, localConfig)) continue; + const { targets, stale } = await resolveInstructionTargets(config, localConfig); + for (const file of stale) { + try { + if (await stripInstructionBlocks(file)) { + log.info(`Removed teamai instruction blocks from ${file}: no installed tool reads it`); + } + } catch (e) { + log.warn(`Failed to remove teamai instruction blocks from ${file}: ${(e as Error).message}. Check that the file is writable, or remove the blocks by hand.`); + } + } - const claudeMdPath = path.join(resolveToolBaseDir(tool, localConfig), toolPath.claudemd); + if (compiledCulture !== undefined) { + for (const { path: claudeMdPath, tools } of targets) { try { if (compiledCulture) { await injectClaudeMdSection( @@ -1880,13 +1887,13 @@ async function syncManagedInstructions( TEAMAI_CULTURE_END, compiledCulture, ); - log.debug(`Injected culture into ${tool} CLAUDE.md`); + log.debug(`Injected culture into ${claudeMdPath}`); } else { await removeClaudeMdSection(claudeMdPath, TEAMAI_CULTURE_START, TEAMAI_CULTURE_END); } } catch (e) { const action = compiledCulture ? 'inject culture into' : 'remove culture from'; - log.warn(`Failed to ${action} ${tool} CLAUDE.md: ${(e as Error).message}`); + log.warn(`Failed to ${action} ${tools.join(', ')} (${claudeMdPath}): ${(e as Error).message}`); } } } @@ -1898,12 +1905,7 @@ async function syncManagedInstructions( const { contents: claudemdContents } = await collectClaudemdFiles(localConfig.repo.localPath, roleContext); const compiled = compileClaudemd(claudemdContents); - for (const [tool, toolPath] of Object.entries(scopedToolPaths(config, localConfig))) { - if (isAgentExcluded(localConfig, tool) || !writesInstructionBlock(tool, toolPath, 'claudemd')) continue; - const installProbe = instructionFileInstallProbe(tool, toolPath); - if (installProbe && !await isToolInstalledForConfig(tool, installProbe, localConfig)) continue; - - const claudeMdPath = path.join(resolveToolBaseDir(tool, localConfig), toolPath.claudemd); + for (const { path: claudeMdPath, tools } of targets) { try { if (compiled) { await injectClaudeMdSection( @@ -1912,13 +1914,13 @@ async function syncManagedInstructions( TEAMAI_CLAUDEMD_END, compiled, ); - log.debug(`Injected shared instructions into ${tool} CLAUDE.md`); + log.debug(`Injected shared instructions into ${claudeMdPath}`); } else { await removeClaudeMdSection(claudeMdPath, TEAMAI_CLAUDEMD_START, TEAMAI_CLAUDEMD_END); } } catch (e) { const action = compiled ? 'inject shared instructions into' : 'remove shared instructions from'; - log.warn(`Failed to ${action} ${tool} CLAUDE.md: ${(e as Error).message}`); + log.warn(`Failed to ${action} ${tools.join(', ')} (${claudeMdPath}): ${(e as Error).message}`); } } if (compiled) { @@ -1952,13 +1954,8 @@ export async function injectRecallBlockIntoTools( try { const recallBlock = compileRecallRulesBlock(); let injected = 0; - for (const [tool, toolPath] of Object.entries(scopedToolPaths(config, localConfig))) { - if (isAgentExcluded(localConfig, tool)) continue; - if (!writesInstructionBlock(tool, toolPath, 'recall')) continue; - if (!await isToolInstalledForConfig(tool, toolPath.agents, localConfig)) continue; - - const baseDir = resolveToolBaseDir(tool, localConfig); - const claudeMdPath = path.join(baseDir, toolPath.claudemd); + const { recallTargets } = await resolveInstructionTargets(config, localConfig); + for (const { path: claudeMdPath, tools } of recallTargets) { try { await injectClaudeMdSection( claudeMdPath, @@ -1967,13 +1964,13 @@ export async function injectRecallBlockIntoTools( recallBlock, ); injected++; - log.debug(`Injected recall rules into ${tool} CLAUDE.md`); + log.debug(`Injected recall rules into ${claudeMdPath}`); } catch (e) { - log.warn(`Failed to inject recall rules into ${tool} CLAUDE.md: ${(e as Error).message}`); + log.warn(`Failed to inject recall rules into ${tools.join(', ')} (${claudeMdPath}): ${(e as Error).message}`); } } if (injected > 0) { - log.debug(`[${scopeLabel}] Injected recall rules into ${injected} tool(s) CLAUDE.md`); + log.debug(`[${scopeLabel}] Injected recall rules into ${injected} instruction file(s)`); } } catch (e) { log.debug(`[${scopeLabel}] Recall rules injection skipped: ${(e as Error).message}`); diff --git a/src/recall-toggle.ts b/src/recall-toggle.ts index 1e8788a64..386909bca 100644 --- a/src/recall-toggle.ts +++ b/src/recall-toggle.ts @@ -1,9 +1,8 @@ import path from 'node:path'; import { autoDetectInit, saveLocalConfigForScope } from './config.js'; import { log } from './utils/logger.js'; -import { remove, pathExists } from './utils/fs.js'; -import { removeClaudeMdSection } from './utils/claudemd.js'; -import { isToolInstalledForConfig } from './resources/base.js'; +import { readFileSafe, writeFile, remove, pathExists } from './utils/fs.js'; +import { knownInstructionTargets, resolveInstructionTargets } from './instruction-targets.js'; import { ALL_SUPPORTED_TOOLS, agentFileExtensionForTool, @@ -65,12 +64,24 @@ async function removeRecallArtifacts(teamConfig: TeamaiConfig, localConfig: Loca } } } + } - // Remove recall block from CLAUDE.md - if (toolPath.claudemd) { - const claudeMdPath = path.join(baseDir, toolPath.claudemd); - if (await removeClaudeMdSection(claudeMdPath, TEAMAI_RECALL_RULES_START, TEAMAI_RECALL_RULES_END, { deleteIfEmpty: true })) { - log.debug(`Removed recall rules block from ${tool} CLAUDE.md`); + // Remove the recall block from every file teamai may have written it to. + for (const claudeMdPath of knownInstructionTargets(teamConfig, localConfig)) { + const content = await readFileSafe(claudeMdPath); + if (content && content.includes(TEAMAI_RECALL_RULES_START)) { + const startIdx = content.indexOf(TEAMAI_RECALL_RULES_START); + const endIdx = content.indexOf(TEAMAI_RECALL_RULES_END); + if (startIdx !== -1 && endIdx !== -1) { + const before = content.substring(0, startIdx).replace(/\n+$/, '\n'); + const after = content.substring(endIdx + TEAMAI_RECALL_RULES_END.length).replace(/^\n+/, '\n'); + const cleaned = (before + after).trim(); + if (cleaned.length === 0) { + await remove(claudeMdPath); + } else { + await writeFile(claudeMdPath, cleaned + '\n'); + } + log.debug(`Removed recall rules block from ${claudeMdPath}`); } } } @@ -90,13 +101,8 @@ async function deployRecallArtifacts(teamConfig: TeamaiConfig, localConfig: Loca const { compileRecallRulesBlock } = await import('./pull.js'); const recallBlock = compileRecallRulesBlock(); - for (const [tool, toolPath] of Object.entries(scopedToolPaths(teamConfig, localConfig))) { - if (isAgentExcluded(localConfig, tool)) continue; - if (!writesInstructionBlock(tool, toolPath, 'recall')) continue; - if (!await isToolInstalledForConfig(tool, toolPath.agents, localConfig)) continue; - - const baseDir = resolveToolBaseDir(tool, localConfig); - const claudeMdPath = path.join(baseDir, toolPath.claudemd); + const { recallTargets } = await resolveInstructionTargets(teamConfig, localConfig); + for (const { path: claudeMdPath } of recallTargets) { try { await injectClaudeMdSection( claudeMdPath, diff --git a/src/uninstall.ts b/src/uninstall.ts index 301fb70ac..aa76da5a0 100644 --- a/src/uninstall.ts +++ b/src/uninstall.ts @@ -54,6 +54,7 @@ import { } from './builtin-skills.js'; import { getHermesHome } from './hermes-home.js'; import { CODEX_TOOL, SHARED_AGENT_SKILLS_PATH } from './resources/skills.js'; +import { instructionTargetFile } from './instruction-targets.js'; import { pathExists, readFileSafe, @@ -433,8 +434,9 @@ async function discoverToolResources( } // (b) CLAUDE.md teamai section blocks - if (toolPath.claudemd) { - const claudeMdPath = path.join(baseDir, toolPath.claudemd); + const instructionFile = instructionTargetFile(tool, toolPath, scope); + if (instructionFile) { + const claudeMdPath = path.resolve(baseDir, instructionFile); const content = await readFileSafe(claudeMdPath); if (content && CLAUDEMD_MARKER_PAIRS.some(([start]) => content.includes(start))) { res.claudeMdFiles.push(claudeMdPath); From 15c4a2b3f5b63a6e5bcd46b122a7cfe98074671e Mon Sep 17 00:00:00 2001 From: Saul Moro Date: Thu, 1 Oct 2026 14:17:35 +0200 Subject: [PATCH 02/41] fix(pull): plan instruction files before writing them (#945) Culture, claudemd and recall blocks now go through one planner that works out each file's content first. A pull no longer rewrites a file whose blocks are current, leaves a block with a missing or repeated marker alone with a warning, deletes an emptied file only when git does not track it, and reports the files it would change under --dry-run. Recall is part of the same pass, so a pull removes the recall block when recall is disabled, and recall enable/disable use the same targets. --- docs/usage-guide.md | 2 +- docs/usage-guide.zh-CN.md | 2 +- src/__tests__/e2e/instruction-targets.test.ts | 144 ++++++++++- src/__tests__/instruction-targets.test.ts | 136 ++++++++++ src/__tests__/pull-skip-sync.test.ts | 9 +- src/instruction-targets.ts | 243 ++++++++++++++---- src/pull.ts | 166 +++--------- src/recall-toggle.ts | 56 ++-- 8 files changed, 538 insertions(+), 220 deletions(-) create mode 100644 src/__tests__/instruction-targets.test.ts diff --git a/docs/usage-guide.md b/docs/usage-guide.md index cd565b4b5..dcde36d23 100644 --- a/docs/usage-guide.md +++ b/docs/usage-guide.md @@ -1642,7 +1642,7 @@ teamai pull The injected content sits between the `` and `` markers, is automatically updated on every `pull`, and does not affect any other content in the file. -A pull writes the culture, shared-instructions and recall blocks only to the files of AI tools that are installed. When no installed tool reads a file that an earlier pull wrote these blocks to (for example `~/AGENTS.md` after Hermes and WorkBuddy are gone), the next pull removes the teamai blocks from it, and deletes the file when nothing else is left. +A pull writes the culture, shared-instructions and recall blocks only to the files of AI tools that are installed, and leaves a file alone when its blocks are already current. When no installed tool reads a file that an earlier pull wrote these blocks to (for example `~/AGENTS.md` after Hermes and WorkBuddy are gone), the next pull removes the teamai blocks from it and names the file in its output. It deletes the file when nothing else is left, unless git tracks it. A block with a missing or repeated marker is left as it is, with a warning to fix it by hand. `teamai pull --dry-run` lists the files a pull would change without writing them. When recall is disabled, the pull removes the recall block. ### Viewing the result diff --git a/docs/usage-guide.zh-CN.md b/docs/usage-guide.zh-CN.md index 66ad8668f..97d9093c4 100644 --- a/docs/usage-guide.zh-CN.md +++ b/docs/usage-guide.zh-CN.md @@ -1520,7 +1520,7 @@ teamai pull 注入的内容位于 `` 和 `` 标记之间,每次 pull 时自动更新,不会影响文件中的其他内容。 -pull 只把团队文化、共享指令和 recall 块写入已安装 AI 工具的文件。若之前某次 pull 写过这些块的文件已没有任何已安装工具读取(例如 Hermes 和 WorkBuddy 都已移除后的 `~/AGENTS.md`),下一次 pull 会移除其中的 teamai 块;文件中没有其他内容时一并删除该文件。 +pull 只把团队文化、共享指令和 recall 块写入已安装 AI 工具的文件;块内容未变化时不改写文件。若之前某次 pull 写过这些块的文件已没有任何已安装工具读取(例如 Hermes 和 WorkBuddy 都已移除后的 `~/AGENTS.md`),下一次 pull 会移除其中的 teamai 块,并在输出中列出该文件。文件中没有其他内容时一并删除该文件,但 git 跟踪的文件不会被删除。标记缺失或重复的块保持原样,并提示手动修复。`teamai pull --dry-run` 只列出将要修改的文件,不写入。recall 关闭时,pull 会移除 recall 块。 ### 查看效果 diff --git a/src/__tests__/e2e/instruction-targets.test.ts b/src/__tests__/e2e/instruction-targets.test.ts index 95748b2aa..cd5859ac2 100644 --- a/src/__tests__/e2e/instruction-targets.test.ts +++ b/src/__tests__/e2e/instruction-targets.test.ts @@ -52,7 +52,7 @@ function git(args: string[], cwd: string): void { /** A user-scope sandbox HOME with the given tool directories installed. */ function makeUserSandbox(toolDirs: string[]): { sandbox: string; home: string } { - const sandbox = fs.mkdtempSync(path.join(os.tmpdir(), 'teamai-945-e2e-')); + const sandbox = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'teamai-945-e2e-'))); const home = path.join(sandbox, 'home'); const remote = path.join(sandbox, 'team-remote'); const localRepo = path.join(home, '.teamai', 'team-repo'); @@ -86,6 +86,98 @@ function makeUserSandbox(toolDirs: string[]): { sandbox: string; home: string } return { sandbox, home }; } +const PROJECT_AGENTS_MD = '# Project\n\nAuthored project instructions.\n'; + +/** + * A team whose roles select different `claudemd/` namespaces, and a project + * repo with an authored, committed AGENTS.md (#945). + */ +function makeTeamAndProject(sandbox: string): { remote: string; projectOrigin: string } { + const seed = path.join(sandbox, 'team-seed'); + const remote = path.join(sandbox, 'team.git'); + const write = (rel: string, text: string): void => { + fs.mkdirSync(path.dirname(path.join(seed, rel)), { recursive: true }); + fs.writeFileSync(path.join(seed, rel), text); + }; + write('teamai.yaml', ['team: issue-945-project-e2e', `repo: ${remote}`, 'provider: git', 'sharing:', ' recall:', ' enabled: true', ''].join('\n')); + write('culture.md', '---\ncompany:\n name: Acme\n---\n\nBe kind.\n'); + write('claudemd/common.md', 'COMMON-SENTINEL shared by every role.\n'); + write('claudemd/development/dev.md', 'DEVELOPMENT-SENTINEL for developers.\n'); + write('claudemd/product/product.md', 'PRODUCT-SENTINEL for product.\n'); + write('manifest/roles.yaml', [ + 'version: 1', + 'roles:', + ' - id: developer', + ' description: Developer', + ' resources:', + ' knowledge: [development]', + ' skills: []', + ' - id: product', + ' description: Product', + ' resources:', + ' knowledge: [product]', + ' skills: []', + '', + ].join('\n')); + git(['init', '-q', '-b', 'main'], seed); + git(['add', '-A'], seed); + git(['commit', '-q', '-m', 'seed'], seed); + git(['clone', '-q', '--bare', seed, remote], sandbox); + + const projectSeed = path.join(sandbox, 'project-seed'); + const projectOrigin = path.join(sandbox, 'project.git'); + fs.mkdirSync(projectSeed, { recursive: true }); + fs.writeFileSync(path.join(projectSeed, 'AGENTS.md'), PROJECT_AGENTS_MD); + fs.writeFileSync(path.join(projectSeed, 'app.txt'), 'v1\n'); + git(['init', '-q', '-b', 'main'], projectSeed); + git(['add', '-A'], projectSeed); + git(['commit', '-q', '-m', 'project'], projectSeed); + git(['clone', '-q', '--bare', projectSeed, projectOrigin], sandbox); + return { remote, projectOrigin }; +} + +interface ProjectMember { + home: string; + projectRoot: string; +} + +/** + * One member's checkout of the project, in project scope, with their role and + * the given tool directories installed under the project root. + */ +function makeProjectMember( + sandbox: string, + fixture: { remote: string; projectOrigin: string }, + name: string, + role: string, + toolDirs: string[], +): ProjectMember { + const home = path.join(sandbox, `${name}-home`); + const projectRoot = path.join(sandbox, `${name}-project`); + fs.mkdirSync(home, { recursive: true }); + git(['clone', '-q', fixture.projectOrigin, projectRoot], sandbox); + const teamRepo = path.join(projectRoot, '.teamai', 'team-repo'); + git(['clone', '-q', fixture.remote, teamRepo], sandbox); + for (const dir of toolDirs) fs.mkdirSync(path.join(projectRoot, dir), { recursive: true }); + fs.writeFileSync(path.join(projectRoot, '.teamai', 'config.yaml'), [ + 'repo:', + ` localPath: ${teamRepo}`, + ` remote: ${fixture.remote}`, + `username: ${name}`, + 'updatePolicy: auto', + 'scope: project', + `projectRoot: ${projectRoot}`, + `primaryRole: ${role}`, + 'additionalRoles: []', + 'recallEnabled: true', + '', + ].join('\n')); + return { home, projectRoot }; +} + +const pullAs = (member: ProjectMember, args: string[] = []): Promise => + runCLI(['pull', '--force', ...args], { HOME: member.home }, member.projectRoot); + describe('instruction block targets on real CLI pull (#945)', () => { const sandboxes: string[] = []; @@ -154,4 +246,54 @@ describe('instruction block targets on real CLI pull (#945)', () => { expect(fs.readFileSync(agentsMd, 'utf8')).toBe('# My notes\n'); }); + + it('leaves the project AGENTS.md alone when Hermes is not installed', async () => { + const sandbox = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'teamai-945-e2e-'))); + sandboxes.push(sandbox); + const member = makeProjectMember(sandbox, makeTeamAndProject(sandbox), 'dev', 'developer', ['.claude/skills']); + + const result = await pullAs(member); + expect(result.code, result.output).toBe(0); + + expect(fs.readFileSync(path.join(member.projectRoot, 'AGENTS.md'), 'utf8')).toBe(PROJECT_AGENTS_MD); + }); + + it('reports the cleanup of an old AGENTS.md block in a dry run, then removes it and keeps the authored text', async () => { + const sandbox = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'teamai-945-e2e-'))); + sandboxes.push(sandbox); + const member = makeProjectMember(sandbox, makeTeamAndProject(sandbox), 'dev', 'developer', ['.claude/skills']); + const agentsMd = path.join(member.projectRoot, 'AGENTS.md'); + const leftover = `${PROJECT_AGENTS_MD}\n${CLAUDEMD_START}\nanother member's selection\n${CLAUDEMD_END}\n`; + fs.writeFileSync(agentsMd, leftover); + + const dryRun = await pullAs(member, ['--dry-run']); + expect(dryRun.code, dryRun.output).toBe(0); + expect(dryRun.output).toContain(`Would remove teamai instruction blocks from ${agentsMd}`); + expect(fs.readFileSync(agentsMd, 'utf8')).toBe(leftover); + + const result = await pullAs(member); + expect(result.code, result.output).toBe(0); + expect(result.output).toContain(`Removed teamai instruction blocks from ${agentsMd}`); + expect(fs.readFileSync(agentsMd, 'utf8')).toBe(PROJECT_AGENTS_MD); + }); + + it('does not rewrite an instruction file whose blocks are already current', async () => { + const sandbox = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'teamai-945-e2e-'))); + sandboxes.push(sandbox); + const member = makeProjectMember(sandbox, makeTeamAndProject(sandbox), 'dev', 'developer', ['.claude/skills']); + + const first = await pullAs(member); + expect(first.code, first.output).toBe(0); + const written = fs.readdirSync(member.projectRoot, { recursive: true, withFileTypes: true }) + .filter((entry) => entry.isFile() && !entry.parentPath.includes(`${path.sep}.git`) && !entry.parentPath.includes('.teamai')) + .map((entry) => path.join(entry.parentPath, entry.name)) + .filter((file) => fs.readFileSync(file, 'utf8').includes(CLAUDEMD_START)); + expect(written.length).toBeGreaterThan(0); + const mtimes = written.map((file) => fs.statSync(file).mtimeMs); + + await new Promise((resolve) => setTimeout(resolve, 20)); + const second = await pullAs(member); + expect(second.code, second.output).toBe(0); + expect(written.map((file) => fs.statSync(file).mtimeMs)).toEqual(mtimes); + }); }); diff --git a/src/__tests__/instruction-targets.test.ts b/src/__tests__/instruction-targets.test.ts new file mode 100644 index 000000000..5c40ff290 --- /dev/null +++ b/src/__tests__/instruction-targets.test.ts @@ -0,0 +1,136 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { execFileSync } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { + applyInstructionPlan, + planInstructionFiles, + type InstructionTarget, +} from '../instruction-targets.js'; +import { + TEAMAI_CLAUDEMD_END, + TEAMAI_CLAUDEMD_START, + TEAMAI_CULTURE_END, + TEAMAI_CULTURE_START, +} from '../types.js'; + +const culture = (text: string) => `${TEAMAI_CULTURE_START}\n${text}\n${TEAMAI_CULTURE_END}`; +const claudemd = (text: string) => `${TEAMAI_CLAUDEMD_START}\n${text}\n${TEAMAI_CLAUDEMD_END}`; + +describe('instruction file planning (#945)', () => { + let dir: string; + + beforeEach(() => { + dir = fs.realpathSync.native(fs.mkdtempSync(path.join(os.tmpdir(), 'teamai-945-plan-'))); + }); + + afterEach(() => { + fs.rmSync(dir, { recursive: true, force: true }); + }); + + const target = (file: string, extra: Partial = {}): InstructionTarget => ({ + path: path.join(dir, file), + tools: ['claude'], + recall: false, + ...extra, + }); + + it('creates a missing target with the blocks only', async () => { + const plan = await planInstructionFiles([target('CLAUDE.local.md')], { culture: culture('c'), claudemd: claudemd('s') }); + await applyInstructionPlan(plan, { dryRun: false }); + + expect(fs.readFileSync(path.join(dir, 'CLAUDE.local.md'), 'utf8')).toBe(`${culture('c')}\n\n${claudemd('s')}\n`); + }); + + it('plans no change when the blocks are already current', async () => { + const file = path.join(dir, 'CLAUDE.local.md'); + fs.writeFileSync(file, `# Mine\n\n${culture('c')}\n`); + + const plan = await planInstructionFiles([target('CLAUDE.local.md')], { culture: culture('c') }); + + expect(plan.changes).toEqual([]); + }); + + it('leaves a block with a missing end marker intact and warns', async () => { + const file = path.join(dir, 'AGENTS.md'); + const original = `# Project\n\n${TEAMAI_CLAUDEMD_START}\nold selection\n`; + fs.writeFileSync(file, original); + + const plan = await planInstructionFiles([], {}, [target('AGENTS.md', { tools: [] })]); + await applyInstructionPlan(plan, { dryRun: false }); + + expect(fs.readFileSync(file, 'utf8')).toBe(original); + expect(plan.warnings.join('\n')).toMatch(/AGENTS\.md.*incomplete teamai claudemd block.*by hand/); + }); + + it('removes stale blocks and keeps the authored text', async () => { + const file = path.join(dir, 'AGENTS.md'); + fs.writeFileSync(file, `# Project\n\nAuthored.\n\n${culture('c')}\n\n${claudemd('dev')}\n`); + + const plan = await planInstructionFiles([], {}, [target('AGENTS.md', { tools: [] })]); + await applyInstructionPlan(plan, { dryRun: false }); + + expect(fs.readFileSync(file, 'utf8')).toBe('# Project\n\nAuthored.\n'); + }); + + it('deletes a stale file that held only teamai blocks and is not tracked by git', async () => { + const file = path.join(dir, 'AGENTS.md'); + fs.writeFileSync(file, `\n\n${culture('c')}\n`); + + const plan = await planInstructionFiles([], {}, [target('AGENTS.md', { tools: [] })]); + await applyInstructionPlan(plan, { dryRun: false }); + + expect(fs.existsSync(file)).toBe(false); + }); + + it('keeps a tracked stale file that held only teamai blocks, emptied', async () => { + execFileSync('git', ['init', '-q'], { cwd: dir }); + const file = path.join(dir, 'AGENTS.md'); + fs.writeFileSync(file, `${culture('c')}\n`); + execFileSync('git', ['add', 'AGENTS.md'], { cwd: dir }); + + const plan = await planInstructionFiles([], {}, [target('AGENTS.md', { tools: [] })]); + await applyInstructionPlan(plan, { dryRun: false }); + + expect(fs.readFileSync(file, 'utf8')).toBe(''); + }); + + it('writes nothing in a dry run and reports each file', async () => { + const file = path.join(dir, 'AGENTS.md'); + const original = `# Project\n\n${culture('c')}\n`; + fs.writeFileSync(file, original); + + const plan = await planInstructionFiles([target('CLAUDE.local.md')], { culture: culture('c') }, [target('AGENTS.md', { tools: [] })]); + const { report } = await applyInstructionPlan(plan, { dryRun: true }); + + expect(fs.readFileSync(file, 'utf8')).toBe(original); + expect(fs.existsSync(path.join(dir, 'CLAUDE.local.md'))).toBe(false); + expect(report).toEqual([ + `Would write teamai instruction blocks to ${path.join(dir, 'CLAUDE.local.md')}`, + `Would remove teamai instruction blocks from ${file}`, + ]); + }); + + it('keeps a same-named file teamai does not own and reports it', async () => { + const file = path.join(dir, 'teamai-context.mdc'); + fs.writeFileSync(file, 'my own rule\n'); + + const plan = await planInstructionFiles([target('teamai-context.mdc', { header: '---\nalwaysApply: true\n---\n', owned: true })], { culture: culture('c') }); + + expect(plan.changes).toEqual([]); + expect(plan.warnings.join('\n')).toMatch(/teamai-context\.mdc.*not written by teamai.*left it unchanged/); + }); + + it('writes the header above the blocks and deletes the owned file once its blocks are gone', async () => { + const file = path.join(dir, 'teamai-context.mdc'); + const header = '---\nalwaysApply: true\n---\n'; + const owned = target('teamai-context.mdc', { header, owned: true }); + + await applyInstructionPlan(await planInstructionFiles([owned], { culture: culture('c') }), { dryRun: false }); + expect(fs.readFileSync(file, 'utf8')).toBe(`${header}\n${culture('c')}\n`); + + await applyInstructionPlan(await planInstructionFiles([owned], { culture: null, claudemd: null }), { dryRun: false }); + expect(fs.existsSync(file)).toBe(false); + }); +}); diff --git a/src/__tests__/pull-skip-sync.test.ts b/src/__tests__/pull-skip-sync.test.ts index f2ae089d4..69d13199c 100644 --- a/src/__tests__/pull-skip-sync.test.ts +++ b/src/__tests__/pull-skip-sync.test.ts @@ -689,7 +689,7 @@ describe('pull skip-sync refreshes CLAUDE.md recall block (CLI upgrade)', () => expect(updated.split(TEAMAI_RECALL_RULES_END).length - 1).toBe(1); }); - it('does not touch CLAUDE.md when recall is disabled for the scope', async () => { + it('removes the recall block when recall is disabled for the scope (#945)', async () => { vi.mocked(loadLocalConfigForScope).mockResolvedValue({ repo: { localPath: repoPath, remote: 'https://git.woa.com/test/repo.git' }, username: 'testuser', @@ -704,8 +704,8 @@ describe('pull skip-sync refreshes CLAUDE.md recall block (CLI upgrade)', () => await pull({}); const after = await fse.readFile(claudeMdPath, 'utf8'); - // Untouched: the stale block remains exactly as seeded. - expect(after).toContain('you **MUST** first invoke the `teamai-recall` subagent'); + expect(after).not.toContain(TEAMAI_RECALL_RULES_START); + expect(after).toContain('# CLAUDE.md'); }); it('refreshes Copilot recall instructions under a custom COPILOT_HOME', async () => { @@ -1035,8 +1035,7 @@ describe('enabledAgents whitelist on pull inject, skip-sync, and cleanup (#510)' await pull({ silent: true }); - expect(log.warn).toHaveBeenCalledWith(expect.stringContaining('Failed to inject culture into copilot')); - expect(log.warn).toHaveBeenCalledWith(expect.stringContaining('Failed to inject shared instructions into copilot')); + expect(log.warn).toHaveBeenCalledWith(expect.stringContaining(`Could not update ${instructionPath}`)); expect((await fse.stat(instructionPath)).isDirectory()).toBe(true); }); diff --git a/src/instruction-targets.ts b/src/instruction-targets.ts index b3ef14409..7483bccee 100644 --- a/src/instruction-targets.ts +++ b/src/instruction-targets.ts @@ -1,7 +1,7 @@ import path from 'node:path'; import { isToolInstalledForConfig } from './resources/base.js'; -import { readFileSafe, remove } from './utils/fs.js'; -import { removeClaudeMdSection } from './utils/claudemd.js'; +import { readFileSafe, remove, writeFile } from './utils/fs.js'; +import { gitTracking, gitTracks } from './mcp-git-exclude.js'; import { isAgentExcluded, resolveToolBaseDir, @@ -20,8 +20,9 @@ import { } from './types.js'; /** - * Where teamai's instruction blocks (culture, claudemd, recall) go: one file - * per tool and scope, written only for tools that are installed (#945). + * Where teamai's instruction blocks (culture, claudemd, recall) go: one target + * per tool and scope, written only for tools that are installed, and never a + * file the project or another tool shares (#945). */ type ToolPaths = TeamaiConfig['toolPaths'][string]; @@ -30,9 +31,13 @@ interface TargetEntry { /** * 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 blocks in this scope. + * takes no file in this scope. */ readonly file: (paths: ToolPaths) => string | undefined; + /** Text teamai writes above the blocks when it creates the file, e.g. the frontmatter a rules loader needs. */ + readonly header?: string; + /** teamai owns the whole file: one it did not write is left alone, and it is deleted once its blocks are gone. */ + readonly owned?: boolean; /** * Files an earlier release wrote this tool's blocks to, relative to the same * base dir. A pull strips teamai blocks from them once no installed tool @@ -71,13 +76,16 @@ const PROJECT_TARGETS: Readonly> = { openclaw: { file: configured, retired: [] }, }; -/** Every teamai block a stale target can hold, including the legacy rules block. */ -const TEAMAI_BLOCK_MARKERS: ReadonlyArray = [ - [TEAMAI_CULTURE_START, TEAMAI_CULTURE_END], - [TEAMAI_CLAUDEMD_START, TEAMAI_CLAUDEMD_END], - [TEAMAI_RECALL_RULES_START, TEAMAI_RECALL_RULES_END], - [TEAMAI_RULES_START, TEAMAI_RULES_END], -]; +type MarkerPair = readonly [start: string, end: string, name: string]; + +const CULTURE: MarkerPair = [TEAMAI_CULTURE_START, TEAMAI_CULTURE_END, 'culture']; +const CLAUDEMD: MarkerPair = [TEAMAI_CLAUDEMD_START, TEAMAI_CLAUDEMD_END, 'claudemd']; +const RECALL: MarkerPair = [TEAMAI_RECALL_RULES_START, TEAMAI_RECALL_RULES_END, 'recall']; +/** The rules block releases before per-file rules wrote into the same files. */ +const LEGACY_RULES: MarkerPair = [TEAMAI_RULES_START, TEAMAI_RULES_END, 'rules']; + +/** Every teamai block a stale target can hold. */ +const STALE_BLOCKS: readonly MarkerPair[] = [CULTURE, CLAUDEMD, RECALL, LEGACY_RULES]; function entryFor(tool: string, scope: Scope): TargetEntry | undefined { return (scope === 'user' ? USER_TARGETS : PROJECT_TARGETS)[tool]; @@ -88,15 +96,27 @@ export interface InstructionTarget { /** Absolute path. */ path: string; tools: string[]; + /** Whether a tool reading this file has the `teamai-recall` subagent, so the recall block belongs here. */ + recall: boolean; + header?: string; + owned?: boolean; } export interface InstructionTargets { - /** Culture and claudemd targets of installed, non-excluded tools, one per file. */ + /** Targets of installed, non-excluded tools, one per file. */ targets: InstructionTarget[]; - /** The recall-block subset: tools with an `agents` path, which get the `teamai-recall` subagent. */ - recallTargets: InstructionTarget[]; /** Known targets no installed tool reads: a pull strips teamai blocks from them. */ - stale: string[]; + stale: InstructionTarget[]; +} + +/** + * The block texts to deliver, each with its markers. `null` removes the block; + * an absent field leaves it as it is. + */ +export interface InstructionBlocks { + culture?: string | null; + claudemd?: string | null; + recall?: string | null; } /** @@ -118,22 +138,30 @@ export function instructionTargetPath( return file === undefined ? undefined : path.resolve(resolveToolBaseDir(tool, localConfig), file); } +function targetFor(tool: string, file: string, scope: Scope): InstructionTarget { + const entry = entryFor(tool, scope); + return { path: file, tools: [], recall: false, header: entry?.header, owned: entry?.owned }; +} + /** * Every file teamai may have written instruction blocks to in the active * scope: each tool's current target plus the targets earlier releases used. */ -export function knownInstructionTargets(teamConfig: TeamaiConfig, localConfig: LocalConfig): string[] { - const known = new Set(); +function knownInstructionTargets(teamConfig: TeamaiConfig, localConfig: LocalConfig): Map { + const known = new Map(); for (const [tool, paths] of Object.entries(scopedToolPaths(teamConfig, localConfig))) { - const target = instructionTargetPath(tool, paths, localConfig); - if (target) known.add(target); + const file = instructionTargetPath(tool, paths, localConfig); + if (file && !known.has(file)) known.set(file, targetFor(tool, file, localConfig.scope)); } const table = localConfig.scope === 'user' ? USER_TARGETS : PROJECT_TARGETS; for (const [tool, entry] of Object.entries(table)) { const baseDir = resolveToolBaseDir(tool, localConfig); - for (const file of entry.retired) known.add(path.resolve(baseDir, file)); + for (const retired of entry.retired) { + const file = path.resolve(baseDir, retired); + if (!known.has(file)) known.set(file, { path: file, tools: [], recall: false }); + } } - return [...known]; + return known; } /** @@ -146,46 +174,169 @@ async function isInstalled(tool: string, paths: ToolPaths, localConfig: LocalCon return probe !== undefined && isToolInstalledForConfig(tool, probe, localConfig); } -function addTarget(targets: Map, file: string, tool: string): void { - const existing = targets.get(file); - if (existing) existing.tools.push(tool); - else targets.set(file, { path: file, tools: [tool] }); -} - /** Resolve where this scope's instruction blocks go, and which files to clean. */ export async function resolveInstructionTargets( teamConfig: TeamaiConfig, localConfig: LocalConfig, ): Promise { const targets = new Map(); - const recallTargets = new Map(); // Files an installed tool reads, excluded or not: an excluded tool's file is // left alone, not cleaned. - const owned = new Set(); + const inUse = new Set(); for (const [tool, paths] of Object.entries(scopedToolPaths(teamConfig, localConfig))) { const file = instructionTargetPath(tool, paths, localConfig); if (!file || !await isInstalled(tool, paths, localConfig)) continue; - owned.add(file); + inUse.add(file); if (isAgentExcluded(localConfig, tool)) continue; - addTarget(targets, file, tool); - if (paths.agents) addTarget(recallTargets, file, tool); + const target = targets.get(file) ?? targetFor(tool, file, localConfig.scope); + target.tools.push(tool); + if (paths.agents) target.recall = true; + targets.set(file, target); + } + const stale = [...knownInstructionTargets(teamConfig, localConfig).values()].filter((t) => !inUse.has(t.path)); + return { targets: [...targets.values()], stale }; +} + +// ─── Planning file contents ──────────────────────────── + +/** A file whose content a plan changes; `content: null` deletes it. */ +export interface InstructionFileChange { + path: string; + content: string | null; + /** `write` delivers blocks to a target; `cleanup` removes them from a file no tool loads them from. */ + kind: 'write' | 'cleanup'; +} + +export interface InstructionPlan { + changes: InstructionFileChange[]; + warnings: string[]; +} + +type BlockEdit = { content: string } | { malformed: true }; + +/** + * Set (`block` a string) or remove (`block` null) one marker-delimited block. + * A block whose markers are not exactly one start followed by one end is + * malformed and left alone, so teamai never deletes text it cannot delimit. + */ +function editBlock(content: string, [start, end]: MarkerPair, block: string | null): BlockEdit { + const starts = content.split(start).length - 1; + const ends = content.split(end).length - 1; + if (starts === 0 && ends === 0) { + if (block === null) return { content }; + const kept = content.trimEnd(); + return { content: kept ? `${kept}\n\n${block}\n` : `${block}\n` }; + } + const startIdx = content.indexOf(start); + const endIdx = content.indexOf(end); + if (starts !== 1 || ends !== 1 || endIdx < startIdx) return { malformed: true }; + const after = content.substring(endIdx + end.length); + if (block !== null) return { content: content.substring(0, startIdx) + block + after }; + const before = content.substring(0, startIdx).replace(/\n+$/, '\n'); + const rest = (before + after.replace(/^\n+/, '\n')).trimEnd(); + return { content: rest ? `${rest}\n` : '' }; +} + +function hasTeamaiBlock(content: string): boolean { + return STALE_BLOCKS.some(([start, end]) => content.includes(start) || content.includes(end)); +} + +function withoutHeader(content: string, header: string | undefined): string { + return header && content.startsWith(header) ? content.substring(header.length) : content; +} + +/** + * Whether a file left with nothing but teamai's blocks may go. A file git + * tracks stays, emptied, so the cleanup never deletes a project file; a file + * whose state git cannot report stays too. + */ +async function mayDelete(file: string): Promise { + const tracked = await gitTracks(file); + if (tracked.kind !== 'unknown') return tracked.kind === 'untracked'; + return (await gitTracking(file)).kind === 'outside-repo'; +} + +async function planFile( + target: InstructionTarget, + edits: ReadonlyArray, + kind: InstructionFileChange['kind'], + warnings: string[], +): Promise { + const existing = await readFileSafe(target.path); + if (existing !== null && target.owned && !hasTeamaiBlock(existing) && existing !== (target.header ?? '')) { + warnings.push(`${target.path} was not written by teamai, so teamai left it unchanged. Move or rename it so teamai can deliver the team instructions there.`); + return null; + } + let content = existing ?? target.header ?? ''; + for (const [pair, block] of edits) { + const edited = editBlock(content, pair, block); + if ('malformed' in edited) { + warnings.push(`${target.path} has an incomplete teamai ${pair[2]} block, so teamai left it unchanged. Fix or remove its ${pair[2]} markers by hand.`); + continue; + } + content = edited.content; + } + if (content === (existing ?? target.header ?? '')) return null; + + const remainder = withoutHeader(content, target.header).trim(); + if (remainder === '') { + if (existing === null) return null; + if (target.owned || await mayDelete(target.path)) return { path: target.path, content: null, kind }; + return { path: target.path, content: '', kind }; + } + return { path: target.path, content, kind }; +} + +/** + * Work out every change that delivers `blocks` to `targets` and strips teamai + * blocks from `stale` files, without writing anything. + */ +export async function planInstructionFiles( + targets: readonly InstructionTarget[], + blocks: InstructionBlocks, + stale: readonly InstructionTarget[] = [], +): Promise { + const warnings: string[] = []; + const changes: InstructionFileChange[] = []; + for (const target of targets) { + const edits: Array = []; + if (blocks.culture !== undefined) edits.push([CULTURE, blocks.culture]); + if (blocks.claudemd !== undefined) edits.push([CLAUDEMD, blocks.claudemd]); + if (blocks.recall !== undefined) edits.push([RECALL, target.recall ? blocks.recall : null]); + const change = await planFile(target, edits, 'write', warnings); + if (change) changes.push(change); + } + for (const file of stale) { + const change = await planFile(file, STALE_BLOCKS.map((pair) => [pair, null] as const), 'cleanup', warnings); + if (change) changes.push(change); } - const stale = knownInstructionTargets(teamConfig, localConfig).filter((file) => !owned.has(file)); - return { targets: [...targets.values()], recallTargets: [...recallTargets.values()], stale }; + return { changes, warnings }; } /** - * Remove every teamai block from `file` and delete the file when nothing else - * is left. Returns true when the file changed. + * Write a plan, or with `dryRun` only describe it. Returns one line per file + * changed (or that would change), and one actionable line per file that could + * not be written; a failure leaves that file as it was and the others go on. */ -export async function stripInstructionBlocks(file: string): Promise { - const before = await readFileSafe(file); - if (before === null) return false; - for (const [start, end] of TEAMAI_BLOCK_MARKERS) { - await removeClaudeMdSection(file, start, end); +export async function applyInstructionPlan( + plan: InstructionPlan, + options: { dryRun: boolean }, +): Promise<{ report: string[]; failures: string[] }> { + const report: string[] = []; + const failures: string[] = []; + for (const { path: file, content, kind } of plan.changes) { + if (!options.dryRun) { + try { + if (content === null) await remove(file); + else await writeFile(file, content); + } catch (e) { + failures.push(`Could not update ${file}: ${(e as Error).message}. Check that it is a writable file, then run teamai pull again.`); + continue; + } + } + report.push(kind === 'write' + ? `${options.dryRun ? 'Would write' : 'Wrote'} teamai instruction blocks to ${file}` + : `${options.dryRun ? 'Would remove' : 'Removed'} teamai instruction blocks from ${file}`); } - const after = await readFileSafe(file); - if (after === null || after === before) return false; - if (after.trim() === '') await remove(file); - return true; + return { report, failures }; } diff --git a/src/pull.ts b/src/pull.ts index 3f6834ae2..088c20913 100644 --- a/src/pull.ts +++ b/src/pull.ts @@ -14,8 +14,7 @@ import { indexableLearningsRoots } from './utils/learnings-roots.js'; import { log, spinner } from './utils/logger.js'; import { pathExists, remove, listFiles, listDirs, listFilesRecursive, readFileSafe, dirContentEqual, hasVcsMetadataRecursive } from './utils/fs.js'; import { reconcilePlacementRecords } from './utils/pending-push.js'; -import { injectClaudeMdSection, removeClaudeMdSection } from './utils/claudemd.js'; -import { resolveInstructionTargets, stripInstructionBlocks } from './instruction-targets.js'; +import { applyInstructionPlan, planInstructionFiles, resolveInstructionTargets, type InstructionBlocks } from './instruction-targets.js'; import { getHandler, RulesHandler, DocsHandler, EnvHandler, AgentsHandler } from './resources/index.js'; import { reportHeldAgents, type RedeployedCopy } from './resources/agents.js'; import { listStaleDocDirectories, resolveDesiredDocs, resolveDocsDestination } from './resources/docs.js'; @@ -1214,13 +1213,10 @@ async function pullForScope( } catch (e) { warnStubNotDeployed(scopeLabel, e); } - // Refresh managed culture/shared-instruction blocks as well. A CLI - // upgrade may add a new target file while the team repo SHA and tool - // target set remain unchanged. + // Refresh the managed instruction blocks as well. A CLI upgrade may + // move a target or ship a new recall block while the team repo SHA + // and tool target set remain unchanged. await syncManagedInstructions(freshConfig, localConfig, roleContext, scopeLabel); - // Also refresh the CLAUDE.md recall block so a CLI upgrade that ships - // a new block reaches CLAUDE.md even when the repo HEAD is unchanged. - await injectRecallBlockIntoTools(freshConfig, localConfig, scopeLabel); // Same reason: a machine that already pulled a tombstone with an older // CLI keeps the copies that CLI failed to delete, and its stored rev // never moves again. Re-run the cleanup so the upgrade reaches it (#576). @@ -1574,15 +1570,8 @@ async function pullForScope( // (#704); see `syncLearningsAndRebuildIndex`. await syncLearningsAndRebuildIndex(); - // Steps 3.6-3.7: Inject team culture and shared instructions. - if (!options.dryRun) { - await syncManagedInstructions(freshConfig, localConfig, roleContext, scopeLabel); - } - - // Step 3.8: Inject teamai-recall subagent rules block (Phase 1) - if (!options.dryRun) { - await injectRecallBlockIntoTools(freshConfig, localConfig, scopeLabel); - } + // Steps 3.6-3.8: Deliver team culture, shared instructions and the recall block. + await syncManagedInstructions(freshConfig, localConfig, roleContext, scopeLabel, options.dryRun); // Step 4: Deploy CLI built-in skills if (!options.dryRun) { @@ -1843,138 +1832,61 @@ export function compileClaudemd(contents: string[]): string | null { ].join('\n'); } -/** Refresh culture and shared-instruction blocks for every installed target. */ +/** + * Deliver the culture, shared-instruction and recall blocks to every installed + * tool's target, and strip them from files no installed tool loads them from + * (#945). Runs on the "Already synced" fast path too, so a CLI upgrade that + * moves a target or ships a new recall block takes effect without a repo + * change. The recall block goes to targets whose tool has the `teamai-recall` + * subagent; the block itself tells an agent without one to run + * `teamai recall` directly. A dry run reports the files it would change. + */ async function syncManagedInstructions( config: TeamaiConfig, localConfig: LocalConfig, roleContext: RolePullContext | null, scopeLabel: string, + dryRun = false, ): Promise { const culturePath = path.join(localConfig.repo.localPath, 'culture.md'); - let compiledCulture: string | null | undefined; + const blocks: InstructionBlocks = { + recall: isRecallEnabled(localConfig, config) ? compileRecallRulesBlock() : null, + }; try { const cultureContent = await readFile(culturePath, 'utf8'); - compiledCulture = compileCulture(cultureContent) ?? undefined; - if (compiledCulture === undefined) { + blocks.culture = compileCulture(cultureContent) ?? undefined; + if (blocks.culture === undefined) { log.warn(`Skipped team culture sync because ${culturePath} is empty or invalid`); } } catch (error) { if ((error as NodeJS.ErrnoException).code === FILE_NOT_FOUND_ERROR_CODE) { - compiledCulture = null; + blocks.culture = null; } else { log.warn(`Failed to read team culture from ${culturePath}: ${(error as Error).message}`); } } - - const { targets, stale } = await resolveInstructionTargets(config, localConfig); - for (const file of stale) { - try { - if (await stripInstructionBlocks(file)) { - log.info(`Removed teamai instruction blocks from ${file}: no installed tool reads it`); - } - } catch (e) { - log.warn(`Failed to remove teamai instruction blocks from ${file}: ${(e as Error).message}. Check that the file is writable, or remove the blocks by hand.`); - } - } - - if (compiledCulture !== undefined) { - for (const { path: claudeMdPath, tools } of targets) { - try { - if (compiledCulture) { - await injectClaudeMdSection( - claudeMdPath, - TEAMAI_CULTURE_START, - TEAMAI_CULTURE_END, - compiledCulture, - ); - log.debug(`Injected culture into ${claudeMdPath}`); - } else { - await removeClaudeMdSection(claudeMdPath, TEAMAI_CULTURE_START, TEAMAI_CULTURE_END); - } - } catch (e) { - const action = compiledCulture ? 'inject culture into' : 'remove culture from'; - log.warn(`Failed to ${action} ${tools.join(', ')} (${claudeMdPath}): ${(e as Error).message}`); - } - } - } - if (compiledCulture) { - log.success('Synced team culture'); - } - + let claudemdFiles = 0; try { - const { contents: claudemdContents } = await collectClaudemdFiles(localConfig.repo.localPath, roleContext); - const compiled = compileClaudemd(claudemdContents); - - for (const { path: claudeMdPath, tools } of targets) { - try { - if (compiled) { - await injectClaudeMdSection( - claudeMdPath, - TEAMAI_CLAUDEMD_START, - TEAMAI_CLAUDEMD_END, - compiled, - ); - log.debug(`Injected shared instructions into ${claudeMdPath}`); - } else { - await removeClaudeMdSection(claudeMdPath, TEAMAI_CLAUDEMD_START, TEAMAI_CLAUDEMD_END); - } - } catch (e) { - const action = compiled ? 'inject shared instructions into' : 'remove shared instructions from'; - log.warn(`Failed to ${action} ${tools.join(', ')} (${claudeMdPath}): ${(e as Error).message}`); - } - } - if (compiled) { - log.success(`[${scopeLabel}] Synced shared instructions (${claudemdContents.length} file(s))`); - } + const { contents } = await collectClaudemdFiles(localConfig.repo.localPath, roleContext); + blocks.claudemd = compileClaudemd(contents); + claudemdFiles = contents.length; } catch (e) { log.debug(`Shared instructions sync skipped: ${(e as Error).message}`); } -} -/** - * Inject (or replace) the teamai-recall block into every Tier-1 tool's CLAUDE.md. - * - * Only injected for Tier-1 tools that have BOTH `agents` and `claudemd` - * configured (Codex included: its `claudemd` is AGENTS.md). Tools missing - * either (e.g. cursor / openclaw / hermes) are skipped — for them the recall - * flow runs purely via the TodoWrite hint hook and the manual `teamai recall` - * command. - * - * Extracted so both the full-sync path (Step 3.8) and the "Already synced" - * rev fast-path can call it — otherwise a CLI upgrade that ships a new recall - * block never reaches CLAUDE.md when the team repo HEAD is unchanged. - * No-op when recall is disabled for this scope. - */ -export async function injectRecallBlockIntoTools( - config: TeamaiConfig, - localConfig: LocalConfig, - scopeLabel: string, -): Promise { - if (!isRecallEnabled(localConfig, config)) return; - try { - const recallBlock = compileRecallRulesBlock(); - let injected = 0; - const { recallTargets } = await resolveInstructionTargets(config, localConfig); - for (const { path: claudeMdPath, tools } of recallTargets) { - try { - await injectClaudeMdSection( - claudeMdPath, - TEAMAI_RECALL_RULES_START, - TEAMAI_RECALL_RULES_END, - recallBlock, - ); - injected++; - log.debug(`Injected recall rules into ${claudeMdPath}`); - } catch (e) { - log.warn(`Failed to inject recall rules into ${tools.join(', ')} (${claudeMdPath}): ${(e as Error).message}`); - } - } - if (injected > 0) { - log.debug(`[${scopeLabel}] Injected recall rules into ${injected} instruction file(s)`); - } - } catch (e) { - log.debug(`[${scopeLabel}] Recall rules injection skipped: ${(e as Error).message}`); - } + const { targets, stale } = await resolveInstructionTargets(config, localConfig); + const plan = await planInstructionFiles(targets, blocks, stale); + for (const warning of plan.warnings) log.warn(`[${scopeLabel}] ${warning}`); + const { report, failures } = await applyInstructionPlan(plan, { dryRun }); + for (const line of report) { + if (dryRun) log.info(`[dry-run] ${line}`); + else if (line.startsWith('Removed')) log.info(`[${scopeLabel}] ${line}: no installed tool loads them from this file`); + else log.debug(line); + } + for (const failure of failures) log.warn(`[${scopeLabel}] ${failure}`); + if (dryRun || targets.length === 0) return; + if (blocks.culture) log.success('Synced team culture'); + if (blocks.claudemd) log.success(`[${scopeLabel}] Synced shared instructions (${claudemdFiles} file(s))`); } /** diff --git a/src/recall-toggle.ts b/src/recall-toggle.ts index 386909bca..70f15ac66 100644 --- a/src/recall-toggle.ts +++ b/src/recall-toggle.ts @@ -1,8 +1,8 @@ import path from 'node:path'; import { autoDetectInit, saveLocalConfigForScope } from './config.js'; import { log } from './utils/logger.js'; -import { readFileSafe, writeFile, remove, pathExists } from './utils/fs.js'; -import { knownInstructionTargets, resolveInstructionTargets } from './instruction-targets.js'; +import { remove, pathExists } from './utils/fs.js'; +import { applyInstructionPlan, planInstructionFiles, resolveInstructionTargets } from './instruction-targets.js'; import { ALL_SUPPORTED_TOOLS, agentFileExtensionForTool, @@ -15,8 +15,6 @@ import { isRecallEnabled, isAgentExcluded, scopedToolPaths, - TEAMAI_RECALL_RULES_START, - TEAMAI_RECALL_RULES_END, type GlobalOptions, type TeamaiConfig, type LocalConfig, @@ -67,24 +65,20 @@ async function removeRecallArtifacts(teamConfig: TeamaiConfig, localConfig: Loca } // Remove the recall block from every file teamai may have written it to. - for (const claudeMdPath of knownInstructionTargets(teamConfig, localConfig)) { - const content = await readFileSafe(claudeMdPath); - if (content && content.includes(TEAMAI_RECALL_RULES_START)) { - const startIdx = content.indexOf(TEAMAI_RECALL_RULES_START); - const endIdx = content.indexOf(TEAMAI_RECALL_RULES_END); - if (startIdx !== -1 && endIdx !== -1) { - const before = content.substring(0, startIdx).replace(/\n+$/, '\n'); - const after = content.substring(endIdx + TEAMAI_RECALL_RULES_END.length).replace(/^\n+/, '\n'); - const cleaned = (before + after).trim(); - if (cleaned.length === 0) { - await remove(claudeMdPath); - } else { - await writeFile(claudeMdPath, cleaned + '\n'); - } - log.debug(`Removed recall rules block from ${claudeMdPath}`); - } - } - } + await writeRecallBlock(teamConfig, localConfig, null); +} + +/** Set (`block`) or remove (`null`) the recall block wherever teamai delivers instruction blocks. */ +async function writeRecallBlock(teamConfig: TeamaiConfig, localConfig: LocalConfig, block: string | null): Promise { + const { targets, stale } = await resolveInstructionTargets(teamConfig, localConfig); + // Removal also reaches files no installed tool reads any more, but leaves + // their other blocks to the next pull's cleanup. + const files = block === null ? [...targets, ...stale] : targets; + const plan = await planInstructionFiles(files, { recall: block }); + for (const warning of plan.warnings) log.warn(warning); + const { report, failures } = await applyInstructionPlan(plan, { dryRun: false }); + for (const line of report) log.debug(line); + for (const failure of failures) log.warn(failure); } async function deployRecallArtifacts(teamConfig: TeamaiConfig, localConfig: LocalConfig): Promise { @@ -96,24 +90,8 @@ async function deployRecallArtifacts(teamConfig: TeamaiConfig, localConfig: Loca await deployBuiltinAgents(teamConfig, localConfig, { skipRecall: false }); await deployBuiltinSkills(teamConfig, localConfig); - // Inject recall rules block into CLAUDE.md for Tier-1 tools - const { injectClaudeMdSection } = await import('./utils/claudemd.js'); const { compileRecallRulesBlock } = await import('./pull.js'); - const recallBlock = compileRecallRulesBlock(); - - const { recallTargets } = await resolveInstructionTargets(teamConfig, localConfig); - for (const { path: claudeMdPath } of recallTargets) { - try { - await injectClaudeMdSection( - claudeMdPath, - TEAMAI_RECALL_RULES_START, - TEAMAI_RECALL_RULES_END, - recallBlock, - ); - } catch { - // best-effort - } - } + await writeRecallBlock(teamConfig, localConfig, compileRecallRulesBlock()); } export async function recallDisable(opts: GlobalOptions): Promise { From 69c71b3011e439762d5f2186644160248d9ddd44 Mon Sep 17 00:00:00 2001 From: Saul Moro Date: Thu, 1 Oct 2026 14:23:51 +0200 Subject: [PATCH 03/41] fix(pull): give Claude its project blocks in .claude/rules (#945) Claude's project-scope culture, claudemd and recall blocks move from .claude/CLAUDE.md to .claude/rules/teamai-context.md. Claude loads that file from the root and from subdirectories, and still reads AGENTS.md and an authored CLAUDE.md the way it chose to; CLAUDE.local.md would have stopped the native AGENTS.md load. The next pull removes the old blocks from .claude/CLAUDE.md and keeps the rest of the file. teamai-context is excluded from the rules sweep and from push, so the file is neither deleted as stale nor pushed as a team rule. An e2e test pulls as two members of one project with different roles: each gets their own selection and AGENTS.md keeps its bytes. --- docs/usage-guide.md | 24 +++++++--- docs/usage-guide.zh-CN.md | 24 +++++++--- src/__tests__/e2e/instruction-targets.test.ts | 44 +++++++++++++++++++ src/builtin-rules.ts | 7 +++ src/instruction-targets.ts | 10 ++++- 5 files changed, 98 insertions(+), 11 deletions(-) diff --git a/docs/usage-guide.md b/docs/usage-guide.md index dcde36d23..1f5bd3b87 100644 --- a/docs/usage-guide.md +++ b/docs/usage-guide.md @@ -1615,7 +1615,7 @@ team: | `team.mission` | string | Team mission | | `team.goals` | string[] | Team goals | -The markdown body after the frontmatter becomes the body content of the team culture guidance, injected as a whole into `CLAUDE.md`. +The markdown body after the frontmatter becomes the body content of the team culture guidance, injected as a whole into each AI tool's instruction target (see [Where the blocks go](#where-the-blocks-go)). ### How it works @@ -1632,11 +1632,11 @@ teamai pull │ ├─ frontmatter → structured company/team info │ └─ body → team culture guidance body │ - ▼ Compile into a CLAUDE.md injection block + ▼ Compile into an injection block │ - ▼ Inject into each AI tool's CLAUDE.md - ├─ ~/.claude/CLAUDE.md - ├─ ~/.cursor/CLAUDE.md + ▼ Write it to each installed AI tool's instruction target + ├─ ~/.claude/CLAUDE.md (user scope) + ├─ /.claude/rules/teamai-context.md (project scope) └─ ... ``` @@ -1644,6 +1644,20 @@ The injected content sits between the `` and `/.claude/rules/teamai-context.md (项目范围) └─ ... ``` @@ -1522,6 +1522,20 @@ teamai pull pull 只把团队文化、共享指令和 recall 块写入已安装 AI 工具的文件;块内容未变化时不改写文件。若之前某次 pull 写过这些块的文件已没有任何已安装工具读取(例如 Hermes 和 WorkBuddy 都已移除后的 `~/AGENTS.md`),下一次 pull 会移除其中的 teamai 块,并在输出中列出该文件。文件中没有其他内容时一并删除该文件,但 git 跟踪的文件不会被删除。标记缺失或重复的块保持原样,并提示手动修复。`teamai pull --dry-run` 只列出将要修改的文件,不写入。recall 关闭时,pull 会移除 recall 块。 +#### 这些块写到哪里 + +同一项目的两名成员可能角色不同,因此他们的共享指令(`claudemd/`)可能不同。项目根目录的 `AGENTS.md` 存放项目为所有人编写的指令,所以 teamai 不会把这些块写入它,也不会写入 `~/AGENTS.md`、`~/.agents/AGENTS.md` 或其他工具读取的文件。每个工具通过自己的文件或会话钩子获得这些块: + +| 工具 | 用户范围 | 项目范围 | +|---|---|---| +| Claude Code | `~/.claude/CLAUDE.md` | `.claude/rules/teamai-context.md` | + +早期版本的 pull 可能把这些块留在下列文件中。下一次 pull 会移除它们,并列出所修改的每个文件: + +- Claude Code,项目范围:`.claude/CLAUDE.md` + +与 teamai 目标同名但并非 teamai 写入的文件保持不变,pull 会给出警告。teamai 不修改 `.gitignore`、`.git/info/exclude` 或 git 索引。若团队希望这些文件不进入提交,需要自行排除。 + ### 查看效果 pull 后可以直接查看 AI 工具的 CLAUDE.md: diff --git a/src/__tests__/e2e/instruction-targets.test.ts b/src/__tests__/e2e/instruction-targets.test.ts index cd5859ac2..dbcb89526 100644 --- a/src/__tests__/e2e/instruction-targets.test.ts +++ b/src/__tests__/e2e/instruction-targets.test.ts @@ -296,4 +296,48 @@ describe('instruction block targets on real CLI pull (#945)', () => { expect(second.code, second.output).toBe(0); expect(written.map((file) => fs.statSync(file).mtimeMs)).toEqual(mtimes); }); + + it('gives two members of one project their own role selection without touching the shared AGENTS.md (Claude)', async () => { + const sandbox = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'teamai-945-e2e-'))); + sandboxes.push(sandbox); + const fixture = makeTeamAndProject(sandbox); + const developer = makeProjectMember(sandbox, fixture, 'dev', 'developer', ['.claude/skills']); + const product = makeProjectMember(sandbox, fixture, 'pm', 'product', ['.claude/skills']); + const context = (member: ProjectMember): string => + fs.readFileSync(path.join(member.projectRoot, '.claude', 'rules', 'teamai-context.md'), 'utf8'); + + for (const member of [developer, product, developer]) { + const result = await pullAs(member); + expect(result.code, result.output).toBe(0); + } + + expect(context(developer)).toContain('COMMON-SENTINEL'); + expect(context(developer)).toContain('DEVELOPMENT-SENTINEL'); + expect(context(developer)).not.toContain('PRODUCT-SENTINEL'); + expect(context(developer)).toContain(CULTURE_START); + expect(context(developer)).toContain(RECALL_START); + expect(context(product)).toContain('COMMON-SENTINEL'); + expect(context(product)).toContain('PRODUCT-SENTINEL'); + expect(context(product)).not.toContain('DEVELOPMENT-SENTINEL'); + for (const member of [developer, product]) { + expect(fs.readFileSync(path.join(member.projectRoot, 'AGENTS.md'), 'utf8')).toBe(PROJECT_AGENTS_MD); + expect(fs.existsSync(path.join(member.projectRoot, '.claude', 'CLAUDE.md'))).toBe(false); + expect(fs.existsSync(path.join(member.projectRoot, 'CLAUDE.local.md'))).toBe(false); + } + }); + + it('moves Claude blocks an earlier release left in .claude/CLAUDE.md, keeping the authored text', async () => { + const sandbox = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'teamai-945-e2e-'))); + sandboxes.push(sandbox); + const member = makeProjectMember(sandbox, makeTeamAndProject(sandbox), 'dev', 'developer', ['.claude/skills']); + const legacy = path.join(member.projectRoot, '.claude', 'CLAUDE.md'); + fs.writeFileSync(legacy, `# Team notes\n\n${CLAUDEMD_START}\nold selection\n${CLAUDEMD_END}\n`); + + const result = await pullAs(member); + expect(result.code, result.output).toBe(0); + + expect(result.output).toContain(`Removed teamai instruction blocks from ${legacy}`); + expect(fs.readFileSync(legacy, 'utf8')).toBe('# Team notes\n'); + expect(fs.readFileSync(path.join(member.projectRoot, '.claude', 'rules', 'teamai-context.md'), 'utf8')).toContain('DEVELOPMENT-SENTINEL'); + }); }); diff --git a/src/builtin-rules.ts b/src/builtin-rules.ts index 89048d847..cbe19bbb6 100644 --- a/src/builtin-rules.ts +++ b/src/builtin-rules.ts @@ -31,6 +31,12 @@ export const BUILTIN_RULE_NAMES = new Set(['teamai-recall']); /** Names of previously deployed rules that should be cleaned up. */ export const LEGACY_RULE_NAMES: string[] = []; +/** + * The rule file a pull writes the culture, claudemd and recall blocks into + * for tools whose rules directory is their instruction target (#945). + */ +export const TEAMAI_CONTEXT_RULE_NAME = 'teamai-context'; + /** * Names that scanLocalForPush and stale-cleanup should skip. * Includes both current built-in rules and legacy rules (being cleaned up). @@ -38,6 +44,7 @@ export const LEGACY_RULE_NAMES: string[] = []; export const EXCLUDED_RULE_NAMES = new Set([ ...BUILTIN_RULE_NAMES, ...LEGACY_RULE_NAMES, + TEAMAI_CONTEXT_RULE_NAME, ]); /** diff --git a/src/instruction-targets.ts b/src/instruction-targets.ts index 7483bccee..915dbdc27 100644 --- a/src/instruction-targets.ts +++ b/src/instruction-targets.ts @@ -2,6 +2,7 @@ import path from 'node:path'; import { isToolInstalledForConfig } from './resources/base.js'; import { readFileSafe, remove, writeFile } from './utils/fs.js'; import { gitTracking, gitTracks } from './mcp-git-exclude.js'; +import { TEAMAI_CONTEXT_RULE_NAME } from './builtin-rules.js'; import { isAgentExcluded, resolveToolBaseDir, @@ -49,6 +50,10 @@ interface TargetEntry { /** The tool's `claudemd` path from the team's `toolPaths` (honors `toolRoots`). */ const configured = (paths: ToolPaths): string | undefined => paths.claudemd; +/** teamai's own always-applied file in the tool's rules directory. */ +const contextRule = (extension: string) => (paths: ToolPaths): string | undefined => + paths.rules === undefined ? undefined : path.posix.join(paths.rules, `${TEAMAI_CONTEXT_RULE_NAME}${extension}`); + // One line per tool, so a change to one tool's target edits one line. const USER_TARGETS: Readonly> = { claude: { file: configured, retired: [] }, @@ -64,7 +69,10 @@ const USER_TARGETS: Readonly> = { }; const PROJECT_TARGETS: Readonly> = { - claude: { file: configured, retired: [] }, + // Claude loads every unscoped .claude/rules file from the root and any + // subdirectory, and still reads AGENTS.md and an authored CLAUDE.md as it + // chose to. CLAUDE.local.md would stop the native AGENTS.md load (#945). + claude: { file: contextRule('.md'), owned: true, retired: ['.claude/CLAUDE.md'] }, 'claude-internal': { file: configured, retired: [] }, tclaude: { file: configured, retired: [] }, hermes: { file: configured, retired: [] }, From c1c7152b1e455f7281af451578385eeead07aa86 Mon Sep 17 00:00:00 2001 From: Saul Moro Date: Thu, 1 Oct 2026 14:26:45 +0200 Subject: [PATCH 04/41] fix(pull): give Cursor an always-applied teamai-context.mdc (#945) Cursor had no instruction target and only saw the blocks other tools left in AGENTS.md and .claude/CLAUDE.md. It now gets them in .cursor/rules/teamai-context.mdc with alwaysApply: true, in both scopes. Uninstall and local-agent go through the same planner as pull, so a teamai-context file is removed whole, header included, and is created with its header. --- docs/usage-guide.md | 3 ++ docs/usage-guide.zh-CN.md | 3 ++ src/__tests__/e2e/instruction-targets.test.ts | 26 +++++++++++++ src/__tests__/instruction-targets.test.ts | 18 +++++++++ src/instruction-targets.ts | 39 +++++++++++++++++-- src/local-agent.ts | 23 +++++------ src/uninstall.ts | 11 ++---- 7 files changed, 99 insertions(+), 24 deletions(-) diff --git a/docs/usage-guide.md b/docs/usage-guide.md index 1f5bd3b87..4ec8f6a2f 100644 --- a/docs/usage-guide.md +++ b/docs/usage-guide.md @@ -1651,6 +1651,9 @@ Two members of the same project can have different roles, so their shared instru | Tool | User scope | Project scope | |---|---|---| | Claude Code | `~/.claude/CLAUDE.md` | `.claude/rules/teamai-context.md` | +| Cursor | `~/.cursor/rules/teamai-context.mdc` | `.cursor/rules/teamai-context.mdc` | + +Cursor applies both `teamai-context.mdc` files in every session (`alwaysApply: true`). Cursor CLI reads `~/.cursor/rules` when the session starts under your home directory; the Cursor IDE was not checked. A pull from an earlier release may have left these blocks in a file listed below. The next pull removes them, and names each file it changes: diff --git a/docs/usage-guide.zh-CN.md b/docs/usage-guide.zh-CN.md index 3558eebec..2f342dc1c 100644 --- a/docs/usage-guide.zh-CN.md +++ b/docs/usage-guide.zh-CN.md @@ -1529,6 +1529,9 @@ pull 只把团队文化、共享指令和 recall 块写入已安装 AI 工具的 | 工具 | 用户范围 | 项目范围 | |---|---|---| | Claude Code | `~/.claude/CLAUDE.md` | `.claude/rules/teamai-context.md` | +| Cursor | `~/.cursor/rules/teamai-context.mdc` | `.cursor/rules/teamai-context.mdc` | + +Cursor 在每个会话中应用这两个 `teamai-context.mdc` 文件(`alwaysApply: true`)。Cursor CLI 仅在会话从主目录下启动时读取 `~/.cursor/rules`;Cursor IDE 未经验证。 早期版本的 pull 可能把这些块留在下列文件中。下一次 pull 会移除它们,并列出所修改的每个文件: diff --git a/src/__tests__/e2e/instruction-targets.test.ts b/src/__tests__/e2e/instruction-targets.test.ts index dbcb89526..a888a10f2 100644 --- a/src/__tests__/e2e/instruction-targets.test.ts +++ b/src/__tests__/e2e/instruction-targets.test.ts @@ -340,4 +340,30 @@ describe('instruction block targets on real CLI pull (#945)', () => { expect(fs.readFileSync(legacy, 'utf8')).toBe('# Team notes\n'); expect(fs.readFileSync(path.join(member.projectRoot, '.claude', 'rules', 'teamai-context.md'), 'utf8')).toContain('DEVELOPMENT-SENTINEL'); }); + + it('gives Cursor an always-applied teamai-context.mdc in both scopes', async () => { + const sandbox = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'teamai-945-e2e-'))); + sandboxes.push(sandbox); + const member = makeProjectMember(sandbox, makeTeamAndProject(sandbox), 'dev', 'developer', ['.cursor/skills']); + const projectRule = path.join(member.projectRoot, '.cursor', 'rules', 'teamai-context.mdc'); + + for (let i = 0; i < 2; i++) { + const result = await pullAs(member); + expect(result.code, result.output).toBe(0); + } + const rule = fs.readFileSync(projectRule, 'utf8'); + expect(rule.startsWith('---\nalwaysApply: true\n---\n\n')).toBe(true); + expect(rule).toContain('DEVELOPMENT-SENTINEL'); + expect(rule).toContain(RECALL_START); + expect(fs.readFileSync(path.join(member.projectRoot, 'AGENTS.md'), 'utf8')).toBe(PROJECT_AGENTS_MD); + + const user = makeUserSandbox(['.cursor']); + sandboxes.push(user.sandbox); + const userPull = await runCLI(['pull'], { HOME: user.home }, user.sandbox); + expect(userPull.code, userPull.output).toBe(0); + const userRule = fs.readFileSync(path.join(user.home, '.cursor', 'rules', 'teamai-context.mdc'), 'utf8'); + expect(userRule.startsWith('---\nalwaysApply: true\n---\n\n')).toBe(true); + expect(userRule).toContain(CLAUDEMD_START); + expect(fs.existsSync(path.join(user.home, 'AGENTS.md'))).toBe(false); + }); }); diff --git a/src/__tests__/instruction-targets.test.ts b/src/__tests__/instruction-targets.test.ts index 5c40ff290..2438546a3 100644 --- a/src/__tests__/instruction-targets.test.ts +++ b/src/__tests__/instruction-targets.test.ts @@ -5,6 +5,7 @@ import os from 'node:os'; import path from 'node:path'; import { applyInstructionPlan, + clearInstructionFile, planInstructionFiles, type InstructionTarget, } from '../instruction-targets.js'; @@ -133,4 +134,21 @@ describe('instruction file planning (#945)', () => { await applyInstructionPlan(await planInstructionFiles([owned], { culture: null, claudemd: null }), { dryRun: false }); expect(fs.existsSync(file)).toBe(false); }); + + it('clears teamai\'s own teamai-context file whole, header included, on uninstall', async () => { + const file = path.join(dir, 'teamai-context.mdc'); + fs.writeFileSync(file, `---\nalwaysApply: true\n---\n\n${culture('c')}\n`); + + expect((await clearInstructionFile(file)).changed).toBe(true); + expect(fs.existsSync(file)).toBe(false); + }); + + it('keeps the member\'s text when uninstall clears another instruction file', async () => { + const file = path.join(dir, 'CLAUDE.md'); + fs.writeFileSync(file, `# Mine\n\n${claudemd('s')}\n`); + + await clearInstructionFile(file); + + expect(fs.readFileSync(file, 'utf8')).toBe('# Mine\n'); + }); }); diff --git a/src/instruction-targets.ts b/src/instruction-targets.ts index 915dbdc27..9e21fd8ef 100644 --- a/src/instruction-targets.ts +++ b/src/instruction-targets.ts @@ -54,9 +54,15 @@ const configured = (paths: ToolPaths): string | undefined => paths.claudemd; const contextRule = (extension: string) => (paths: ToolPaths): string | undefined => paths.rules === undefined ? undefined : path.posix.join(paths.rules, `${TEAMAI_CONTEXT_RULE_NAME}${extension}`); +/** Cursor applies an `.mdc` rule in every session only with this frontmatter. */ +const CURSOR_ALWAYS_APPLY = '---\nalwaysApply: true\n---\n'; +const cursor: TargetEntry = { file: contextRule('.mdc'), header: CURSOR_ALWAYS_APPLY, owned: true, retired: [] }; + // One line per tool, so a change to one tool's target edits one line. const USER_TARGETS: Readonly> = { claude: { file: configured, retired: [] }, + // Cursor CLI reads ~/.cursor/rules when the session starts under $HOME. + cursor, 'claude-internal': { file: configured, retired: [] }, tclaude: { file: configured, retired: [] }, hermes: { file: configured, retired: [] }, @@ -73,6 +79,7 @@ const PROJECT_TARGETS: Readonly> = { // subdirectory, and still reads AGENTS.md and an authored CLAUDE.md as it // chose to. CLAUDE.local.md would stop the native AGENTS.md load (#945). claude: { file: contextRule('.md'), owned: true, retired: ['.claude/CLAUDE.md'] }, + cursor, 'claude-internal': { file: configured, retired: [] }, tclaude: { file: configured, retired: [] }, hermes: { file: configured, retired: [] }, @@ -146,7 +153,8 @@ export function instructionTargetPath( return file === undefined ? undefined : path.resolve(resolveToolBaseDir(tool, localConfig), file); } -function targetFor(tool: string, file: string, scope: Scope): InstructionTarget { +/** The target `tool` reads from `file` in `scope`, with the header and ownership its entry declares. */ +export function instructionTargetAt(tool: string, file: string, scope: Scope): InstructionTarget { const entry = entryFor(tool, scope); return { path: file, tools: [], recall: false, header: entry?.header, owned: entry?.owned }; } @@ -159,7 +167,7 @@ function knownInstructionTargets(teamConfig: TeamaiConfig, localConfig: LocalCon const known = new Map(); for (const [tool, paths] of Object.entries(scopedToolPaths(teamConfig, localConfig))) { const file = instructionTargetPath(tool, paths, localConfig); - if (file && !known.has(file)) known.set(file, targetFor(tool, file, localConfig.scope)); + if (file && !known.has(file)) known.set(file, instructionTargetAt(tool, file, localConfig.scope)); } const table = localConfig.scope === 'user' ? USER_TARGETS : PROJECT_TARGETS; for (const [tool, entry] of Object.entries(table)) { @@ -196,7 +204,7 @@ export async function resolveInstructionTargets( if (!file || !await isInstalled(tool, paths, localConfig)) continue; inUse.add(file); if (isAgentExcluded(localConfig, tool)) continue; - const target = targets.get(file) ?? targetFor(tool, file, localConfig.scope); + const target = targets.get(file) ?? instructionTargetAt(tool, file, localConfig.scope); target.tools.push(tool); if (paths.agents) target.recall = true; targets.set(file, target); @@ -295,6 +303,31 @@ async function planFile( return { path: target.path, content, kind }; } +/** Every header a target writes, so a file teamai created can be recognised later. */ +const KNOWN_HEADERS = [CURSOR_ALWAYS_APPLY]; + +/** + * Remove every teamai instruction block from `file`, as uninstall does. A + * `teamai-context` file is teamai's own and goes once its blocks are gone; + * another file goes only if nothing else was in it and git does not track it. + * Returns warnings about blocks it could not delimit. + */ +export async function clearInstructionFile(file: string): Promise<{ changed: boolean; warnings: string[] }> { + const existing = await readFileSafe(file); + if (existing === null) return { changed: false, warnings: [] }; + const target: InstructionTarget = { + path: file, + tools: [], + recall: false, + header: KNOWN_HEADERS.find((header) => existing.startsWith(header)), + owned: path.basename(file).startsWith(`${TEAMAI_CONTEXT_RULE_NAME}.`), + }; + const plan = await planInstructionFiles([], {}, [target]); + const { failures } = await applyInstructionPlan(plan, { dryRun: false }); + if (failures.length > 0) throw new Error(failures.join(' ')); + return { changed: plan.changes.length > 0, warnings: plan.warnings }; +} + /** * Work out every change that delivers `blocks` to `targets` and strips teamai * blocks from `stale` files, without writing anything. diff --git a/src/local-agent.ts b/src/local-agent.ts index 227dfb904..4708c6f86 100644 --- a/src/local-agent.ts +++ b/src/local-agent.ts @@ -52,8 +52,7 @@ import { } from './mcp-reconcile.js'; import { normalizeAgentType } from './utils/tool-names.js'; import { logHttpRequest, logHttpResponse } from './utils/http-log.js'; -import { injectClaudeMdSection, removeClaudeMdSection } from './utils/claudemd.js'; -import { instructionTargetFile } from './instruction-targets.js'; +import { applyInstructionPlan, instructionTargetAt, instructionTargetFile, planInstructionFiles } from './instruction-targets.js'; import { reconcilePlugins, teardownAllPlugins, parseGetConfig, substituteVars, unresolvedPlaceholders, type ReconcileDeps, type PluginState } from './plugin-lifecycle.js'; import { resolveBaseDir, @@ -2127,19 +2126,15 @@ async function syncClaudemd( } const claudeMdPath = resolvedAbsPath ?? path.resolve(baseDir, targetFile); - try { - if (block) { - await injectClaudeMdSection(claudeMdPath, TEAMAI_CLAUDEMD_START, TEAMAI_CLAUDEMD_END, block); - log.debug(`local-agent: synced CLAUDE.md instructions to ${tool}`); - syncedAny = true; - } else { - await removeClaudeMdSection(claudeMdPath, TEAMAI_CLAUDEMD_START, TEAMAI_CLAUDEMD_END); - log.debug(`local-agent: removed CLAUDE.md instructions from ${tool}`); - syncedAny = true; - } - } catch (e) { - log.warn(`Failed to sync CLAUDE.md instructions to ${tool}: ${(e as Error).message}`); + const plan = await planInstructionFiles([instructionTargetAt(tool, claudeMdPath, localConfig.scope)], { claudemd: block }); + for (const warning of plan.warnings) log.warn(warning); + const { failures } = await applyInstructionPlan(plan, { dryRun: false }); + if (failures.length > 0) { + log.warn(`Failed to sync CLAUDE.md instructions to ${tool}: ${failures.join(' ')}`); + continue; } + log.debug(`local-agent: ${block ? 'synced' : 'removed'} CLAUDE.md instructions for ${tool}`); + syncedAny = true; } if (files.length > 0 && !syncedAny) { diff --git a/src/uninstall.ts b/src/uninstall.ts index aa76da5a0..e0fc8cb6f 100644 --- a/src/uninstall.ts +++ b/src/uninstall.ts @@ -54,7 +54,7 @@ import { } from './builtin-skills.js'; import { getHermesHome } from './hermes-home.js'; import { CODEX_TOOL, SHARED_AGENT_SKILLS_PATH } from './resources/skills.js'; -import { instructionTargetFile } from './instruction-targets.js'; +import { clearInstructionFile, instructionTargetFile } from './instruction-targets.js'; import { pathExists, readFileSafe, @@ -1043,12 +1043,9 @@ async function executeRemoval(plan: RemovalPlan): Promise { // (b) Clean CLAUDE.md teamai section blocks for (const { path: claudeMdPath, blocks } of plan.claudeMdFiles) { try { - // A file teamai created goes with its last block; a member's file, - // even an empty one, stays. - for (const [startMarker, endMarker] of blocks) { - await removeClaudeMdSection(claudeMdPath, startMarker, endMarker, { deleteIfEmpty: true }); - } - log.success(`Cleaned ${claudeMdPath}`); + const { changed, warnings } = await clearInstructionFile(claudeMdPath); + for (const warning of warnings) log.warn(warning); + if (changed) log.success(`Cleaned CLAUDE.md: ${claudeMdPath}`); } catch (e) { log.warn(`Failed to clean ${claudeMdPath}: ${(e as Error).message}`); } From 578479783db2f894338a976cd70803004cde3268 Mon Sep 17 00:00:00 2001 From: Saul Moro Date: Thu, 1 Oct 2026 14:31:41 +0200 Subject: [PATCH 05/41] fix(pull): give CodeBuddy and WorkBuddy their own rule files (#945) CodeBuddy and WorkBuddy both read the project's .codebuddy/rules, so their project blocks share one always-applied .codebuddy/rules/teamai-context.md instead of .codebuddy/CODEBUDDY.md and the project AGENTS.md. WorkBuddy's user blocks move from ~/AGENTS.md to ~/.workbuddy/rules/teamai-context.md. Uninstalling one of the two keeps the shared copy while the other is installed. The shared AGENTS.md is cleaned only once no installed tool still targets it; Pi and Hermes keep it until their own channels land. --- docs/usage-guide.md | 6 +++ docs/usage-guide.zh-CN.md | 6 +++ src/__tests__/e2e/instruction-targets.test.ts | 39 +++++++++++++++++++ src/__tests__/local-agent.test.ts | 6 +-- src/__tests__/uninstall.test.ts | 39 +++++-------------- src/instruction-targets.ts | 24 ++++++++---- 6 files changed, 81 insertions(+), 39 deletions(-) diff --git a/docs/usage-guide.md b/docs/usage-guide.md index 4ec8f6a2f..78eda971d 100644 --- a/docs/usage-guide.md +++ b/docs/usage-guide.md @@ -1652,12 +1652,18 @@ Two members of the same project can have different roles, so their shared instru |---|---|---| | Claude Code | `~/.claude/CLAUDE.md` | `.claude/rules/teamai-context.md` | | Cursor | `~/.cursor/rules/teamai-context.mdc` | `.cursor/rules/teamai-context.mdc` | +| CodeBuddy | `~/.codebuddy/CODEBUDDY.md` | `.codebuddy/rules/teamai-context.md`, one copy shared with WorkBuddy | +| WorkBuddy | `~/.workbuddy/rules/teamai-context.md` | `.codebuddy/rules/teamai-context.md`, one copy shared with CodeBuddy | Cursor applies both `teamai-context.mdc` files in every session (`alwaysApply: true`). Cursor CLI reads `~/.cursor/rules` when the session starts under your home directory; the Cursor IDE was not checked. +The CodeBuddy and WorkBuddy rule files are applied in every session (`alwaysApply: true`). Uninstalling one of the two keeps the shared project copy while the other is still installed. + A pull from an earlier release may have left these blocks in a file listed below. The next pull removes them, and names each file it changes: - Claude Code, project scope: `.claude/CLAUDE.md` +- CodeBuddy, project scope: `.codebuddy/CODEBUDDY.md` +- WorkBuddy: `~/AGENTS.md` and the project `AGENTS.md` A file named like a teamai target that teamai did not write is left alone, and the pull warns about it. teamai does not change `.gitignore`, `.git/info/exclude` or the git index. A team that wants to keep these files out of commits excludes them itself. diff --git a/docs/usage-guide.zh-CN.md b/docs/usage-guide.zh-CN.md index 2f342dc1c..a54c38856 100644 --- a/docs/usage-guide.zh-CN.md +++ b/docs/usage-guide.zh-CN.md @@ -1530,12 +1530,18 @@ pull 只把团队文化、共享指令和 recall 块写入已安装 AI 工具的 |---|---|---| | Claude Code | `~/.claude/CLAUDE.md` | `.claude/rules/teamai-context.md` | | Cursor | `~/.cursor/rules/teamai-context.mdc` | `.cursor/rules/teamai-context.mdc` | +| CodeBuddy | `~/.codebuddy/CODEBUDDY.md` | `.codebuddy/rules/teamai-context.md`,与 WorkBuddy 共用一份 | +| WorkBuddy | `~/.workbuddy/rules/teamai-context.md` | `.codebuddy/rules/teamai-context.md`,与 CodeBuddy 共用一份 | Cursor 在每个会话中应用这两个 `teamai-context.mdc` 文件(`alwaysApply: true`)。Cursor CLI 仅在会话从主目录下启动时读取 `~/.cursor/rules`;Cursor IDE 未经验证。 +CodeBuddy 和 WorkBuddy 的规则文件在每个会话中应用(`alwaysApply: true`)。卸载其中一个工具时,只要另一个仍已安装,项目中共用的那份文件就会保留。 + 早期版本的 pull 可能把这些块留在下列文件中。下一次 pull 会移除它们,并列出所修改的每个文件: - Claude Code,项目范围:`.claude/CLAUDE.md` +- CodeBuddy,项目范围:`.codebuddy/CODEBUDDY.md` +- WorkBuddy:`~/AGENTS.md` 和项目 `AGENTS.md` 与 teamai 目标同名但并非 teamai 写入的文件保持不变,pull 会给出警告。teamai 不修改 `.gitignore`、`.git/info/exclude` 或 git 索引。若团队希望这些文件不进入提交,需要自行排除。 diff --git a/src/__tests__/e2e/instruction-targets.test.ts b/src/__tests__/e2e/instruction-targets.test.ts index a888a10f2..14603f90f 100644 --- a/src/__tests__/e2e/instruction-targets.test.ts +++ b/src/__tests__/e2e/instruction-targets.test.ts @@ -366,4 +366,43 @@ describe('instruction block targets on real CLI pull (#945)', () => { expect(userRule).toContain(CLAUDEMD_START); expect(fs.existsSync(path.join(user.home, 'AGENTS.md'))).toBe(false); }); + + it('gives CodeBuddy and WorkBuddy one shared rule file and keeps it while either tool remains', async () => { + const sandbox = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'teamai-945-e2e-'))); + sandboxes.push(sandbox); + const member = makeProjectMember(sandbox, makeTeamAndProject(sandbox), 'dev', 'developer', ['.codebuddy/skills', '.workbuddy/skills']); + const rule = path.join(member.projectRoot, '.codebuddy', 'rules', 'teamai-context.md'); + const legacy = path.join(member.projectRoot, '.codebuddy', 'CODEBUDDY.md'); + const agentsMd = path.join(member.projectRoot, 'AGENTS.md'); + fs.writeFileSync(legacy, `# CodeBuddy notes\n\n${CLAUDEMD_START}\nold selection\n${CLAUDEMD_END}\n`); + fs.writeFileSync(agentsMd, `${PROJECT_AGENTS_MD}\n${CULTURE_START}\nold culture\n${CULTURE_END}\n`); + + const result = await pullAs(member); + expect(result.code, result.output).toBe(0); + + const content = fs.readFileSync(rule, 'utf8'); + expect(content.startsWith('---\nalwaysApply: true\n---\n\n')).toBe(true); + expect(content).toContain('DEVELOPMENT-SENTINEL'); + expect(content.split(CLAUDEMD_START).length - 1).toBe(1); + expect(fs.existsSync(path.join(member.projectRoot, '.workbuddy', 'rules', 'teamai-context.md'))).toBe(false); + expect(fs.readFileSync(legacy, 'utf8')).toBe('# CodeBuddy notes\n'); + expect(fs.readFileSync(agentsMd, 'utf8')).toBe(PROJECT_AGENTS_MD); + + const uninstall = await runCLI(['uninstall', '--agent', 'workbuddy', '--force'], { HOME: member.home }, member.projectRoot); + expect(uninstall.code, uninstall.output).toBe(0); + expect(fs.readFileSync(rule, 'utf8')).toContain('DEVELOPMENT-SENTINEL'); + }); + + it('gives WorkBuddy its user blocks in ~/.workbuddy/rules, not ~/AGENTS.md', async () => { + const { sandbox, home } = makeUserSandbox(['.workbuddy']); + sandboxes.push(sandbox); + + const result = await runCLI(['pull'], { HOME: home }, sandbox); + expect(result.code, result.output).toBe(0); + + const rule = fs.readFileSync(path.join(home, '.workbuddy', 'rules', 'teamai-context.md'), 'utf8'); + expect(rule.startsWith('---\nalwaysApply: true\n---\n\n')).toBe(true); + expect(rule).toContain(CLAUDEMD_START); + expect(fs.existsSync(path.join(home, 'AGENTS.md'))).toBe(false); + }); }); diff --git a/src/__tests__/local-agent.test.ts b/src/__tests__/local-agent.test.ts index 70da0f394..7b47275cd 100644 --- a/src/__tests__/local-agent.test.ts +++ b/src/__tests__/local-agent.test.ts @@ -2039,14 +2039,14 @@ describe('local-agent: per-worktree claudemd isolation (issue #374 P1-2C)', () = syncFor = { ws: wtBReal, slug: 'b-doc' }; await reportAndSyncLocalAgent({ cwd: wtBReal, tool: 'codebuddy', status: 'running' }); - // B's injected claudemd (.codebuddy/CODEBUDDY.md) must contain ONLY B's + // B's injected claudemd (.codebuddy/rules/teamai-context.md, #945) must contain ONLY B's // instruction (the pre-fix shared cache made syncClaudemd merge A's in too). const readTxt = async (p: string) => (await fse.pathExists(p)) ? fse.readFile(p, 'utf-8') : ''; - const bClaudemd = await readTxt(path.join(wtBReal, '.codebuddy', 'CODEBUDDY.md')); + const bClaudemd = await readTxt(path.join(wtBReal, '.codebuddy', 'rules', 'teamai-context.md')); expect(bClaudemd).toContain('INSTRUCTION-FROM-B'); expect(bClaudemd).not.toContain('INSTRUCTION-FROM-A'); // A keeps only A's. - const aClaudemd = await readTxt(path.join(repo, '.codebuddy', 'CODEBUDDY.md')); + const aClaudemd = await readTxt(path.join(repo, '.codebuddy', 'rules', 'teamai-context.md')); expect(aClaudemd).toContain('INSTRUCTION-FROM-A'); expect(aClaudemd).not.toContain('INSTRUCTION-FROM-B'); diff --git a/src/__tests__/uninstall.test.ts b/src/__tests__/uninstall.test.ts index 20d26ee83..cec2ead80 100644 --- a/src/__tests__/uninstall.test.ts +++ b/src/__tests__/uninstall.test.ts @@ -584,54 +584,35 @@ describe('uninstall', () => { expect(await fse.readFile(projectPiHook, 'utf8')).toBe('// user-owned extension'); }); - it('targeted Pi uninstall preserves a shared AGENTS.md used by another installed agent', async () => { + it('targeted WorkBuddy uninstall keeps the .codebuddy rule CodeBuddy still reads (#945)', async () => { const { homeDir, repoPath } = await setupFixture(tmpDir); const projectRoot = path.join(tmpDir, 'business-repo'); vi.stubEnv('HOME', homeDir); vi.stubEnv('SHELL', '/bin/zsh'); - const sharedInstructions = path.join(projectRoot, 'AGENTS.md'); - const projectPiHook = path.join(projectRoot, '.pi', 'extensions', 'teamai-hooks.ts'); - await fse.ensureDir(path.dirname(projectPiHook)); - // WorkBuddy is actually installed here (its own resource dir exists), - // not just present in toolPaths — see the sibling "not installed" test. + // CodeBuddy and WorkBuddy share one project instruction file. + const sharedInstructions = path.join(projectRoot, '.codebuddy', 'rules', 'teamai-context.md'); + await fse.ensureDir(path.join(projectRoot, '.codebuddy', 'skills')); await fse.ensureDir(path.join(projectRoot, '.workbuddy', 'skills')); - // A block WorkBuddy's pull writes: the legacy rules block is kept for nobody. - await fse.writeFile(sharedInstructions, `${TEAMAI_CULTURE_START}\nteam culture\n${TEAMAI_CULTURE_END}\n`); - await fse.writeFile(projectPiHook, TEAMAI_PI_HOOK); + await fse.ensureDir(path.dirname(sharedInstructions)); + await fse.writeFile(sharedInstructions, `---\nalwaysApply: true\n---\n\n${TEAMAI_CULTURE_START}\nculture\n${TEAMAI_CULTURE_END}\n`); const teamConfig = makeTeamConfig({ toolPaths: { - pi: { - skills: '.pi/skills', - rules: '.pi/rules', - claudemd: 'AGENTS.md', - userScope: { - skills: '.pi/agent/skills', - rules: '.pi/agent/rules', - claudemd: '.pi/agent/AGENTS.md', - }, - }, - workbuddy: { - skills: '.workbuddy/skills', - rules: '.workbuddy/rules', - settings: '.workbuddy/settings.json', - claudemd: 'AGENTS.md', - }, + codebuddy: { skills: '.codebuddy/skills', rules: '.codebuddy/rules', settings: '.codebuddy/settings.json', claudemd: '.codebuddy/CODEBUDDY.md' }, + workbuddy: { skills: '.workbuddy/skills', rules: '.workbuddy/rules', settings: '.workbuddy/settings.json', claudemd: 'AGENTS.md' }, }, }); const localConfig = makeLocalConfig(homeDir, repoPath, { scope: 'project', projectRoot, - enabledAgents: ['pi', 'workbuddy'], + enabledAgents: ['codebuddy', 'workbuddy'], repo: { localPath: repoPath, remote: '', kind: 'self', businessRepoRoot: projectRoot }, }); mockAutoDetectInit.mockResolvedValue({ localConfig, teamConfig }); - await uninstall({ force: true, agent: 'pi' }); + await uninstall({ force: true, agent: 'workbuddy' }); - expect(await fse.pathExists(projectPiHook)).toBe(false); - expect(await fse.pathExists(sharedInstructions)).toBe(true); expect(await fse.readFile(sharedInstructions, 'utf8')).toContain(TEAMAI_CULTURE_START); }); diff --git a/src/instruction-targets.ts b/src/instruction-targets.ts index 9e21fd8ef..55cba9571 100644 --- a/src/instruction-targets.ts +++ b/src/instruction-targets.ts @@ -54,9 +54,18 @@ const configured = (paths: ToolPaths): string | undefined => paths.claudemd; const contextRule = (extension: string) => (paths: ToolPaths): string | undefined => paths.rules === undefined ? undefined : path.posix.join(paths.rules, `${TEAMAI_CONTEXT_RULE_NAME}${extension}`); -/** Cursor applies an `.mdc` rule in every session only with this frontmatter. */ -const CURSOR_ALWAYS_APPLY = '---\nalwaysApply: true\n---\n'; -const cursor: TargetEntry = { file: contextRule('.mdc'), header: CURSOR_ALWAYS_APPLY, owned: true, retired: [] }; +/** + * Cursor applies an `.mdc` rule in every session only with this frontmatter; + * CodeBuddy and WorkBuddy read the same key. + */ +const ALWAYS_APPLY = '---\nalwaysApply: true\n---\n'; +const cursor: TargetEntry = { file: contextRule('.mdc'), header: ALWAYS_APPLY, owned: true, retired: [] }; + +/** + * CodeBuddy and WorkBuddy both read the project's .codebuddy/rules, so they + * share one copy there; uninstalling one keeps it while the other remains. + */ +const codebuddyProjectRule = (): string => `.codebuddy/rules/${TEAMAI_CONTEXT_RULE_NAME}.md`; // One line per tool, so a change to one tool's target edits one line. const USER_TARGETS: Readonly> = { @@ -69,7 +78,8 @@ const USER_TARGETS: Readonly> = { copilot: { file: configured, retired: [] }, omp: { file: configured, retired: [] }, pi: { file: configured, retired: [] }, - workbuddy: { file: configured, 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: [] }, }; @@ -86,8 +96,8 @@ const PROJECT_TARGETS: Readonly> = { copilot: { file: configured, retired: [] }, omp: { file: configured, retired: [] }, pi: { file: configured, retired: [] }, - workbuddy: { file: configured, retired: [] }, - codebuddy: { file: configured, retired: [] }, + 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: [] }, }; @@ -304,7 +314,7 @@ async function planFile( } /** Every header a target writes, so a file teamai created can be recognised later. */ -const KNOWN_HEADERS = [CURSOR_ALWAYS_APPLY]; +const KNOWN_HEADERS = [ALWAYS_APPLY]; /** * Remove every teamai instruction block from `file`, as uninstall does. A From b86e39d130cf8d8077498050d8c2cb5c5f76c0b1 Mon Sep 17 00:00:00 2001 From: Saul Moro Date: Thu, 1 Oct 2026 14:34:13 +0200 Subject: [PATCH 06/41] fix(pull): give Hermes its user blocks in SOUL.md (#945) Hermes' user-scope culture and claudemd blocks move from ~/AGENTS.md, which Hermes does not read from a project under the home directory, to $HERMES_HOME/SOUL.md beside the team rules block teamai already writes there. Only a user-scope pull writes them, so a project pull leaves them alone. Hermes counts as installed when $HERMES_HOME exists, the same check rules delivery uses, instead of a ~/.hermes probe. --- docs/usage-guide.md | 2 + docs/usage-guide.zh-CN.md | 2 + src/__tests__/e2e/instruction-targets.test.ts | 44 +++++++++++++------ src/instruction-targets.ts | 9 +++- src/local-agent.ts | 2 + 5 files changed, 44 insertions(+), 15 deletions(-) diff --git a/docs/usage-guide.md b/docs/usage-guide.md index 78eda971d..1f999509a 100644 --- a/docs/usage-guide.md +++ b/docs/usage-guide.md @@ -1654,6 +1654,7 @@ Two members of the same project can have different roles, so their shared instru | Cursor | `~/.cursor/rules/teamai-context.mdc` | `.cursor/rules/teamai-context.mdc` | | CodeBuddy | `~/.codebuddy/CODEBUDDY.md` | `.codebuddy/rules/teamai-context.md`, one copy shared with WorkBuddy | | WorkBuddy | `~/.workbuddy/rules/teamai-context.md` | `.codebuddy/rules/teamai-context.md`, one copy shared with CodeBuddy | +| Hermes | A block in `$HERMES_HOME/SOUL.md`, beside the team rules block | (see below) | Cursor applies both `teamai-context.mdc` files in every session (`alwaysApply: true`). Cursor CLI reads `~/.cursor/rules` when the session starts under your home directory; the Cursor IDE was not checked. @@ -1664,6 +1665,7 @@ A pull from an earlier release may have left these blocks in a file listed below - Claude Code, project scope: `.claude/CLAUDE.md` - CodeBuddy, project scope: `.codebuddy/CODEBUDDY.md` - WorkBuddy: `~/AGENTS.md` and the project `AGENTS.md` +- Hermes: `~/AGENTS.md` A file named like a teamai target that teamai did not write is left alone, and the pull warns about it. teamai does not change `.gitignore`, `.git/info/exclude` or the git index. A team that wants to keep these files out of commits excludes them itself. diff --git a/docs/usage-guide.zh-CN.md b/docs/usage-guide.zh-CN.md index a54c38856..5e50e63a4 100644 --- a/docs/usage-guide.zh-CN.md +++ b/docs/usage-guide.zh-CN.md @@ -1532,6 +1532,7 @@ pull 只把团队文化、共享指令和 recall 块写入已安装 AI 工具的 | Cursor | `~/.cursor/rules/teamai-context.mdc` | `.cursor/rules/teamai-context.mdc` | | CodeBuddy | `~/.codebuddy/CODEBUDDY.md` | `.codebuddy/rules/teamai-context.md`,与 WorkBuddy 共用一份 | | WorkBuddy | `~/.workbuddy/rules/teamai-context.md` | `.codebuddy/rules/teamai-context.md`,与 CodeBuddy 共用一份 | +| Hermes | `$HERMES_HOME/SOUL.md` 中的一个块,位于团队规则块旁 | (见下文) | Cursor 在每个会话中应用这两个 `teamai-context.mdc` 文件(`alwaysApply: true`)。Cursor CLI 仅在会话从主目录下启动时读取 `~/.cursor/rules`;Cursor IDE 未经验证。 @@ -1542,6 +1543,7 @@ CodeBuddy 和 WorkBuddy 的规则文件在每个会话中应用(`alwaysApply: - Claude Code,项目范围:`.claude/CLAUDE.md` - CodeBuddy,项目范围:`.codebuddy/CODEBUDDY.md` - WorkBuddy:`~/AGENTS.md` 和项目 `AGENTS.md` +- Hermes:`~/AGENTS.md` 与 teamai 目标同名但并非 teamai 写入的文件保持不变,pull 会给出警告。teamai 不修改 `.gitignore`、`.git/info/exclude` 或 git 索引。若团队希望这些文件不进入提交,需要自行排除。 diff --git a/src/__tests__/e2e/instruction-targets.test.ts b/src/__tests__/e2e/instruction-targets.test.ts index 14603f90f..480a0ab49 100644 --- a/src/__tests__/e2e/instruction-targets.test.ts +++ b/src/__tests__/e2e/instruction-targets.test.ts @@ -51,7 +51,7 @@ function git(args: string[], cwd: string): void { } /** A user-scope sandbox HOME with the given tool directories installed. */ -function makeUserSandbox(toolDirs: string[]): { sandbox: string; home: string } { +function makeUserSandbox(toolDirs: string[], options: { rule?: boolean } = {}): { sandbox: string; home: string } { const sandbox = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'teamai-945-e2e-'))); const home = path.join(sandbox, 'home'); const remote = path.join(sandbox, 'team-remote'); @@ -64,6 +64,10 @@ function makeUserSandbox(toolDirs: string[]): { sandbox: string; home: string } ); fs.writeFileSync(path.join(remote, 'culture.md'), '---\ncompany:\n name: Acme\n---\n\nBe kind.\n'); fs.writeFileSync(path.join(remote, 'claudemd', 'common', 'note.md'), 'Shared team instructions.\n'); + if (options.rule) { + fs.mkdirSync(path.join(remote, 'rules'), { recursive: true }); + fs.writeFileSync(path.join(remote, 'rules', 'style.md'), 'RULE-SENTINEL: keep functions small.\n'); + } git(['init', '-q'], remote); git(['add', '-A'], remote); git(['commit', '-q', '-m', 'fixture'], remote); @@ -205,23 +209,37 @@ describe('instruction block targets on real CLI pull (#945)', () => { expect(fs.existsSync(path.join(home, 'AGENTS.md'))).toBe(false); }); - it('writes ~/AGENTS.md while Hermes is installed and deletes it once no installed tool reads it', async () => { - const { sandbox, home } = makeUserSandbox(['.claude', '.hermes']); + it('gives Hermes its user blocks in $HERMES_HOME/SOUL.md beside the rules block, and moves them out of ~/AGENTS.md', async () => { + const { sandbox, home } = makeUserSandbox(['.claude'], { rule: true }); sandboxes.push(sandbox); + const hermesHome = path.join(sandbox, 'hermes-home'); + fs.mkdirSync(hermesHome, { recursive: true }); + const soul = path.join(hermesHome, 'SOUL.md'); + fs.writeFileSync(soul, '# Persona\n'); const agentsMd = path.join(home, 'AGENTS.md'); + fs.writeFileSync(agentsMd, `${CULTURE_START}\nold culture\n${CULTURE_END}\n`); - const withHermes = await runCLI(['pull'], { HOME: home }, sandbox); - expect(withHermes.code, withHermes.output).toBe(0); - const written = fs.readFileSync(agentsMd, 'utf8'); - expect(written).toContain(CULTURE_START); - expect(written).toContain(CLAUDEMD_START); - - fs.rmSync(path.join(home, '.hermes'), { recursive: true, force: true }); - const withoutHermes = await runCLI(['pull'], { HOME: home }, sandbox); - expect(withoutHermes.code, withoutHermes.output).toBe(0); + const result = await runCLI(['pull'], { HOME: home, HERMES_HOME: hermesHome }, sandbox); + expect(result.code, result.output).toBe(0); + const content = fs.readFileSync(soul, 'utf8'); + expect(content).toContain('# Persona'); + expect(content).toContain(''); + expect(content).toContain('RULE-SENTINEL'); + expect(content).toContain(CULTURE_START); + expect(content).toContain(CLAUDEMD_START); expect(fs.existsSync(agentsMd)).toBe(false); - expect(fs.readFileSync(path.join(home, '.claude', 'CLAUDE.md'), 'utf8')).toContain(CLAUDEMD_START); + + // A second pull keeps both blocks, and a project pull leaves the user blocks alone. + const again = await runCLI(['pull', '--force'], { HOME: home, HERMES_HOME: hermesHome }, sandbox); + expect(again.code, again.output).toBe(0); + const member = makeProjectMember(sandbox, makeTeamAndProject(sandbox), 'dev', 'developer', ['.claude/skills']); + const projectPull = await runCLI(['pull', '--force'], { HOME: member.home, HERMES_HOME: hermesHome }, member.projectRoot); + expect(projectPull.code, projectPull.output).toBe(0); + const after = fs.readFileSync(soul, 'utf8'); + expect(after).toContain(CULTURE_START); + expect(after).toContain('Shared team instructions.'); + expect(after).not.toContain('DEVELOPMENT-SENTINEL'); }); it('keeps only the hand-written text of a ~/AGENTS.md no installed tool reads', async () => { diff --git a/src/instruction-targets.ts b/src/instruction-targets.ts index 55cba9571..48b0ac6f2 100644 --- a/src/instruction-targets.ts +++ b/src/instruction-targets.ts @@ -1,8 +1,10 @@ import path from 'node:path'; import { isToolInstalledForConfig } from './resources/base.js'; -import { readFileSafe, remove, writeFile } from './utils/fs.js'; +import { pathExists, readFileSafe, remove, writeFile } from './utils/fs.js'; import { gitTracking, gitTracks } from './mcp-git-exclude.js'; import { TEAMAI_CONTEXT_RULE_NAME } from './builtin-rules.js'; +import { getHermesHome } from './hermes-home.js'; +import { getHermesSoulPath } from './hermes-config.js'; import { isAgentExcluded, resolveToolBaseDir, @@ -74,7 +76,8 @@ const USER_TARGETS: Readonly> = { cursor, 'claude-internal': { file: configured, retired: [] }, tclaude: { file: configured, retired: [] }, - hermes: { file: configured, retired: [] }, + // Hermes loads SOUL.md in every session; teamai's rules block is already there. + hermes: { file: () => getHermesSoulPath(), retired: ['AGENTS.md'] }, copilot: { file: configured, retired: [] }, omp: { file: configured, retired: [] }, pi: { file: configured, retired: [] }, @@ -196,6 +199,8 @@ function knownInstructionTargets(teamConfig: TeamaiConfig, localConfig: LocalCon * tools and says nothing about any one of them. */ async function isInstalled(tool: string, paths: ToolPaths, localConfig: LocalConfig): Promise { + // Hermes lives in $HERMES_HOME, which ~/.hermes need not be. + if (tool === 'hermes') return pathExists(getHermesHome()); const probe = paths.skills ?? paths.rules ?? paths.agents ?? paths.settings; return probe !== undefined && isToolInstalledForConfig(tool, probe, localConfig); } diff --git a/src/local-agent.ts b/src/local-agent.ts index 4708c6f86..3d0b4e066 100644 --- a/src/local-agent.ts +++ b/src/local-agent.ts @@ -2115,6 +2115,8 @@ async function syncClaudemd( const toolInstalled = resolvedAbsPath ? await pathExists(resolvedAbsPath) + : path.isAbsolute(targetFile) + ? await pathExists(path.dirname(targetFile)) : tool === COPILOT_TOOL_ID && localConfig.scope === 'user' ? await isToolInstalledForConfig(tool, targetFile, localConfig) : targetFile.includes('/') From 6a34edaa09654f0727f933c8a5d08ca73822a141 Mon Sep 17 00:00:00 2001 From: Saul Moro Date: Thu, 1 Oct 2026 14:44:25 +0200 Subject: [PATCH 07/41] fix(pull): give Oh My Pi its blocks without hiding AGENTS.md (#945) Oh My Pi keeps one context file per level, so ~/.omp/agent/AGENTS.md hid ~/.agents/AGENTS.md and .omp/AGENTS.md hid the project's AGENTS.md. User-scope blocks now go to ~/.omp/agent/RULES.md, an always-applied rule beside that slot. In a project, teamai's OMP extension asks the new `hook-dispatch instructions` event for the member's blocks when a session starts and appends them to each turn's system prompt; OMP rebuilds that prompt from its base every turn, so they reach each request once. The next pull removes the old blocks from both context files. Verified with OMP 18.2.1 against a local capture server: the blocks reach each request once from the project root and a subdirectory, and the project AGENTS.md still loads. --- docs/usage-guide.md | 6 +- docs/usage-guide.zh-CN.md | 6 +- src/__tests__/e2e/instruction-targets.test.ts | 59 +++++++++++++++++-- src/__tests__/helpers/pi-extensions.ts | 7 ++- src/__tests__/omp-hooks.test.ts | 42 +++++++++++-- src/hook-handlers.ts | 43 ++++++-------- src/instruction-targets.ts | 26 +++++++- src/omp-hooks.ts | 26 +++++++- src/pull.ts | 36 +++++++---- 9 files changed, 198 insertions(+), 53 deletions(-) diff --git a/docs/usage-guide.md b/docs/usage-guide.md index 1f999509a..a3e88b468 100644 --- a/docs/usage-guide.md +++ b/docs/usage-guide.md @@ -1655,17 +1655,21 @@ Two members of the same project can have different roles, so their shared instru | CodeBuddy | `~/.codebuddy/CODEBUDDY.md` | `.codebuddy/rules/teamai-context.md`, one copy shared with WorkBuddy | | WorkBuddy | `~/.workbuddy/rules/teamai-context.md` | `.codebuddy/rules/teamai-context.md`, one copy shared with CodeBuddy | | Hermes | A block in `$HERMES_HOME/SOUL.md`, beside the team rules block | (see below) | +| Oh My Pi | `~/.omp/agent/RULES.md` | Added to each turn's system prompt by teamai's OMP extension | Cursor applies both `teamai-context.mdc` files in every session (`alwaysApply: true`). Cursor CLI reads `~/.cursor/rules` when the session starts under your home directory; the Cursor IDE was not checked. The CodeBuddy and WorkBuddy rule files are applied in every session (`alwaysApply: true`). Uninstalling one of the two keeps the shared project copy while the other is still installed. +Oh My Pi reads `RULES.md` as an always-applied rule beside its single user context file. In project scope teamai's OMP extension asks `teamai` for the blocks when the session starts and adds them to each turn's system prompt, from the project root and any subdirectory. Without that extension (for example with hooks removed), an Oh My Pi project session gets no team blocks. + A pull from an earlier release may have left these blocks in a file listed below. The next pull removes them, and names each file it changes: - Claude Code, project scope: `.claude/CLAUDE.md` - CodeBuddy, project scope: `.codebuddy/CODEBUDDY.md` - WorkBuddy: `~/AGENTS.md` and the project `AGENTS.md` - 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`. A file named like a teamai target that teamai did not write is left alone, and the pull warns about it. teamai does not change `.gitignore`, `.git/info/exclude` or the git index. A team that wants to keep these files out of commits excludes them itself. @@ -2210,7 +2214,7 @@ These paths are verified against the ZCode desktop app: profiles created in its ### Oh My Pi -Oh My Pi (OMP) is available as a built-in target. TeamAI deploys skills, rules, and subagents to OMP's native directories — `.omp/skills/`, `.omp/rules/`, and `.omp/agents/` at project scope, and `~/.omp/agent/skills/`, `~/.omp/agent/rules/`, and `~/.omp/agent/agents/` at user scope (user-scope resources live under the agent directory `~/.omp/agent/`, a different prefix from the project one, so TeamAI switches prefixes with the scope). Instructions (`claudemd`) deploy to the matching `AGENTS.md`, 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`. 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. `teamai uninstall` removes the extension. 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. +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 doc the main agent opens after a subagent's recall is not upvoted yet: the subagent's session is not linked to its parent. 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. `teamai uninstall` removes the extension. 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. ### DeepSeek Harness diff --git a/docs/usage-guide.zh-CN.md b/docs/usage-guide.zh-CN.md index 5e50e63a4..a389007f8 100644 --- a/docs/usage-guide.zh-CN.md +++ b/docs/usage-guide.zh-CN.md @@ -1533,17 +1533,21 @@ pull 只把团队文化、共享指令和 recall 块写入已安装 AI 工具的 | CodeBuddy | `~/.codebuddy/CODEBUDDY.md` | `.codebuddy/rules/teamai-context.md`,与 WorkBuddy 共用一份 | | WorkBuddy | `~/.workbuddy/rules/teamai-context.md` | `.codebuddy/rules/teamai-context.md`,与 CodeBuddy 共用一份 | | Hermes | `$HERMES_HOME/SOUL.md` 中的一个块,位于团队规则块旁 | (见下文) | +| Oh My Pi | `~/.omp/agent/RULES.md` | 由 teamai 的 OMP 扩展加入每轮的系统提示 | Cursor 在每个会话中应用这两个 `teamai-context.mdc` 文件(`alwaysApply: true`)。Cursor CLI 仅在会话从主目录下启动时读取 `~/.cursor/rules`;Cursor IDE 未经验证。 CodeBuddy 和 WorkBuddy 的规则文件在每个会话中应用(`alwaysApply: true`)。卸载其中一个工具时,只要另一个仍已安装,项目中共用的那份文件就会保留。 +Oh My Pi 把 `RULES.md` 作为始终应用的规则读取,与其唯一的用户上下文文件并存。在项目范围内,teamai 的 OMP 扩展在会话开始时向 `teamai` 获取这些块,并加入每轮的系统提示,无论会话从项目根目录还是子目录启动。没有该扩展时(例如移除了 hooks),Oh My Pi 的项目会话不会获得团队块。 + 早期版本的 pull 可能把这些块留在下列文件中。下一次 pull 会移除它们,并列出所修改的每个文件: - Claude Code,项目范围:`.claude/CLAUDE.md` - CodeBuddy,项目范围:`.codebuddy/CODEBUDDY.md` - WorkBuddy:`~/AGENTS.md` 和项目 `AGENTS.md` - Hermes:`~/AGENTS.md` +- Oh My Pi:`~/.omp/agent/AGENTS.md` 和 `.omp/AGENTS.md`。Oh My Pi 每一层只读取一个上下文文件,因此它们会遮蔽 `~/.agents/AGENTS.md` 和项目的 `AGENTS.md`。 与 teamai 目标同名但并非 teamai 写入的文件保持不变,pull 会给出警告。teamai 不修改 `.gitignore`、`.git/info/exclude` 或 git 索引。若团队希望这些文件不进入提交,需要自行排除。 @@ -2073,7 +2077,7 @@ ZCode 已作为内置目标支持。Skills 下发到 `.zcode/skills/`(ZCode ### Oh My Pi -Oh My Pi(OMP)已作为内置目标支持。TeamAI 将 Skills、Rules 和 Subagents 下发到 OMP 的原生目录——项目级为 `.omp/skills/`、`.omp/rules/` 和 `.omp/agents/`,用户级为 `~/.omp/agent/skills/`、`~/.omp/agent/rules/` 和 `~/.omp/agent/agents/`(用户级资源位于 agent 目录 `~/.omp/agent/` 下,与项目级前缀不同,TeamAI 会随作用域自动切换)。指令(`claudemd`)下发到对应的 `AGENTS.md`;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` 做项目门控。每个事件都携带 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。OMP 的 profile(`OMP_PROFILE` / `PI_CODING_AGENT_DIR` / `PI_CONFIG_DIR`,会迁移 agent 目录)暂不支持,使用默认的 `~/.omp/agent/` 布局。 +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 自身的读取从不计入。主 agent 在 subagent recall 之后打开的文档暂不会被 upvote:subagent 的会话尚未关联到父会话。`session_stop` 处理器不返回任何值,分发绝不会强制会话继续;由于 OMP 的工具名是小写(`bash`、`read` 等)且没有 `Skill` / `TodoWrite` 工具,post-tool-use 不做 matcher 定向分发。`teamai uninstall` 会移除该 extension。OMP 的 profile(`OMP_PROFILE` / `PI_CODING_AGENT_DIR` / `PI_CONFIG_DIR`,会迁移 agent 目录)暂不支持,使用默认的 `~/.omp/agent/` 布局。 ### DeepSeek Harness diff --git a/src/__tests__/e2e/instruction-targets.test.ts b/src/__tests__/e2e/instruction-targets.test.ts index 480a0ab49..98552c1b2 100644 --- a/src/__tests__/e2e/instruction-targets.test.ts +++ b/src/__tests__/e2e/instruction-targets.test.ts @@ -31,7 +31,7 @@ interface RunResult { output: string; } -function runCLI(args: string[], env: Record, cwd: string): Promise { +function runCLI(args: string[], env: Record, cwd: string, stdin = ''): Promise { return new Promise((resolve) => { const child = spawn('node', [CLI, ...args], { env: { ...process.env, FORCE_COLOR: '0', ...env }, @@ -39,13 +39,22 @@ function runCLI(args: string[], env: Record, cwd: string): Promi cwd, }); let output = ''; - child.stdout.on('data', (data: Buffer) => { output += data.toString(); }); + let stdout = ''; + child.stdout.on('data', (data: Buffer) => { output += data.toString(); stdout += data.toString(); }); child.stderr.on('data', (data: Buffer) => { output += data.toString(); }); - child.stdin.end(); - child.on('close', (code) => resolve({ code, output })); + child.stdin.end(stdin); + child.on('close', (code) => resolve({ code, output, stdout })); }); } +/** The context `teamai hook-dispatch instructions` hands an extension for a session in `cwd`. */ +async function sessionInstructions(tool: string, home: string, cwd: string): Promise { + const result = await runCLI(['hook-dispatch', 'instructions', '--tool', tool], { HOME: home }, cwd, JSON.stringify({ cwd })); + expect(result.code, result.output).toBe(0); + if (!result.stdout.trim()) return ''; + return JSON.parse(result.stdout).hookSpecificOutput.additionalContext as string; +} + function git(args: string[], cwd: string): void { execFileSync('git', args, { cwd, stdio: 'pipe', env: { ...process.env, ...GIT_ENV } }); } @@ -423,4 +432,46 @@ describe('instruction block targets on real CLI pull (#945)', () => { expect(rule).toContain(CLAUDEMD_START); expect(fs.existsSync(path.join(home, 'AGENTS.md'))).toBe(false); }); + + it('gives Oh My Pi its project blocks through the extension, its user blocks in RULES.md, and frees both context files', async () => { + const sandbox = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'teamai-945-e2e-'))); + sandboxes.push(sandbox); + const fixture = makeTeamAndProject(sandbox); + const developer = makeProjectMember(sandbox, fixture, 'dev', 'developer', ['.omp/skills']); + const product = makeProjectMember(sandbox, fixture, 'pm', 'product', ['.omp/skills']); + const legacy = path.join(developer.projectRoot, '.omp', 'AGENTS.md'); + fs.writeFileSync(legacy, `${CLAUDEMD_START}\nold selection\n${CLAUDEMD_END}\n`); + for (const member of [developer, product]) { + fs.mkdirSync(path.join(member.home, '.omp'), { recursive: true }); + const result = await pullAs(member); + expect(result.code, result.output).toBe(0); + } + + expect(fs.existsSync(legacy)).toBe(false); + const sub = path.join(developer.projectRoot, 'src'); + fs.mkdirSync(sub, { recursive: true }); + const devContext = await sessionInstructions('omp', developer.home, sub); + expect(devContext).toContain('DEVELOPMENT-SENTINEL'); + expect(devContext).not.toContain('PRODUCT-SENTINEL'); + expect(devContext).toContain('Acme'); + expect(devContext).toContain('teamai-recall'); + const pmContext = await sessionInstructions('omp', product.home, product.projectRoot); + expect(pmContext).toContain('PRODUCT-SENTINEL'); + expect(pmContext).not.toContain('DEVELOPMENT-SENTINEL'); + expect(await sessionInstructions('claude', developer.home, developer.projectRoot)).toBe(''); + for (const member of [developer, product]) { + expect(fs.readFileSync(path.join(member.projectRoot, 'AGENTS.md'), 'utf8')).toBe(PROJECT_AGENTS_MD); + } + + const user = makeUserSandbox(['.omp']); + sandboxes.push(user.sandbox); + const userContextFile = path.join(user.home, '.omp', 'agent', 'AGENTS.md'); + fs.mkdirSync(path.dirname(userContextFile), { recursive: true }); + fs.writeFileSync(userContextFile, `${CULTURE_START}\nold culture\n${CULTURE_END}\n`); + const userPull = await runCLI(['pull'], { HOME: user.home }, user.sandbox); + expect(userPull.code, userPull.output).toBe(0); + expect(fs.readFileSync(path.join(user.home, '.omp', 'agent', 'RULES.md'), 'utf8')).toContain(CLAUDEMD_START); + expect(fs.existsSync(userContextFile)).toBe(false); + expect(await sessionInstructions('omp', user.home, user.sandbox)).toBe(''); + }); }); diff --git a/src/__tests__/helpers/pi-extensions.ts b/src/__tests__/helpers/pi-extensions.ts index 2089e0c38..dd3134f49 100644 --- a/src/__tests__/helpers/pi-extensions.ts +++ b/src/__tests__/helpers/pi-extensions.ts @@ -18,7 +18,7 @@ export interface ExtensionContext { agent?: { kind: 'main' | 'sub'; id: string; name: string; depth?: number; parentId?: string }; } -export type ExtensionHandler = (event: Record, ctx: ExtensionContext) => Promise; +export type ExtensionHandler = (event: Record, ctx: ExtensionContext) => Promise; export interface LoadedExtension { /** The handler the extension registered for each host event. */ @@ -59,13 +59,14 @@ export function loadPiExtension(): LoadedExtension { * instead of running `teamai`: the argv is the template's first value, the * STDIN the Response redirected into it. */ -export function loadOmpExtension(): LoadedExtension { +export function loadOmpExtension(stdoutFor: (args: string[]) => string = () => ''): LoadedExtension { const dispatches: ExtensionDispatch[] = []; const $ = (_strings: TemplateStringsArray, args: string[], stdin: Response) => { const run = (async () => { dispatches.push({ args, payload: JSON.parse(await stdin.text()) as Record }); })(); - return { quiet: () => ({ nothrow: () => run }) }; + const output = Object.assign(run, { text: async () => { await run; return stdoutFor(args); } }); + return { quiet: () => ({ nothrow: () => output }) }; }; const on = register(buildOmpExtensionSource(), { $, Response, fs, path, Buffer }); return { on, dispatches }; diff --git a/src/__tests__/omp-hooks.test.ts b/src/__tests__/omp-hooks.test.ts index 1452408fc..d6f83baf3 100644 --- a/src/__tests__/omp-hooks.test.ts +++ b/src/__tests__/omp-hooks.test.ts @@ -24,7 +24,7 @@ import { } from '../omp-hooks.js'; import { reconcileHooksToAllTools } from '../hooks.js'; import { log } from '../utils/logger.js'; -import { loadOmpExtension } from './helpers/pi-extensions.js'; +import { loadOmpExtension, type ExtensionDispatch } from './helpers/pi-extensions.js'; describe('resolveOmpExtensionsDir', () => { it('always targets the user agent dir (single-copy policy)', () => { @@ -82,6 +82,8 @@ describe('buildOmpExtensionSource', () => { it('is syntactically valid JavaScript', () => { assertValidJs(src); }); + + }); describe('injectOmpHooks / removeOmpHooks', () => { @@ -191,7 +193,35 @@ describe('reconcileHooksToAllTools routes omp to the extension adapter', () => { }); }); +// Team instructions (#945): the extension adds hook-dispatch's context to the prompt. +describe('OMP extension: team instructions in the system prompt (#945)', () => { + const ctx = { cwd: '/work/proj/src', sessionManager: { getSessionId: () => 'omp-main' } }; + const context = (text: string) => (args: string[]) => args[1] === 'instructions' + ? JSON.stringify({ hookSpecificOutput: { hookEventName: 'SessionStart', additionalContext: text } }) + : ''; + + it('appends the blocks to each turn\'s system prompt, asking hook-dispatch once per session', async () => { + const { on, dispatches } = loadOmpExtension(context('TEAM-BLOCKS')); + await on.session_start({}, ctx); + const first = await on.before_agent_start({ prompt: 'one', systemPrompt: ['BASE'] }, ctx); + const second = await on.before_agent_start({ prompt: 'two', systemPrompt: ['BASE'] }, ctx); + expect(first).toEqual({ systemPrompt: ['BASE', 'TEAM-BLOCKS'] }); + expect(second).toEqual({ systemPrompt: ['BASE', 'TEAM-BLOCKS'] }); + expect(dispatches.filter((d) => d.args[1] === 'instructions')).toHaveLength(1); + expect(dispatches.find((d) => d.args[1] === 'instructions')?.payload).toEqual({ cwd: '/work/proj/src', session_id: 'omp-main' }); + }); + + it('leaves the system prompt alone when there are no blocks (user scope, or teamai unavailable)', async () => { + const { on } = loadOmpExtension(); + await on.session_start({}, ctx); + expect(await on.before_agent_start({ prompt: 'one', systemPrompt: ['BASE'] }, ctx)).toBeUndefined(); + }); +}); + // Recall attribution (#884): the extension evaluated in `vm`, with a fake host. +/** The lifecycle dispatches, without the team-instructions request (#945), which has its own tests. */ +const lifecycle = (dispatches: ExtensionDispatch[]) => dispatches.filter((d) => d.args[1] !== 'instructions'); + describe('OMP extension: bridge payloads (#884)', () => { const main = { cwd: '/work/proj', sessionManager: { getSessionId: () => 'omp-main' }, agent: { kind: 'main' as const, id: 'Main', name: 'main', depth: 0 } }; const sub = { @@ -204,12 +234,12 @@ describe('OMP extension: bridge payloads (#884)', () => { await on.session_start({}, main); await on.before_agent_start({ prompt: 'hi' }, main); await on.session_stop({}, main); - expect(dispatches.map((d) => [d.args[1], d.payload])).toEqual([ + expect(lifecycle(dispatches).map((d) => [d.args[1], d.payload])).toEqual([ ['session-start', { cwd: '/work/proj', session_id: 'omp-main' }], ['prompt-submit', { cwd: '/work/proj', session_id: 'omp-main', prompt: 'hi' }], ['stop', { cwd: '/work/proj', session_id: 'omp-main' }], ]); - expect(dispatches.every((d) => d.args.join(' ').endsWith('--tool omp'))).toBe(true); + expect(lifecycle(dispatches).every((d) => d.args.join(' ').endsWith('--tool omp'))).toBe(true); }); it('sends the text output and the status on post-tool-use', async () => { @@ -218,7 +248,7 @@ describe('OMP extension: bridge payloads (#884)', () => { content: [{ type: 'text', text: 'a.md' }, { type: 'image', data: 'AAAA' }, { type: 'text', text: 'b.md' }], isError: false }, main); await on.tool_result({ type: 'tool_result', toolCallId: 'c2', toolName: 'bash', input: { command: 'false' }, content: [{ type: 'text', text: 'Command exited with code 1' }], isError: true }, main); - expect(dispatches.map((d) => d.payload)).toEqual([ + expect(lifecycle(dispatches).map((d) => d.payload)).toEqual([ { cwd: '/work/proj', session_id: 'omp-main', tool_name: 'bash', tool_input: { command: 'ls' }, tool_response: 'a.md\nb.md', tool_status: 'success' }, { cwd: '/work/proj', session_id: 'omp-main', tool_name: 'bash', tool_input: { command: 'false' }, tool_response: 'Command exited with code 1', tool_status: 'failure' }, ]); @@ -229,7 +259,7 @@ describe('OMP extension: bridge payloads (#884)', () => { await on.session_start({}, sub); await on.tool_result({ type: 'tool_result', toolCallId: 'c1', toolName: 'read', input: { path: 'x.md' }, content: [], isError: false }, sub); await on.session_stop({}, sub); - expect(dispatches.map((d) => d.payload)).toEqual([ + expect(lifecycle(dispatches).map((d) => d.payload)).toEqual([ { cwd: '/work/proj', session_id: 'omp-sub', agent_id: '0-TeamaiRecall', agent_type: 'teamai-recall' }, { cwd: '/work/proj', session_id: 'omp-sub', agent_id: '0-TeamaiRecall', agent_type: 'teamai-recall', tool_name: 'read', tool_input: { path: 'x.md' }, tool_response: '', tool_status: 'success' }, { cwd: '/work/proj', session_id: 'omp-sub', agent_id: '0-TeamaiRecall', agent_type: 'teamai-recall' }, @@ -240,7 +270,7 @@ describe('OMP extension: bridge payloads (#884)', () => { const { on, dispatches } = loadOmpExtension(); await on.session_start({}, { cwd: '/work/proj', sessionManager: { getSessionId: () => 'omp-old' } }); await on.tool_result({ toolName: 'read', input: { path: 'x.md' } }, { cwd: '/work/proj' }); - expect(dispatches.map((d) => d.payload)).toEqual([ + expect(lifecycle(dispatches).map((d) => d.payload)).toEqual([ { cwd: '/work/proj', session_id: 'omp-old' }, { cwd: '/work/proj', tool_name: 'read', tool_input: { path: 'x.md' }, tool_status: 'unknown' }, ]); diff --git a/src/hook-handlers.ts b/src/hook-handlers.ts index 4358ed31c..ea42b36f8 100644 --- a/src/hook-handlers.ts +++ b/src/hook-handlers.ts @@ -716,34 +716,27 @@ const secretsHintHandler: HookHandler = { }; /** - * SessionStart: a project's rules and instruction blocks (culture, shared - * instructions, recall) for a tool with no rules format and no project file of - * its own (the Codex family, #938, #945). The project AGENTS.md is the - * owners' file, and other tools read it too. User-scope content is in the - * tool's own AGENTS.md, so a session outside a project gets nothing here. - * Codex runs SessionStart again after a compaction or a clear; a resumed - * session already holds the content in its history. A subagent fires - * SubagentStart instead, which gets the same content. + * `instructions`: the culture, claudemd and recall blocks for a tool whose + * extension adds them to the prompt instead of reading a file (#945). Resolved + * for the member, project and scope of the session's cwd, as a pull would. + * Nothing when the tool reads a file in that scope, or is excluded. */ -const teamRulesHandler: HookHandler = { - name: 'team-rules', - async execute(stdin, tool, config) { - if (!config || config.scope !== 'project' || stdin.source === 'resume') return null; - const { getsRulesFromSessionHook } = await import('./resources/rule-format.js'); - const { isAgentExcluded } = await import('./types.js'); - if (!getsRulesFromSessionHook(tool) || isAgentExcluded(config, tool)) return null; +const instructionsHandler: HookHandler = { + name: 'instructions', + async execute(_stdin, tool, config) { + if (!config) return null; + const { deliversInstructionsByHook, instructionHookText } = await import('./instruction-targets.js'); + const { isAgentExcluded, scopedToolPaths } = await import('./types.js'); + if (!deliversInstructionsByHook(tool, config.scope) || isAgentExcluded(config, tool)) return null; const { loadTeamConfig } = await import('./config.js'); const teamConfig = await loadTeamConfig(config.repo.localPath); if (!teamConfig) return null; - const { sessionInstructionBlocks } = await import('./pull.js'); - const { teamRulesContext } = await import('./resources/rules.js'); - const parts = await sessionInstructionBlocks(teamConfig, config, tool); - const rules = await teamRulesContext(teamConfig, config); - if (rules !== null) parts.push(rules); - if (parts.length === 0) return null; - // Codex rejects output whose hookEventName is not the event it ran. - const hookEventName = stdin.hook_event_name === 'SubagentStart' ? 'SubagentStart' : 'SessionStart'; - return JSON.stringify({ hookSpecificOutput: { hookEventName, additionalContext: parts.join('\n\n') } }); + const { buildRolePullContext } = await import('./resources/desired.js'); + const { resolveInstructionBlocks } = await import('./pull.js'); + const { blocks } = await resolveInstructionBlocks(teamConfig, config, await buildRolePullContext(config)); + const text = instructionHookText(blocks, Boolean(scopedToolPaths(teamConfig, config)[tool]?.agents)); + if (!text) return null; + return JSON.stringify({ hookSpecificOutput: { hookEventName: 'SessionStart', additionalContext: text } }); }, }; @@ -863,6 +856,8 @@ export function buildHandlerRegistry(): HandlerRegistration[] { { event: 'session-start', matcher: '*', handler: packageHintHandler, timeoutMs: FOREGROUND_HOOK_TIMEOUT_MS, requiresConfig: true }, { event: 'session-start', matcher: '*', handler: secretsHintHandler, timeoutMs: FOREGROUND_HOOK_TIMEOUT_MS, requiresConfig: true }, { event: 'session-start', matcher: '*', handler: localAgentHandler, timeoutMs: FOREGROUND_HOOK_TIMEOUT_MS }, + // Asked for by the Pi and OMP extensions, which add the result to the prompt. + { event: 'instructions', matcher: '*', handler: instructionsHandler, timeoutMs: FOREGROUND_HOOK_TIMEOUT_MS, requiresConfig: true }, { event: 'session-start', matcher: '*', handler: webhookHandler, timeoutMs: FOREGROUND_HOOK_TIMEOUT_MS, background: true, requiresConfig: true }, // Copilot emits SessionEnd after its final turn (not Stop), so the webhook diff --git a/src/instruction-targets.ts b/src/instruction-targets.ts index 48b0ac6f2..ce4d5f76f 100644 --- a/src/instruction-targets.ts +++ b/src/instruction-targets.ts @@ -37,6 +37,8 @@ interface TargetEntry { * takes no file in this scope. */ readonly file: (paths: ToolPaths) => string | undefined; + /** The tool gets this scope's blocks from teamai's session hook or extension instead of a file. */ + readonly hook?: boolean; /** Text teamai writes above the blocks when it creates the file, e.g. the frontmatter a rules loader needs. */ readonly header?: string; /** teamai owns the whole file: one it did not write is left alone, and it is deleted once its blocks are gone. */ @@ -79,7 +81,9 @@ const USER_TARGETS: Readonly> = { // Hermes loads SOUL.md in every session; teamai's rules block is already there. hermes: { file: () => getHermesSoulPath(), retired: ['AGENTS.md'] }, copilot: { file: configured, retired: [] }, - omp: { file: configured, retired: [] }, + // 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: [] }, // WorkBuddy reads user rules from ~/.workbuddy/rules; nothing else reads them. workbuddy: { file: contextRule('.md'), header: ALWAYS_APPLY, owned: true, retired: ['AGENTS.md'] }, @@ -97,7 +101,10 @@ const PROJECT_TARGETS: Readonly> = { tclaude: { file: configured, retired: [] }, hermes: { file: configured, retired: [] }, copilot: { file: configured, retired: [] }, - omp: { file: configured, retired: [] }, + // OMP reads project rules only from the root and keeps one context file per + // level, so .omp/AGENTS.md would hide the project's AGENTS.md: teamai's OMP + // extension adds the blocks to each turn's system prompt instead. + omp: { file: () => undefined, hook: true, retired: ['.omp/AGENTS.md'] }, pi: { file: configured, retired: [] }, workbuddy: { file: codebuddyProjectRule, header: ALWAYS_APPLY, owned: true, retired: ['AGENTS.md'] }, codebuddy: { file: codebuddyProjectRule, header: ALWAYS_APPLY, owned: true, retired: ['.codebuddy/CODEBUDDY.md'] }, @@ -156,6 +163,21 @@ export function instructionTargetFile(tool: string, paths: ToolPaths, scope: Sco return (entryFor(tool, scope)?.file ?? configured)(paths); } +/** Whether `tool` gets this scope's blocks from teamai's session hook or extension rather than a file. */ +export function deliversInstructionsByHook(tool: string, scope: Scope): boolean { + return entryFor(tool, scope)?.hook === true; +} + +/** + * The text a session hook adds to the prompt: the same blocks a file target + * holds, recall included only for a tool with the `teamai-recall` subagent. + */ +export function instructionHookText(blocks: InstructionBlocks, recall: boolean): string { + return [blocks.culture, blocks.claudemd, recall ? blocks.recall : null] + .filter((block): block is string => typeof block === 'string' && block !== '') + .join('\n\n'); +} + /** Absolute instruction file of `tool` in the active scope, or undefined when it takes none. */ export function instructionTargetPath( tool: string, diff --git a/src/omp-hooks.ts b/src/omp-hooks.ts index c6ac6dce0..e94b664a2 100644 --- a/src/omp-hooks.ts +++ b/src/omp-hooks.ts @@ -18,7 +18,11 @@ * - `tool_result` → post-tool-use (dashboard; wildcard only) * * The generated extension runs each dispatch for its side effects and never - * blocks the agent: all shell errors are swallowed. `session_stop` IS awaited + * blocks the agent: all shell errors are swallowed. One dispatch has a + * result: \`instructions\` returns the member's culture, claudemd and recall + * blocks for a project session, which \`before_agent_start\` appends to the + * system prompt (#945). OMP rebuilds that prompt from its base every turn, so + * the blocks reach each request once. `session_stop` IS awaited * by OMP before the main session settles — the dispatch carries its own * timeout, and the handler deliberately returns nothing: the `continue` / * `decision: "block"` fields of SessionStopEventResult would force a session @@ -177,7 +181,23 @@ export default function teamaiHooks(pi) { } }; + // The member's culture, claudemd and recall blocks for a project session, + // or "" (user scope, or teamai unavailable). Fetched once per session. + let instructions; + const loadInstructions = async (ctx) => { + try { + const stdin = JSON.stringify({ cwd: ctx.cwd, ...sessionOf(ctx) }); + const args = ["hook-dispatch", "instructions", "--tool", "omp"]; + const out = await $\`teamai \${args} < \${new Response(stdin)}\`.quiet().nothrow().text(); + const text = out.trim() ? JSON.parse(out).hookSpecificOutput?.additionalContext : undefined; + return typeof text === "string" ? text : ""; + } catch { + return ""; + } + }; + pi.on("session_start", async (_event, ctx) => { + instructions = loadInstructions(ctx); await dispatch("session-start", ctx); }); @@ -185,8 +205,12 @@ export default function teamaiHooks(pi) { await dispatch("stop", ctx); }); + // OMP rebuilds the system prompt from its base each turn, so appending + // here adds the blocks to every request once, without piling up. pi.on("before_agent_start", async (event, ctx) => { await dispatch("prompt-submit", ctx, { prompt: event.prompt }); + const text = await (instructions ??= loadInstructions(ctx)); + return text ? { systemPrompt: [...event.systemPrompt, text] } : undefined; }); // Subagent sessions already linked: a session's parent never changes. diff --git a/src/pull.ts b/src/pull.ts index 088c20913..fba19c483 100644 --- a/src/pull.ts +++ b/src/pull.ts @@ -1833,21 +1833,16 @@ export function compileClaudemd(contents: string[]): string | null { } /** - * Deliver the culture, shared-instruction and recall blocks to every installed - * tool's target, and strip them from files no installed tool loads them from - * (#945). Runs on the "Already synced" fast path too, so a CLI upgrade that - * moves a target or ships a new recall block takes effect without a repo - * change. The recall block goes to targets whose tool has the `teamai-recall` - * subagent; the block itself tells an agent without one to run - * `teamai recall` directly. A dry run reports the files it would change. + * The culture, shared-instruction and recall blocks this member gets in this + * scope: the same text whether a pull writes it to a file or a session hook + * adds it to the prompt (#945). A block whose source cannot be read is left + * undefined, so a write keeps what is there. */ -async function syncManagedInstructions( +export async function resolveInstructionBlocks( config: TeamaiConfig, localConfig: LocalConfig, roleContext: RolePullContext | null, - scopeLabel: string, - dryRun = false, -): Promise { +): Promise<{ blocks: InstructionBlocks; claudemdFiles: number }> { const culturePath = path.join(localConfig.repo.localPath, 'culture.md'); const blocks: InstructionBlocks = { recall: isRecallEnabled(localConfig, config) ? compileRecallRulesBlock() : null, @@ -1873,7 +1868,26 @@ async function syncManagedInstructions( } catch (e) { log.debug(`Shared instructions sync skipped: ${(e as Error).message}`); } + return { blocks, claudemdFiles }; +} +/** + * Deliver the culture, shared-instruction and recall blocks to every installed + * tool's target, and strip them from files no installed tool loads them from + * (#945). Runs on the "Already synced" fast path too, so a CLI upgrade that + * moves a target or ships a new recall block takes effect without a repo + * change. The recall block goes to targets whose tool has the `teamai-recall` + * subagent; the block itself tells an agent without one to run + * `teamai recall` directly. A dry run reports the files it would change. + */ +async function syncManagedInstructions( + config: TeamaiConfig, + localConfig: LocalConfig, + roleContext: RolePullContext | null, + scopeLabel: string, + dryRun = false, +): Promise { + const { blocks, claudemdFiles } = await resolveInstructionBlocks(config, localConfig, roleContext); const { targets, stale } = await resolveInstructionTargets(config, localConfig); const plan = await planInstructionFiles(targets, blocks, stale); for (const warning of plan.warnings) log.warn(`[${scopeLabel}] ${warning}`); From 7f3f8ca648e252e3b828f32b22c50939f009d1b7 Mon Sep 17 00:00:00 2001 From: Saul Moro Date: Thu, 1 Oct 2026 14:47:20 +0200 Subject: [PATCH 08/41] fix(pull): give Pi its project blocks through its extension (#945) Pi's project blocks went into the project AGENTS.md, the file every member shares. teamai's Pi extension now asks `hook-dispatch instructions` for the member's blocks when a session starts and adds them to each run's system prompt; Pi renders that prompt from its base for every run, so they do not pile up. The next pull removes the old blocks from AGENTS.md once no installed tool still writes there. User-scope blocks stay in ~/.pi/agent/AGENTS.md. --- docs/usage-guide.md | 4 +- docs/usage-guide.zh-CN.md | 4 +- src/__tests__/e2e/instruction-targets.test.ts | 17 ++++++++ src/__tests__/helpers/pi-extensions.ts | 11 +++-- src/__tests__/pi-hooks.test.ts | 38 +++++++++++++--- src/instruction-targets.ts | 4 +- src/pi-hooks.ts | 43 +++++++++++++++++++ 7 files changed, 110 insertions(+), 11 deletions(-) diff --git a/docs/usage-guide.md b/docs/usage-guide.md index a3e88b468..2cc77703c 100644 --- a/docs/usage-guide.md +++ b/docs/usage-guide.md @@ -1656,6 +1656,7 @@ Two members of the same project can have different roles, so their shared instru | WorkBuddy | `~/.workbuddy/rules/teamai-context.md` | `.codebuddy/rules/teamai-context.md`, one copy shared with CodeBuddy | | Hermes | A block in `$HERMES_HOME/SOUL.md`, beside the team rules block | (see below) | | 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 | Cursor applies both `teamai-context.mdc` files in every session (`alwaysApply: true`). Cursor CLI reads `~/.cursor/rules` when the session starts under your home directory; the Cursor IDE was not checked. @@ -1670,6 +1671,7 @@ A pull from an earlier release may have left these blocks in a file listed below - WorkBuddy: `~/AGENTS.md` and the project `AGENTS.md` - 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` A file named like a teamai target that teamai did not write is left alone, and the pull warns about it. teamai does not change `.gitignore`, `.git/info/exclude` or the git index. A team that wants to keep these files out of commits excludes them itself. @@ -2185,7 +2187,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/`. -- **Instructions.** Project instructions use `AGENTS.md`; user instructions use `~/.pi/agent/AGENTS.md`. Pi also accepts `CLAUDE.md` as a project instruction file, but TeamAI keeps the canonical TeamAI block in `AGENTS.md`. +- **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. Any targeted removal — the explicit `teamai hooks remove` command, or a scoped `teamai uninstall --agent pi` — deletes this shared extension outright, the same single-file removal semantics as the OMP adapter: Pi has no way to scope one shared file to a single project, so it doesn't pretend to preserve it for other projects while the extension keeps firing for this one anyway; 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`. Because the extension is one shared file rather than a per-project one, a scoped removal is not durable in a multi-project setup: the next `teamai init`/`pull` in any other scope where Pi is still enabled re-creates it, and hook dispatch has no per-project exclusion check, so hooks can resume firing in the project that was just uninstalled from. This is the same trade-off the OMP adapter already ships with. - **Team hooks boundary.** The Pi adapter installs only the built-in lifecycle bridge. Custom team hooks and built-in hook overrides declared in `hooks/hooks.yaml` are skipped with a warning. Full team-hook and per-project ownership semantics require a separate cross-adapter design and are deferred to a follow-up PR. - **Server-pushed agent hooks.** HTTP-source hooks are installed as `teamai-agent-.ts` extensions in the same global extension directory. Unsupported lifecycle events are skipped with a warning. diff --git a/docs/usage-guide.zh-CN.md b/docs/usage-guide.zh-CN.md index a389007f8..e4e172655 100644 --- a/docs/usage-guide.zh-CN.md +++ b/docs/usage-guide.zh-CN.md @@ -1534,6 +1534,7 @@ pull 只把团队文化、共享指令和 recall 块写入已安装 AI 工具的 | WorkBuddy | `~/.workbuddy/rules/teamai-context.md` | `.codebuddy/rules/teamai-context.md`,与 CodeBuddy 共用一份 | | Hermes | `$HERMES_HOME/SOUL.md` 中的一个块,位于团队规则块旁 | (见下文) | | Oh My Pi | `~/.omp/agent/RULES.md` | 由 teamai 的 OMP 扩展加入每轮的系统提示 | +| Pi | `~/.pi/agent/AGENTS.md` | 由 teamai 的 Pi 扩展加入每次运行的系统提示 | Cursor 在每个会话中应用这两个 `teamai-context.mdc` 文件(`alwaysApply: true`)。Cursor CLI 仅在会话从主目录下启动时读取 `~/.cursor/rules`;Cursor IDE 未经验证。 @@ -1548,6 +1549,7 @@ Oh My Pi 把 `RULES.md` 作为始终应用的规则读取,与其唯一的用 - WorkBuddy:`~/AGENTS.md` 和项目 `AGENTS.md` - Hermes:`~/AGENTS.md` - Oh My Pi:`~/.omp/agent/AGENTS.md` 和 `.omp/AGENTS.md`。Oh My Pi 每一层只读取一个上下文文件,因此它们会遮蔽 `~/.agents/AGENTS.md` 和项目的 `AGENTS.md`。 +- Pi:项目 `AGENTS.md` 与 teamai 目标同名但并非 teamai 写入的文件保持不变,pull 会给出警告。teamai 不修改 `.gitignore`、`.git/info/exclude` 或 git 索引。若团队希望这些文件不进入提交,需要自行排除。 @@ -2048,7 +2050,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/`。 -- **指令文件。** 项目级使用 `AGENTS.md`,用户级使用 `~/.pi/agent/AGENTS.md`。Pi 也接受项目级 `CLAUDE.md`,但 TeamAI 将规范的 TeamAI 区块保留在 `AGENTS.md`。 +- **指令文件。** 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`,或者某个 scope 下的 `teamai uninstall --agent pi`——都会直接删除这份共享扩展,和 OMP 适配器的单文件删除语义完全一致:Pi 没有办法把一份共享文件限定在某一个项目里,所以不会假装"为其他项目保留"却让这份扩展继续对当前项目触发;没有 TeamAI 标记的同名文件不会被删除。`teamai hooks list` 始终显示这个全局路径。Pi 的 profile 覆盖项(`PI_CODING_AGENT_DIR` / `PI_CONFIG_DIR`,会迁移 agent 目录)在 hooks 中暂不支持,与 OMP 适配器一致,使用默认的 `~/.pi/agent/` 布局。模型配置是另一回事,会读取 `PI_CODING_AGENT_DIR`。由于这份扩展是机器级共享的单个文件而非按项目隔离,某个 scope 下的移除在多项目场景中并不持久:只要 Pi 在其他任意 scope 仍处于启用状态,下一次在那里执行 `teamai init`/`pull` 就会把它重新生成,而 hook 派发本身没有按项目排除的检查,因此刚被卸载的项目里 hooks 仍可能重新触发。这与 OMP 适配器早已上线的取舍完全一致。 - **团队 Hooks 边界。** Pi 适配器只安装内置生命周期桥接。`hooks/hooks.yaml` 声明的自定义团队 Hooks 和内置 Hook 覆盖会被跳过并给出警告。完整团队 Hooks 与逐项目归属语义需要单独的跨适配器设计,留待后续 PR。 - **服务端下发的 Agent Hooks。** HTTP source hooks 会以同一用户级扩展目录中的 `teamai-agent-.ts` 形式安装。不支持的生命周期事件会警告并跳过。 diff --git a/src/__tests__/e2e/instruction-targets.test.ts b/src/__tests__/e2e/instruction-targets.test.ts index 98552c1b2..0cf06b2fa 100644 --- a/src/__tests__/e2e/instruction-targets.test.ts +++ b/src/__tests__/e2e/instruction-targets.test.ts @@ -474,4 +474,21 @@ describe('instruction block targets on real CLI pull (#945)', () => { expect(fs.existsSync(userContextFile)).toBe(false); expect(await sessionInstructions('omp', user.home, user.sandbox)).toBe(''); }); + + it('gives Pi its project blocks through the extension and stops writing the project AGENTS.md', async () => { + const sandbox = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'teamai-945-e2e-'))); + sandboxes.push(sandbox); + const member = makeProjectMember(sandbox, makeTeamAndProject(sandbox), 'pm', 'product', ['.pi/skills']); + const agentsMd = path.join(member.projectRoot, 'AGENTS.md'); + fs.writeFileSync(agentsMd, `${PROJECT_AGENTS_MD}\n${CLAUDEMD_START}\nanother member's selection\n${CLAUDEMD_END}\n`); + + const result = await pullAs(member); + expect(result.code, result.output).toBe(0); + + expect(fs.readFileSync(agentsMd, 'utf8')).toBe(PROJECT_AGENTS_MD); + const context = await sessionInstructions('pi', member.home, member.projectRoot); + expect(context).toContain('PRODUCT-SENTINEL'); + expect(context).not.toContain('DEVELOPMENT-SENTINEL'); + expect(context).toContain('Acme'); + }); }); diff --git a/src/__tests__/helpers/pi-extensions.ts b/src/__tests__/helpers/pi-extensions.ts index dd3134f49..6e41777f4 100644 --- a/src/__tests__/helpers/pi-extensions.ts +++ b/src/__tests__/helpers/pi-extensions.ts @@ -37,14 +37,19 @@ function register(source: string, context: Record): Record string = () => ''): LoadedExtension { const dispatches: ExtensionDispatch[] = []; const spawn = (_command: string, args: string[]) => { - const child = new EventEmitter() as EventEmitter & { stdin: EventEmitter & { end: (s: string) => void }; kill: () => void }; + const child = new EventEmitter() as EventEmitter & { stdin: EventEmitter & { end: (s: string) => void }; stdout: EventEmitter; kill: () => void }; + child.stdout = new EventEmitter(); child.stdin = Object.assign(new EventEmitter(), { end: (stdin: string) => { dispatches.push({ args, payload: JSON.parse(stdin) as Record }); - queueMicrotask(() => child.emit('close', 0)); + queueMicrotask(() => { + const stdout = stdoutFor(args); + if (stdout) child.stdout.emit('data', stdout); + child.emit('close', 0); + }); }, }); child.kill = () => undefined; diff --git a/src/__tests__/pi-hooks.test.ts b/src/__tests__/pi-hooks.test.ts index 8261442fb..50ac0e213 100644 --- a/src/__tests__/pi-hooks.test.ts +++ b/src/__tests__/pi-hooks.test.ts @@ -53,7 +53,7 @@ import { import { reconcileHooksToAllTools } from '../hooks.js'; import { log } from '../utils/logger.js'; import type { HookDef } from '../types.js'; -import { loadPiExtension } from './helpers/pi-extensions.js'; +import { loadPiExtension, type ExtensionDispatch } from './helpers/pi-extensions.js'; describe('Pi hook extension', () => { let tmp: string; @@ -367,6 +367,34 @@ describe('Pi hook extension', () => { }); // Recall attribution (#884): the extension evaluated in `vm`, with a fake host. +/** The lifecycle dispatches, without the team-instructions request (#945), which has its own tests. */ +const lifecycle = (dispatches: ExtensionDispatch[]) => dispatches.filter((d) => d.args[1] !== 'instructions'); + +// Team instructions (#945): the extension adds hook-dispatch's context to the prompt. +describe('Pi extension: team instructions in the system prompt (#945)', () => { + const ctx = { cwd: '/work/proj/src', sessionManager: { getSessionId: () => 'pi-sess' } }; + const context = (text: string) => (args: string[]) => args[1] === 'instructions' + ? JSON.stringify({ hookSpecificOutput: { hookEventName: 'SessionStart', additionalContext: text } }) + : ''; + + it('appends the blocks to each run\'s system prompt, asking hook-dispatch once per session', async () => { + const { on, dispatches } = loadPiExtension(context('TEAM-BLOCKS')); + await on.session_start({}, ctx); + const first = await on.before_agent_start({ prompt: 'one', systemPrompt: 'BASE' }, ctx); + const second = await on.before_agent_start({ prompt: 'two', systemPrompt: 'BASE' }, ctx); + expect(first).toEqual({ systemPrompt: 'BASE\n\nTEAM-BLOCKS' }); + expect(second).toEqual({ systemPrompt: 'BASE\n\nTEAM-BLOCKS' }); + expect(dispatches.filter((d) => d.args[1] === 'instructions')).toHaveLength(1); + expect(dispatches.find((d) => d.args[1] === 'instructions')?.payload).toEqual({ cwd: '/work/proj/src', session_id: 'pi-sess' }); + }); + + it('leaves the system prompt alone when there are no blocks (user scope, or teamai unavailable)', async () => { + const { on } = loadPiExtension(); + await on.session_start({}, ctx); + expect(await on.before_agent_start({ prompt: 'one', systemPrompt: 'BASE' }, ctx)).toBeUndefined(); + }); +}); + describe('Pi extension: bridge payloads (#884)', () => { const ctx = { cwd: '/work/proj', sessionManager: { getSessionId: () => 'pi-sess' } }; @@ -375,12 +403,12 @@ describe('Pi extension: bridge payloads (#884)', () => { await on.session_start({}, ctx); await on.before_agent_start({ prompt: 'hi' }, ctx); await on.agent_settled({}, ctx); - expect(dispatches.map((d) => [d.args[1], d.payload])).toEqual([ + expect(lifecycle(dispatches).map((d) => [d.args[1], d.payload])).toEqual([ ['session-start', { cwd: '/work/proj', session_id: 'pi-sess' }], ['prompt-submit', { cwd: '/work/proj', session_id: 'pi-sess', prompt: 'hi' }], ['stop', { cwd: '/work/proj', session_id: 'pi-sess' }], ]); - expect(dispatches.every((d) => d.args.join(' ').endsWith('--tool pi'))).toBe(true); + expect(lifecycle(dispatches).every((d) => d.args.join(' ').endsWith('--tool pi'))).toBe(true); }); it('sends the cached input, the text output and the status on post-tool-use', async () => { @@ -392,7 +420,7 @@ describe('Pi extension: bridge payloads (#884)', () => { await end('c1', false, [{ type: 'text', text: 'line one' }, { type: 'image', data: 'AAAA' }, { type: 'text', text: 'line two' }]); await end('c2', true, [{ type: 'text', text: 'cat: x.md: No such file\n\nCommand exited with code 1' }]); await end('c3', undefined, []); - expect(dispatches.map((d) => d.payload)).toEqual([ + expect(lifecycle(dispatches).map((d) => d.payload)).toEqual([ { cwd: '/work/proj', session_id: 'pi-sess', tool_name: 'bash', tool_input: { command: 'cat x.md' }, tool_response: 'line one\nline two', tool_status: 'success' }, { cwd: '/work/proj', session_id: 'pi-sess', tool_name: 'bash', tool_input: { command: 'cat x.md' }, tool_response: 'cat: x.md: No such file\n\nCommand exited with code 1', tool_status: 'failure' }, { cwd: '/work/proj', session_id: 'pi-sess', tool_name: 'bash', tool_input: { command: 'cat x.md' }, tool_response: '', tool_status: 'unknown' }, @@ -403,7 +431,7 @@ describe('Pi extension: bridge payloads (#884)', () => { const { on, dispatches } = loadPiExtension(); await on.session_start({}, { cwd: '/work/proj' }); await on.tool_execution_end({ toolCallId: 'c1', toolName: 'read' }, { cwd: '/work/proj' }); - expect(dispatches.map((d) => d.payload)).toEqual([ + expect(lifecycle(dispatches).map((d) => d.payload)).toEqual([ { cwd: '/work/proj' }, { cwd: '/work/proj', tool_name: 'read', tool_input: {}, tool_status: 'unknown' }, ]); diff --git a/src/instruction-targets.ts b/src/instruction-targets.ts index ce4d5f76f..5f91a962f 100644 --- a/src/instruction-targets.ts +++ b/src/instruction-targets.ts @@ -105,7 +105,9 @@ const PROJECT_TARGETS: Readonly> = { // level, so .omp/AGENTS.md would hide the project's AGENTS.md: teamai's OMP // extension adds the blocks to each turn's system prompt instead. omp: { file: () => undefined, hook: true, retired: ['.omp/AGENTS.md'] }, - pi: { file: configured, retired: [] }, + // Pi reads the project's AGENTS.md itself; teamai's Pi extension adds the + // blocks to each run's system prompt. + 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: [] }, diff --git a/src/pi-hooks.ts b/src/pi-hooks.ts index fc0eb0a8c..aa371177d 100644 --- a/src/pi-hooks.ts +++ b/src/pi-hooks.ts @@ -116,7 +116,46 @@ export default function teamaiHooks(pi) { } }; + // The member's culture, claudemd and recall blocks for a project session, + // or "" (user scope, or teamai unavailable). Fetched once per session. + let instructions; + const loadInstructions = (ctx) => new Promise((resolve) => { + try { + const cwd = ctx.cwd; + const command = process.platform === "win32" ? "teamai.cmd" : "teamai"; + const child = spawn(command, ["hook-dispatch", "instructions", "--tool", "pi"], { + cwd, + stdio: ["pipe", "pipe", "ignore"], + windowsHide: true, + shell: process.platform === "win32", + }); + let out = ""; + const finish = () => { + clearTimeout(timer); + try { + const text = out.trim() ? JSON.parse(out).hookSpecificOutput?.additionalContext : undefined; + resolve(typeof text === "string" ? text : ""); + } catch { + resolve(""); + } + }; + const timer = setTimeout(() => { + try { child.kill(); } catch {} + out = ""; + finish(); + }, 15000); + child.stdout?.on("data", (chunk) => { out += chunk; }); + child.stdin?.on("error", () => {}); + child.stdin?.end(JSON.stringify({ cwd, ...sessionOf(ctx) })); + child.once("close", finish); + child.once("error", () => { out = ""; finish(); }); + } catch { + resolve(""); + } + }); + pi.on("session_start", async (_event, ctx) => { + instructions = loadInstructions(ctx); await dispatch("session-start", ctx); }); @@ -124,8 +163,12 @@ export default function teamaiHooks(pi) { await dispatch("stop", ctx); }); + // Pi renders the system prompt again for every run, so adding the blocks + // to this run's prompt reaches the model once, without piling up. pi.on("before_agent_start", async (event, ctx) => { await dispatch("prompt-submit", ctx, { prompt: event.prompt }); + const text = await (instructions ??= loadInstructions(ctx)); + return text ? { systemPrompt: \`\${event.systemPrompt}\\n\\n\${text}\` } : undefined; }); pi.on("tool_execution_start", async (event) => { From 271d239619ea16dedbcecd1d865cc24906935866 Mon Sep 17 00:00:00 2001 From: Saul Moro Date: Thu, 1 Oct 2026 14:52:51 +0200 Subject: [PATCH 09/41] fix(pull): give Hermes its project blocks through a plugin (#945) Hermes' project blocks went into the project AGENTS.md even when Hermes was not installed. teamai now installs a Hermes plugin, $HERMES_HOME/plugins/teamai-instructions, enabled in plugins.enabled, whose system prompt section asks `hook-dispatch instructions` for the member's blocks for the session's directory. Hermes builds it once per session and keeps it through compression and resume. A section holds 4,000 characters; when the blocks are longer, pull says Hermes skips them instead of cutting them or falling back to AGENTS.md. With Pi, Hermes and WorkBuddy moved, no tool writes the project AGENTS.md any more, so the next pull removes the teamai blocks left there. Uninstall also cleans a tool's retired files. --- docs/usage-guide.md | 4 +- docs/usage-guide.zh-CN.md | 4 +- src/__tests__/e2e/instruction-targets.test.ts | 45 ++++++++++- src/__tests__/hermes-hooks.test.ts | 31 +++++++- src/hermes-config.ts | 32 ++++++++ src/hermes-hooks.ts | 78 ++++++++++++++++++- src/instruction-targets.ts | 31 +++++++- src/pull.ts | 12 ++- src/uninstall.ts | 18 ++++- 9 files changed, 242 insertions(+), 13 deletions(-) diff --git a/docs/usage-guide.md b/docs/usage-guide.md index 2cc77703c..b71793d3d 100644 --- a/docs/usage-guide.md +++ b/docs/usage-guide.md @@ -1654,7 +1654,7 @@ Two members of the same project can have different roles, so their shared instru | Cursor | `~/.cursor/rules/teamai-context.mdc` | `.cursor/rules/teamai-context.mdc` | | CodeBuddy | `~/.codebuddy/CODEBUDDY.md` | `.codebuddy/rules/teamai-context.md`, one copy shared with WorkBuddy | | WorkBuddy | `~/.workbuddy/rules/teamai-context.md` | `.codebuddy/rules/teamai-context.md`, one copy shared with CodeBuddy | -| Hermes | A block in `$HERMES_HOME/SOUL.md`, beside the team rules block | (see below) | +| Hermes | A block in `$HERMES_HOME/SOUL.md`, beside the team rules block | A system prompt section from teamai's Hermes plugin | | 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 | @@ -1662,6 +1662,8 @@ Cursor applies both `teamai-context.mdc` files in every session (`alwaysApply: t The CodeBuddy and WorkBuddy rule files are applied in every session (`alwaysApply: true`). Uninstalling one of the two keeps the shared project copy while the other is still installed. +In a project, teamai installs the Hermes plugin `$HERMES_HOME/plugins/teamai-instructions/` and adds it to `plugins.enabled` in `$HERMES_HOME/config.yaml` (a name you list under `plugins.disabled` stays off). Hermes builds the section once for each new session from the session's directory and keeps it through compression and resume. A section holds at most 4,000 characters, and all plugin sections together at most 8,000. When this member's instructions for the project are longer, Hermes skips them and `teamai pull` says so: teamai does not cut them or write them to `AGENTS.md`. Outside a project the section is empty, and Hermes may log that it skipped an empty section. + Oh My Pi reads `RULES.md` as an always-applied rule beside its single user context file. In project scope teamai's OMP extension asks `teamai` for the blocks when the session starts and adds them to each turn's system prompt, from the project root and any subdirectory. Without that extension (for example with hooks removed), an Oh My Pi project session gets no team blocks. A pull from an earlier release may have left these blocks in a file listed below. The next pull removes them, and names each file it changes: diff --git a/docs/usage-guide.zh-CN.md b/docs/usage-guide.zh-CN.md index e4e172655..2fd4be2b9 100644 --- a/docs/usage-guide.zh-CN.md +++ b/docs/usage-guide.zh-CN.md @@ -1532,7 +1532,7 @@ pull 只把团队文化、共享指令和 recall 块写入已安装 AI 工具的 | Cursor | `~/.cursor/rules/teamai-context.mdc` | `.cursor/rules/teamai-context.mdc` | | CodeBuddy | `~/.codebuddy/CODEBUDDY.md` | `.codebuddy/rules/teamai-context.md`,与 WorkBuddy 共用一份 | | WorkBuddy | `~/.workbuddy/rules/teamai-context.md` | `.codebuddy/rules/teamai-context.md`,与 CodeBuddy 共用一份 | -| Hermes | `$HERMES_HOME/SOUL.md` 中的一个块,位于团队规则块旁 | (见下文) | +| Hermes | `$HERMES_HOME/SOUL.md` 中的一个块,位于团队规则块旁 | teamai 的 Hermes 插件提供的系统提示段落 | | Oh My Pi | `~/.omp/agent/RULES.md` | 由 teamai 的 OMP 扩展加入每轮的系统提示 | | Pi | `~/.pi/agent/AGENTS.md` | 由 teamai 的 Pi 扩展加入每次运行的系统提示 | @@ -1540,6 +1540,8 @@ Cursor 在每个会话中应用这两个 `teamai-context.mdc` 文件(`alwaysAp CodeBuddy 和 WorkBuddy 的规则文件在每个会话中应用(`alwaysApply: true`)。卸载其中一个工具时,只要另一个仍已安装,项目中共用的那份文件就会保留。 +在项目中,teamai 安装 Hermes 插件 `$HERMES_HOME/plugins/teamai-instructions/`,并把它加入 `$HERMES_HOME/config.yaml` 的 `plugins.enabled`(列在 `plugins.disabled` 中的名称保持关闭)。Hermes 在每个新会话开始时根据会话目录生成该段落,并在压缩和恢复后保留。一个段落最多 4,000 个字符,所有插件段落合计最多 8,000 个字符。当该成员在此项目的指令更长时,Hermes 会跳过它们,`teamai pull` 会给出提示:teamai 不会截断它们,也不会写入 `AGENTS.md`。在项目之外段落为空,Hermes 可能记录它跳过了一个空段落。 + Oh My Pi 把 `RULES.md` 作为始终应用的规则读取,与其唯一的用户上下文文件并存。在项目范围内,teamai 的 OMP 扩展在会话开始时向 `teamai` 获取这些块,并加入每轮的系统提示,无论会话从项目根目录还是子目录启动。没有该扩展时(例如移除了 hooks),Oh My Pi 的项目会话不会获得团队块。 早期版本的 pull 可能把这些块留在下列文件中。下一次 pull 会移除它们,并列出所修改的每个文件: diff --git a/src/__tests__/e2e/instruction-targets.test.ts b/src/__tests__/e2e/instruction-targets.test.ts index 0cf06b2fa..500b62ffe 100644 --- a/src/__tests__/e2e/instruction-targets.test.ts +++ b/src/__tests__/e2e/instruction-targets.test.ts @@ -188,8 +188,8 @@ function makeProjectMember( return { home, projectRoot }; } -const pullAs = (member: ProjectMember, args: string[] = []): Promise => - runCLI(['pull', '--force', ...args], { HOME: member.home }, member.projectRoot); +const pullAs = (member: ProjectMember, args: string[] = [], env: Record = {}): Promise => + runCLI(['pull', '--force', ...args], { HOME: member.home, ...env }, member.projectRoot); describe('instruction block targets on real CLI pull (#945)', () => { const sandboxes: string[] = []; @@ -491,4 +491,45 @@ describe('instruction block targets on real CLI pull (#945)', () => { expect(context).not.toContain('DEVELOPMENT-SENTINEL'); expect(context).toContain('Acme'); }); + + it('gives Hermes its project blocks through its plugin and frees the project AGENTS.md', async () => { + const sandbox = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'teamai-945-e2e-'))); + sandboxes.push(sandbox); + const member = makeProjectMember(sandbox, makeTeamAndProject(sandbox), 'dev', 'developer', []); + const hermesHome = path.join(sandbox, 'hermes-home'); + fs.mkdirSync(hermesHome, { recursive: true }); + const agentsMd = path.join(member.projectRoot, 'AGENTS.md'); + fs.writeFileSync(agentsMd, `${PROJECT_AGENTS_MD}\n${CULTURE_START}\nold culture\n${CULTURE_END}\n`); + + const result = await pullAs(member, [], { HERMES_HOME: hermesHome }); + expect(result.code, result.output).toBe(0); + + expect(fs.readFileSync(agentsMd, 'utf8')).toBe(PROJECT_AGENTS_MD); + expect(fs.readFileSync(path.join(hermesHome, 'plugins', 'teamai-instructions', '__init__.py'), 'utf8')) + .toContain('register_system_prompt_section'); + expect(fs.readFileSync(path.join(hermesHome, 'config.yaml'), 'utf8')).toMatch(/plugins:\n\s+enabled:\n\s+- teamai-instructions/); + const sub = path.join(member.projectRoot, 'docs'); + fs.mkdirSync(sub, { recursive: true }); + const run = await runCLI(['hook-dispatch', 'instructions', '--tool', 'hermes'], { HOME: member.home, HERMES_HOME: hermesHome }, sub, JSON.stringify({ cwd: sub })); + const context = JSON.parse(run.stdout).hookSpecificOutput.additionalContext as string; + expect(context).toContain('DEVELOPMENT-SENTINEL'); + expect(context).not.toContain('PRODUCT-SENTINEL'); + expect(context).not.toContain('teamai-recall'); + }); + + it('says Hermes cannot load project instructions over its 4,000-character section, without cutting them or using AGENTS.md', async () => { + const sandbox = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'teamai-945-e2e-'))); + sandboxes.push(sandbox); + const member = makeProjectMember(sandbox, makeTeamAndProject(sandbox), 'dev', 'developer', []); + const teamRepo = path.join(member.projectRoot, '.teamai', 'team-repo'); + fs.writeFileSync(path.join(teamRepo, 'claudemd', 'development', 'long.md'), `${'Long developer guidance. '.repeat(200)}\n`); + const hermesHome = path.join(sandbox, 'hermes-home'); + fs.mkdirSync(hermesHome, { recursive: true }); + + const result = await pullAs(member, [], { HERMES_HOME: hermesHome }); + expect(result.code, result.output).toBe(0); + + expect(result.output).toMatch(/hermes cannot load this project's team instructions: they are \d+ characters, over the 4000-character limit/); + expect(fs.readFileSync(path.join(member.projectRoot, 'AGENTS.md'), 'utf8')).toBe(PROJECT_AGENTS_MD); + }); }); diff --git a/src/__tests__/hermes-hooks.test.ts b/src/__tests__/hermes-hooks.test.ts index d0ae75802..6cd33817f 100644 --- a/src/__tests__/hermes-hooks.test.ts +++ b/src/__tests__/hermes-hooks.test.ts @@ -2,7 +2,7 @@ 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 { injectHermesHooks, getReportScriptPath } from '../hermes-hooks.js'; +import { injectHermesHooks, removeHermesHooks, getReportScriptPath, getInstructionsPluginDir } from '../hermes-hooks.js'; import { log } from '../utils/logger.js'; let tmpDir: string; @@ -36,3 +36,32 @@ describe('injectHermesHooks', () => { } }); }); + +describe('the teamai-instructions plugin (#945)', () => { + const config = () => fs.readFileSync(path.join(tmpDir, 'config.yaml'), 'utf8'); + + it('installs the plugin and enables it beside the member\'s own plugins, then removes both', async () => { + fs.writeFileSync(path.join(tmpDir, 'config.yaml'), '# mine\nplugins:\n enabled:\n - disk-cleanup\n'); + vi.spyOn(log, 'success').mockImplementation(() => {}); + + await injectHermesHooks(); + expect(fs.readFileSync(path.join(getInstructionsPluginDir(), '__init__.py'), 'utf8')).toContain('register_system_prompt_section'); + expect(fs.readFileSync(path.join(getInstructionsPluginDir(), 'plugin.yaml'), 'utf8')).toContain('name: teamai-instructions'); + expect(config()).toContain('# mine'); + expect(config()).toMatch(/- disk-cleanup\n\s+- teamai-instructions/); + + await removeHermesHooks(); + expect(fs.existsSync(getInstructionsPluginDir())).toBe(false); + expect(config()).toContain('- disk-cleanup'); + expect(config()).not.toContain('teamai-instructions'); + }); + + it('leaves the plugin off when the member disabled it', async () => { + fs.writeFileSync(path.join(tmpDir, 'config.yaml'), 'plugins:\n disabled:\n - teamai-instructions\n'); + vi.spyOn(log, 'success').mockImplementation(() => {}); + + await injectHermesHooks(); + + expect(config()).not.toMatch(/enabled:/); + }); +}); diff --git a/src/hermes-config.ts b/src/hermes-config.ts index 49acd0ed9..f258888c6 100644 --- a/src/hermes-config.ts +++ b/src/hermes-config.ts @@ -295,3 +295,35 @@ export async function removeHermesAllowlist(event: string, command: string): Pro await writeJson(filePath, { approvals: filtered }); } + +/** + * Add `name` to `plugins.enabled` in the Hermes config.yaml: Hermes loads no + * user plugin that is not listed there. A name the member put under + * `plugins.disabled` stays off. Returns whether config.yaml was written. + */ +export async function enableHermesPlugin(name: string): Promise { + const doc = await readConfigDoc(); + const disabled = doc.getIn(['plugins', 'disabled']); + if (YAML.isSeq(disabled) && (disabled.toJSON() as unknown[]).includes(name)) return false; + const enabled = doc.getIn(['plugins', 'enabled']); + const list = YAML.isSeq(enabled) ? (enabled.toJSON() as unknown[]) : []; + if (list.includes(name)) return false; + doc.setIn(['plugins', 'enabled'], [...list, name]); + await writeConfigDoc(doc); + return true; +} + +/** Remove `name` from `plugins.enabled` in the Hermes config.yaml, dropping keys left empty. */ +export async function disableHermesPlugin(name: string): Promise { + const doc = await readConfigDoc(); + const enabled = doc.getIn(['plugins', 'enabled']); + if (!YAML.isSeq(enabled)) return; + const list = enabled.toJSON() as unknown[]; + if (!list.includes(name)) return; + const rest = list.filter((entry) => entry !== name); + if (rest.length > 0) doc.setIn(['plugins', 'enabled'], rest); + else doc.deleteIn(['plugins', 'enabled']); + const plugins = doc.getIn(['plugins']); + if (YAML.isMap(plugins) && plugins.items.length === 0) doc.deleteIn(['plugins']); + await writeConfigDoc(doc); +} diff --git a/src/hermes-hooks.ts b/src/hermes-hooks.ts index b5d9ef8dc..898c8aa8d 100644 --- a/src/hermes-hooks.ts +++ b/src/hermes-hooks.ts @@ -18,11 +18,80 @@ import { removeHermesHookByCommand, addHermesAllowlist, removeHermesAllowlist, + enableHermesPlugin, + disableHermesPlugin, } from './hermes-config.js'; /** Hermes hook event used for session-start status reporting. */ const REPORT_EVENT = 'on_session_start'; +/** Name of teamai's Hermes plugin, also its `plugins.enabled` entry. */ +export const HERMES_INSTRUCTIONS_PLUGIN = 'teamai-instructions'; + +/** + * Characters Hermes allows one plugin prompt section. A larger section is + * skipped, so teamai reports it rather than truncating the instructions. + */ +export const HERMES_SECTION_LIMIT = 4000; + +/** Directory of teamai's Hermes plugin. */ +export function getInstructionsPluginDir(): string { + return path.join(getHermesHome(), 'plugins', HERMES_INSTRUCTIONS_PLUGIN); +} + +/** + * The plugin adds the member's team instructions for the session's project + * as a cache-safe system prompt section (#945). Hermes builds the section + * once for a new session and keeps it through compression and resume. + * Outside a project `hook-dispatch instructions` prints nothing and the + * section is empty: the user-scope blocks are in SOUL.md. + */ +export function buildInstructionsPlugin(): { manifest: string; init: string } { + const manifest = [ + '# [teamai] generated by teamai, do not edit.', + `name: ${HERMES_INSTRUCTIONS_PLUGIN}`, + 'version: "1"', + 'description: Adds the team instructions teamai resolves for this member and project.', + '', + ].join('\n'); + const init = [ + '"""[teamai] team instructions plugin, generated by teamai, do not edit."""', + 'import json', + 'import os', + 'import shutil', + 'import subprocess', + '', + '', + 'def _instructions(session_info):', + ' cwd = session_info.get("cwd") or os.getcwd()', + ' try:', + ' result = subprocess.run(', + ' [shutil.which("teamai") or "teamai", "hook-dispatch", "instructions", "--tool", "hermes"],', + ' input=json.dumps({"cwd": cwd, "session_id": session_info.get("session_id")}),', + ' capture_output=True,', + ' text=True,', + ' cwd=cwd,', + ' timeout=15,', + ' )', + ' out = result.stdout.strip()', + ' text = json.loads(out)["hookSpecificOutput"]["additionalContext"] if out else ""', + ' except Exception:', + ' return ""', + ' return text if isinstance(text, str) else ""', + '', + '', + 'def register(ctx):', + ' ctx.register_system_prompt_section(', + ' "teamai.instructions",', + ' _instructions,', + ' position="after_memory",', + ` max_chars=${HERMES_SECTION_LIMIT},`, + ' )', + '', + ].join('\n'); + return { manifest, init }; +} + /** Absolute path to the generated status-report shell script. */ export function getReportScriptPath(): string { return path.join(getHermesHome(), 'hooks', 'teamai-status-report.sh'); @@ -61,7 +130,12 @@ export async function injectHermesHooks(): Promise { } const hookChanged = await upsertHermesHook(REPORT_EVENT, { command: scriptPath, timeout: 60 }); const allowlistChanged = await addHermesAllowlist(REPORT_EVENT, scriptPath); - if (scriptChanged || hookChanged || allowlistChanged) { + const plugin = buildInstructionsPlugin(); + const pluginDir = getInstructionsPluginDir(); + const manifestChanged = await writeIfChanged(path.join(pluginDir, 'plugin.yaml'), plugin.manifest); + const initChanged = await writeIfChanged(path.join(pluginDir, '__init__.py'), plugin.init); + const pluginEnabled = await enableHermesPlugin(HERMES_INSTRUCTIONS_PLUGIN); + if (scriptChanged || hookChanged || allowlistChanged || manifestChanged || initChanged || pluginEnabled) { log.success('Injected teamai Hermes hook into ' + scriptPath); } else { log.debug(`teamai Hermes hook already up-to-date in ${scriptPath}`); @@ -83,6 +157,8 @@ export async function removeHermesHooks(): Promise { if (await pathExists(scriptPath)) { await remove(scriptPath); } + await disableHermesPlugin(HERMES_INSTRUCTIONS_PLUGIN); + await remove(getInstructionsPluginDir()); log.success('Removed teamai Hermes hook from ' + scriptPath); } diff --git a/src/instruction-targets.ts b/src/instruction-targets.ts index 5f91a962f..813bd6ef2 100644 --- a/src/instruction-targets.ts +++ b/src/instruction-targets.ts @@ -5,6 +5,7 @@ import { gitTracking, gitTracks } from './mcp-git-exclude.js'; import { TEAMAI_CONTEXT_RULE_NAME } from './builtin-rules.js'; import { getHermesHome } from './hermes-home.js'; import { getHermesSoulPath } from './hermes-config.js'; +import { HERMES_SECTION_LIMIT } from './hermes-hooks.js'; import { isAgentExcluded, resolveToolBaseDir, @@ -39,6 +40,8 @@ interface TargetEntry { readonly file: (paths: ToolPaths) => string | undefined; /** 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. */ + readonly hookLimit?: number; /** Text teamai writes above the blocks when it creates the file, e.g. the frontmatter a rules loader needs. */ readonly header?: string; /** teamai owns the whole file: one it did not write is left alone, and it is deleted once its blocks are gone. */ @@ -99,7 +102,9 @@ const PROJECT_TARGETS: Readonly> = { cursor, 'claude-internal': { file: configured, retired: [] }, tclaude: { file: configured, retired: [] }, - hermes: { file: configured, retired: [] }, + // Hermes reads the project's AGENTS.md itself; teamai's Hermes plugin adds + // the blocks as a system prompt section, which holds 4,000 characters. + hermes: { file: () => undefined, hook: true, hookLimit: HERMES_SECTION_LIMIT, retired: ['AGENTS.md'] }, copilot: { file: configured, retired: [] }, // OMP reads project rules only from the root and keeps one context file per // level, so .omp/AGENTS.md would hide the project's AGENTS.md: teamai's OMP @@ -139,9 +144,18 @@ export interface InstructionTarget { owned?: boolean; } +/** An installed, non-excluded tool that gets this scope's blocks from its session hook or extension. */ +export interface InstructionHook { + tool: string; + recall: boolean; + /** The most characters its channel takes, when it has a limit. */ + limit?: number; +} + export interface InstructionTargets { /** Targets of installed, non-excluded tools, one per file. */ targets: InstructionTarget[]; + hooks: InstructionHook[]; /** Known targets no installed tool reads: a pull strips teamai blocks from them. */ stale: InstructionTarget[]; } @@ -165,6 +179,11 @@ export function instructionTargetFile(tool: string, paths: ToolPaths, scope: Sco return (entryFor(tool, scope)?.file ?? configured)(paths); } +/** Files, relative to the tool's base dir, an earlier release wrote `tool`'s blocks to in `scope`. */ +export function retiredInstructionFiles(tool: string, scope: Scope): readonly string[] { + return entryFor(tool, scope)?.retired ?? []; +} + /** Whether `tool` gets this scope's blocks from teamai's session hook or extension rather than a file. */ export function deliversInstructionsByHook(tool: string, scope: Scope): boolean { return entryFor(tool, scope)?.hook === true; @@ -238,7 +257,15 @@ export async function resolveInstructionTargets( // Files an installed tool reads, excluded or not: an excluded tool's file is // left alone, not cleaned. const inUse = new Set(); + const hooks: InstructionHook[] = []; for (const [tool, paths] of Object.entries(scopedToolPaths(teamConfig, localConfig))) { + const entry = entryFor(tool, localConfig.scope); + if (entry?.hook) { + if (!isAgentExcluded(localConfig, tool) && await isInstalled(tool, paths, localConfig)) { + hooks.push({ tool, recall: Boolean(paths.agents), limit: entry.hookLimit }); + } + continue; + } const file = instructionTargetPath(tool, paths, localConfig); if (!file || !await isInstalled(tool, paths, localConfig)) continue; inUse.add(file); @@ -249,7 +276,7 @@ export async function resolveInstructionTargets( targets.set(file, target); } const stale = [...knownInstructionTargets(teamConfig, localConfig).values()].filter((t) => !inUse.has(t.path)); - return { targets: [...targets.values()], stale }; + return { targets: [...targets.values()], hooks, stale }; } // ─── Planning file contents ──────────────────────────── diff --git a/src/pull.ts b/src/pull.ts index fba19c483..2d16dd548 100644 --- a/src/pull.ts +++ b/src/pull.ts @@ -14,7 +14,7 @@ import { indexableLearningsRoots } from './utils/learnings-roots.js'; import { log, spinner } from './utils/logger.js'; import { pathExists, remove, listFiles, listDirs, listFilesRecursive, readFileSafe, dirContentEqual, hasVcsMetadataRecursive } from './utils/fs.js'; import { reconcilePlacementRecords } from './utils/pending-push.js'; -import { applyInstructionPlan, planInstructionFiles, resolveInstructionTargets, type InstructionBlocks } from './instruction-targets.js'; +import { applyInstructionPlan, instructionHookText, planInstructionFiles, resolveInstructionTargets, type InstructionBlocks } from './instruction-targets.js'; import { getHandler, RulesHandler, DocsHandler, EnvHandler, AgentsHandler } from './resources/index.js'; import { reportHeldAgents, type RedeployedCopy } from './resources/agents.js'; import { listStaleDocDirectories, resolveDesiredDocs, resolveDocsDestination } from './resources/docs.js'; @@ -1888,7 +1888,7 @@ async function syncManagedInstructions( dryRun = false, ): Promise { const { blocks, claudemdFiles } = await resolveInstructionBlocks(config, localConfig, roleContext); - const { targets, stale } = await resolveInstructionTargets(config, localConfig); + const { targets, hooks, stale } = await resolveInstructionTargets(config, localConfig); const plan = await planInstructionFiles(targets, blocks, stale); for (const warning of plan.warnings) log.warn(`[${scopeLabel}] ${warning}`); const { report, failures } = await applyInstructionPlan(plan, { dryRun }); @@ -1898,7 +1898,13 @@ async function syncManagedInstructions( else log.debug(line); } for (const failure of failures) log.warn(`[${scopeLabel}] ${failure}`); - if (dryRun || targets.length === 0) return; + for (const hook of hooks) { + const length = instructionHookText(blocks, hook.recall).length; + if (hook.limit !== undefined && length > hook.limit) { + log.warn(`[${scopeLabel}] ${hook.tool} cannot load this project's team instructions: they are ${length} characters, over the ${hook.limit}-character limit of its prompt section, so ${hook.tool} skips them. Shorten culture.md or the claudemd/ files for this scope. teamai does not cut them or write them to AGENTS.md.`); + } + } + if (dryRun || targets.length + hooks.length === 0) return; if (blocks.culture) log.success('Synced team culture'); if (blocks.claudemd) log.success(`[${scopeLabel}] Synced shared instructions (${claudemdFiles} file(s))`); } diff --git a/src/uninstall.ts b/src/uninstall.ts index e0fc8cb6f..65a56b365 100644 --- a/src/uninstall.ts +++ b/src/uninstall.ts @@ -54,7 +54,7 @@ import { } from './builtin-skills.js'; import { getHermesHome } from './hermes-home.js'; import { CODEX_TOOL, SHARED_AGENT_SKILLS_PATH } from './resources/skills.js'; -import { clearInstructionFile, instructionTargetFile } from './instruction-targets.js'; +import { clearInstructionFile, instructionTargetFile, retiredInstructionFiles } from './instruction-targets.js'; import { pathExists, readFileSafe, @@ -152,6 +152,8 @@ interface ToolResources { piHookFiles: string[]; dshHookFile: string | null; claudeMdFiles: string[]; + /** Files an earlier release wrote this tool's instruction blocks to; no tool reads them now (#945). */ + retiredInstructionFiles: string[]; skillDirs: SkillDirEntry[]; ruleFiles: string[]; keptRuleFiles: string[]; @@ -167,6 +169,7 @@ function hasToolResources(r: ToolResources): boolean { r.piHookFiles.length > 0 || r.dshHookFile !== null || r.claudeMdFiles.length > 0 || + r.retiredInstructionFiles.length > 0 || r.skillDirs.length > 0 || r.ruleFiles.length > 0 || r.agentFiles.length > 0 @@ -313,7 +316,7 @@ async function discoverToolResources( ): Promise { const res: ToolResources = { hookFiles: [], openclawHookDirs: [], opencodeHookScopes: [], ompHookFile: null, piHookFiles: [], dshHookFile: null, - claudeMdFiles: [], skillDirs: [], ruleFiles: [], keptRuleFiles: [], agentFiles: [], + claudeMdFiles: [], retiredInstructionFiles: [], skillDirs: [], ruleFiles: [], agentFiles: [], }; // (a) Hooks — settings.json / hooks.json @@ -442,6 +445,13 @@ async function discoverToolResources( res.claudeMdFiles.push(claudeMdPath); } } + for (const retired of retiredInstructionFiles(tool, scope)) { + const file = path.resolve(baseDir, retired); + const content = await readFileSafe(file); + if (content && CLAUDEMD_MARKER_PAIRS.some(([start]) => content.includes(start))) { + res.retiredInstructionFiles.push(file); + } + } // (c) Skills — only those matching team repo if (toolPath.skills) { @@ -706,6 +716,10 @@ async function buildRemovalPlan( .filter(([start]) => content.includes(start) && !kept?.has(start)); if (blocks.length > 0) plan.claudeMdFiles.push({ path: file, blocks }); } + // No tool reads a retired file any more, so nothing retains its blocks. + for (const file of res.retiredInstructionFiles) { + if (!plan.claudeMdFiles.includes(file)) plan.claudeMdFiles.push(file); + } plan.skillDirs.push(...res.skillDirs); plan.ruleFiles.push(...res.ruleFiles); plan.keptRuleFiles.push(...res.keptRuleFiles); From e63561cc1fde28885eac7d322fd97ab6ea03914d Mon Sep 17 00:00:00 2001 From: Saul Moro Date: Thu, 1 Oct 2026 14:56:45 +0200 Subject: [PATCH 10/41] fix(pull): give OpenCode its own team instruction file (#945) OpenCode had no instruction target and only saw the blocks other tools left in AGENTS.md. It now gets them in .opencode/teamai-context.md, listed in the instructions of .opencode/opencode.json, and in user scope in ~/.config/opencode/teamai-context.md, listed by absolute path in the user opencode.json. Only that entry is added or removed; the member's entries and the root opencode.json stay as they are. While ~/.config/opencode/AGENTS.md does not exist, OpenCode reads ~/.claude/CLAUDE.md, which already holds the user blocks when Claude is installed, so teamai adds no second copy and says so. Verified with OpenCode 1.18.21 against a local capture server: the blocks reach the request once from the project root and a subdirectory. --- docs/usage-guide.md | 3 ++ docs/usage-guide.zh-CN.md | 3 ++ src/__tests__/e2e/instruction-targets.test.ts | 46 +++++++++++++++++++ src/instruction-targets.ts | 4 ++ src/pull.ts | 42 ++++++++++++++++- src/resources/opencode-config.ts | 37 ++++++++++++++- src/uninstall.ts | 12 +++++ 7 files changed, 143 insertions(+), 4 deletions(-) diff --git a/docs/usage-guide.md b/docs/usage-guide.md index b71793d3d..bf974f44f 100644 --- a/docs/usage-guide.md +++ b/docs/usage-guide.md @@ -1381,6 +1381,7 @@ Recall counts every doc it returns (`recalled_count`). A returned doc is **adopt | OpenCode | Yes | Yes: the `task` call links the subagent's session to its parent | | OMP | Yes, settled only by the claim of its `bash` call | Yes: the subagent's session file sits under its parent's, whose session header links the two sessions (verified against OMP 18.4.8) | | Pi | Yes | None: TeamAI deploys no subagent to Pi | +| 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` | | ZCode | Yes | No: ZCode runs no hooks inside a subagent | | OpenClaw, Hermes, Kiro, JoyCode | No: no PostToolUse hook | No | @@ -1664,6 +1665,8 @@ The CodeBuddy and WorkBuddy rule files are applied in every session (`alwaysAppl In a project, teamai installs the Hermes plugin `$HERMES_HOME/plugins/teamai-instructions/` and adds it to `plugins.enabled` in `$HERMES_HOME/config.yaml` (a name you list under `plugins.disabled` stays off). Hermes builds the section once for each new session from the session's directory and keeps it through compression and resume. A section holds at most 4,000 characters, and all plugin sections together at most 8,000. When this member's instructions for the project are longer, Hermes skips them and `teamai pull` says so: teamai does not cut them or write them to `AGENTS.md`. Outside a project the section is empty, and Hermes may log that it skipped an empty section. +OpenCode loads a file only when its config lists it in `instructions`, so teamai adds that one entry and keeps your other entries and keys; the root `opencode.json` and OpenCode's own `AGENTS.md` files are left alone. While `~/.config/opencode/AGENTS.md` does not exist, OpenCode reads `~/.claude/CLAUDE.md` instead; when Claude Code gets the user blocks there, OpenCode already has them, so teamai writes no second user copy for OpenCode and says so in the pull output. A config file teamai cannot parse as JSON (for example one with comments) is left unchanged with a warning; add the entry by hand. + Oh My Pi reads `RULES.md` as an always-applied rule beside its single user context file. In project scope teamai's OMP extension asks `teamai` for the blocks when the session starts and adds them to each turn's system prompt, from the project root and any subdirectory. Without that extension (for example with hooks removed), an Oh My Pi project session gets no team blocks. A pull from an earlier release may have left these blocks in a file listed below. The next pull removes them, and names each file it changes: diff --git a/docs/usage-guide.zh-CN.md b/docs/usage-guide.zh-CN.md index 2fd4be2b9..ef5dd8cda 100644 --- a/docs/usage-guide.zh-CN.md +++ b/docs/usage-guide.zh-CN.md @@ -1259,6 +1259,7 @@ recall 会为返回的每篇文档计数(`recalled_count`)。运行 recall | OpenCode | 支持 | 支持:`task` 调用将 subagent 的会话关联到父会话 | | OMP | 支持,仅通过其 `bash` 调用的认领确定归属 | 支持:subagent 的会话文件位于父会话文件之下,父会话文件的会话头把两个会话关联起来(对照 OMP 18.4.8 验证) | | Pi | 支持 | 不适用:TeamAI 不向 Pi 部署 subagent | +| OpenCode | `~/.config/opencode/teamai-context.md`,以绝对路径列在 `~/.config/opencode/opencode.json` 的 `instructions` 中 | `.opencode/teamai-context.md`,列在 `.opencode/opencode.json` 的 `instructions` 中 | | ZCode | 支持 | 不支持:ZCode 在 subagent 内不运行 hook | | OpenClaw、Hermes、Kiro、JoyCode | 不支持:没有 PostToolUse hook | 不支持 | @@ -1542,6 +1543,8 @@ CodeBuddy 和 WorkBuddy 的规则文件在每个会话中应用(`alwaysApply: 在项目中,teamai 安装 Hermes 插件 `$HERMES_HOME/plugins/teamai-instructions/`,并把它加入 `$HERMES_HOME/config.yaml` 的 `plugins.enabled`(列在 `plugins.disabled` 中的名称保持关闭)。Hermes 在每个新会话开始时根据会话目录生成该段落,并在压缩和恢复后保留。一个段落最多 4,000 个字符,所有插件段落合计最多 8,000 个字符。当该成员在此项目的指令更长时,Hermes 会跳过它们,`teamai pull` 会给出提示:teamai 不会截断它们,也不会写入 `AGENTS.md`。在项目之外段落为空,Hermes 可能记录它跳过了一个空段落。 +OpenCode 只加载配置中 `instructions` 列出的文件,因此 teamai 只添加这一项,并保留你的其他条目和键;根目录的 `opencode.json` 以及 OpenCode 自己的 `AGENTS.md` 文件保持不变。当 `~/.config/opencode/AGENTS.md` 不存在时,OpenCode 会改为读取 `~/.claude/CLAUDE.md`;若 Claude Code 的用户块已在那里,OpenCode 已经获得它们,因此 teamai 不会为 OpenCode 再写一份用户副本,并在 pull 输出中说明。teamai 无法按 JSON 解析的配置文件(例如带注释的文件)保持不变并给出警告,请手动添加该条目。 + Oh My Pi 把 `RULES.md` 作为始终应用的规则读取,与其唯一的用户上下文文件并存。在项目范围内,teamai 的 OMP 扩展在会话开始时向 `teamai` 获取这些块,并加入每轮的系统提示,无论会话从项目根目录还是子目录启动。没有该扩展时(例如移除了 hooks),Oh My Pi 的项目会话不会获得团队块。 早期版本的 pull 可能把这些块留在下列文件中。下一次 pull 会移除它们,并列出所修改的每个文件: diff --git a/src/__tests__/e2e/instruction-targets.test.ts b/src/__tests__/e2e/instruction-targets.test.ts index 500b62ffe..11055c96e 100644 --- a/src/__tests__/e2e/instruction-targets.test.ts +++ b/src/__tests__/e2e/instruction-targets.test.ts @@ -532,4 +532,50 @@ describe('instruction block targets on real CLI pull (#945)', () => { expect(result.output).toMatch(/hermes cannot load this project's team instructions: they are \d+ characters, over the 4000-character limit/); expect(fs.readFileSync(path.join(member.projectRoot, 'AGENTS.md'), 'utf8')).toBe(PROJECT_AGENTS_MD); }); + + it('gives OpenCode its project blocks in .opencode/teamai-context.md, registered in .opencode/opencode.json', async () => { + const sandbox = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'teamai-945-e2e-'))); + sandboxes.push(sandbox); + const member = makeProjectMember(sandbox, makeTeamAndProject(sandbox), 'dev', 'developer', ['.opencode/skills']); + const config = path.join(member.projectRoot, '.opencode', 'opencode.json'); + fs.writeFileSync(config, JSON.stringify({ instructions: ['docs/style.md'], theme: 'dark' }, null, 2)); + + for (let i = 0; i < 2; i++) { + const result = await pullAs(member); + expect(result.code, result.output).toBe(0); + } + + expect(fs.readFileSync(path.join(member.projectRoot, '.opencode', 'teamai-context.md'), 'utf8')).toContain('DEVELOPMENT-SENTINEL'); + expect(JSON.parse(fs.readFileSync(config, 'utf8'))).toEqual({ instructions: ['docs/style.md', '.opencode/teamai-context.md'], theme: 'dark' }); + expect(fs.existsSync(path.join(member.projectRoot, 'opencode.json'))).toBe(false); + expect(fs.readFileSync(path.join(member.projectRoot, 'AGENTS.md'), 'utf8')).toBe(PROJECT_AGENTS_MD); + + const uninstall = await runCLI(['uninstall', '--agent', 'opencode', '--force'], { HOME: member.home }, member.projectRoot); + expect(uninstall.code, uninstall.output).toBe(0); + expect(fs.existsSync(path.join(member.projectRoot, '.opencode', 'teamai-context.md'))).toBe(false); + expect(JSON.parse(fs.readFileSync(config, 'utf8'))).toEqual({ instructions: ['docs/style.md'], theme: 'dark' }); + }); + + it('gives OpenCode its user blocks in its config dir, registered with an absolute path, and adds no copy beside its Claude fallback', async () => { + const own = makeUserSandbox(['.config/opencode']); + sandboxes.push(own.sandbox); + const ocDir = path.join(own.home, '.config', 'opencode'); + fs.writeFileSync(path.join(ocDir, 'AGENTS.md'), '# My OpenCode notes\n'); + fs.writeFileSync(path.join(ocDir, 'opencode.json'), JSON.stringify({ instructions: ['~/notes.md'] })); + const result = await runCLI(['pull'], { HOME: own.home }, own.sandbox); + expect(result.code, result.output).toBe(0); + const contextFile = path.join(ocDir, 'teamai-context.md'); + expect(fs.readFileSync(contextFile, 'utf8')).toContain(CLAUDEMD_START); + expect(JSON.parse(fs.readFileSync(path.join(ocDir, 'opencode.json'), 'utf8')).instructions).toEqual(['~/notes.md', contextFile]); + expect(fs.readFileSync(path.join(ocDir, 'AGENTS.md'), 'utf8')).toBe('# My OpenCode notes\n'); + + // No native user AGENTS.md: OpenCode falls back to ~/.claude/CLAUDE.md, which already holds the blocks. + const fallback = makeUserSandbox(['.config/opencode', '.claude']); + sandboxes.push(fallback.sandbox); + const viaClaude = await runCLI(['pull'], { HOME: fallback.home }, fallback.sandbox); + expect(viaClaude.code, viaClaude.output).toBe(0); + expect(fs.readFileSync(path.join(fallback.home, '.claude', 'CLAUDE.md'), 'utf8')).toContain(CLAUDEMD_START); + expect(fs.existsSync(path.join(fallback.home, '.config', 'opencode', 'teamai-context.md'))).toBe(false); + expect(viaClaude.output).toContain('OpenCode reads the team instructions from'); + }); }); diff --git a/src/instruction-targets.ts b/src/instruction-targets.ts index 813bd6ef2..46fc1c59f 100644 --- a/src/instruction-targets.ts +++ b/src/instruction-targets.ts @@ -92,6 +92,8 @@ const USER_TARGETS: Readonly> = { workbuddy: { file: contextRule('.md'), header: ALWAYS_APPLY, owned: true, retired: ['AGENTS.md'] }, codebuddy: { file: configured, retired: [] }, openclaw: { file: configured, 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: [] }, }; const PROJECT_TARGETS: Readonly> = { @@ -116,6 +118,8 @@ const PROJECT_TARGETS: Readonly> = { 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: [] }, + // Registered in .opencode/opencode.json `instructions`; the root opencode.json stays the project's. + opencode: { file: () => '.opencode/teamai-context.md', owned: true, retired: [] }, }; type MarkerPair = readonly [start: string, end: string, name: string]; diff --git a/src/pull.ts b/src/pull.ts index 2d16dd548..db4bc3445 100644 --- a/src/pull.ts +++ b/src/pull.ts @@ -14,7 +14,7 @@ import { indexableLearningsRoots } from './utils/learnings-roots.js'; import { log, spinner } from './utils/logger.js'; import { pathExists, remove, listFiles, listDirs, listFilesRecursive, readFileSafe, dirContentEqual, hasVcsMetadataRecursive } from './utils/fs.js'; import { reconcilePlacementRecords } from './utils/pending-push.js'; -import { applyInstructionPlan, instructionHookText, planInstructionFiles, resolveInstructionTargets, type InstructionBlocks } from './instruction-targets.js'; +import { applyInstructionPlan, instructionHookText, instructionTargetPath, planInstructionFiles, resolveInstructionTargets, type InstructionBlocks, type InstructionTarget } from './instruction-targets.js'; import { getHandler, RulesHandler, DocsHandler, EnvHandler, AgentsHandler } from './resources/index.js'; import { reportHeldAgents, type RedeployedCopy } from './resources/agents.js'; import { listStaleDocDirectories, resolveDesiredDocs, resolveDocsDestination } from './resources/docs.js'; @@ -57,6 +57,7 @@ import { declaredSecretKeys } from './resources/secrets.js'; import { envShVariables, resolveTeamEnv, variablesKeptWarning, type TeamEnv } from './env-resolution.js'; import { describeEnvAdvisory, envAdvisories } from './env-advisories.js'; import { getUserHome } from './utils/home.js'; +import { opencodeClaudeFallback, opencodeContextReference, reconcileOpencodeInstructions } from './resources/opencode-config.js'; import { acquireLock, releaseLock } from './update.js'; import { mirrorLearnings } from './utils/learnings-mirror.js'; import { withTimeout } from './utils/async.js'; @@ -1888,7 +1889,18 @@ async function syncManagedInstructions( dryRun = false, ): Promise { const { blocks, claudemdFiles } = await resolveInstructionBlocks(config, localConfig, roleContext); - const { targets, hooks, stale } = await resolveInstructionTargets(config, localConfig); + const resolved = await resolveInstructionTargets(config, localConfig); + const { hooks, stale } = resolved; + let { targets } = resolved; + const opencodeTarget = targets.find((target) => target.tools.includes('opencode')); + if (opencodeTarget && localConfig.scope === 'user') { + const claudeFile = await opencodeClaudeFallback(getUserHome(), targets.map((target) => target.path)); + if (claudeFile) { + log.info(`[${scopeLabel}] OpenCode reads the team instructions from ${claudeFile}, its fallback while ~/.config/opencode/AGENTS.md does not exist, so teamai adds no second copy for it. Create that AGENTS.md to have teamai deliver them to ${opencodeTarget.path} instead.`); + targets = targets.filter((target) => target !== opencodeTarget); + stale.push(opencodeTarget); + } + } const plan = await planInstructionFiles(targets, blocks, stale); for (const warning of plan.warnings) log.warn(`[${scopeLabel}] ${warning}`); const { report, failures } = await applyInstructionPlan(plan, { dryRun }); @@ -1898,6 +1910,7 @@ async function syncManagedInstructions( else log.debug(line); } for (const failure of failures) log.warn(`[${scopeLabel}] ${failure}`); + if (!dryRun) await registerOpencodeContext(config, localConfig, targets, stale); for (const hook of hooks) { const length = instructionHookText(blocks, hook.recall).length; if (hook.limit !== undefined && length > hook.limit) { @@ -1909,6 +1922,31 @@ async function syncManagedInstructions( if (blocks.claudemd) log.success(`[${scopeLabel}] Synced shared instructions (${claudemdFiles} file(s))`); } +/** + * Point OpenCode's `instructions` at teamai's instruction file while it + * exists, and drop the entry once it is gone: OpenCode reads no file it is + * not told about (#945). + */ +async function registerOpencodeContext( + config: TeamaiConfig, + localConfig: LocalConfig, + targets: readonly InstructionTarget[], + stale: readonly InstructionTarget[], +): Promise { + const paths = scopedToolPaths(config, localConfig).opencode; + const contextFile = paths && instructionTargetPath('opencode', paths, localConfig); + if (!contextFile) return; + const present = targets.some((target) => target.path === contextFile) && await pathExists(contextFile); + if (!present && !stale.some((target) => target.path === contextFile)) return; + const projectRoot = resolveToolBaseDir('opencode', localConfig); + const { config: configFile, entry } = opencodeContextReference(contextFile, localConfig.scope, projectRoot); + try { + await reconcileOpencodeInstructions(configFile, entry, present, 'team instructions'); + } catch (e) { + log.warn(`Failed to update ${configFile}: ${(e as Error).message}. Add "${entry}" to its "instructions" by hand so OpenCode loads the team instructions.`); + } +} + /** * Build the CLAUDE.md block that instructs the main conversation to: * 1. Invoke the `teamai-recall` subagent before starting any task that diff --git a/src/resources/opencode-config.ts b/src/resources/opencode-config.ts index 71d39d3d4..5e310032b 100644 --- a/src/resources/opencode-config.ts +++ b/src/resources/opencode-config.ts @@ -50,6 +50,7 @@ export async function reconcileOpencodeInstructions( configFileAbs: string, glob: string, present: boolean, + purpose = 'rules activation', ): Promise { const exists = await pathExists(configFileAbs); @@ -70,12 +71,12 @@ export async function reconcileOpencodeInstructions( try { const parsed = JSON.parse(raw) as unknown; if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { - log.warn(`Could not parse ${configFileAbs} as a JSON object — skipping OpenCode rules activation`); + log.warn(`Could not parse ${configFileAbs} as a JSON object — skipping OpenCode ${purpose}`); return false; } data = parsed as Record; } catch { - log.warn(`Could not parse ${configFileAbs} — skipping OpenCode rules activation`); + log.warn(`Could not parse ${configFileAbs} — skipping OpenCode ${purpose}`); return false; } } @@ -106,3 +107,35 @@ export async function reconcileOpencodeInstructions( log.debug(`${present ? 'Added' : 'Removed'} teamai rules glob in ${configFileAbs}`); return true; } + +// ─── OpenCode team instructions (#945) ─────────────────────── + +/** + * Where OpenCode is told to load teamai's instruction file: the config file + * and its `instructions` entry. In user scope the user config holds the + * file's absolute path. In a project `.opencode/opencode.json` holds the path + * from the project root, which OpenCode resolves the same way from any + * subdirectory; the root `opencode.json` is left alone. + */ +export function opencodeContextReference(contextFile: string, scope: 'user' | 'project', projectRoot: string): { config: string; entry: string } { + if (scope === 'user') { + 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('/'), + }; +} + +/** + * OpenCode loads `~/.claude/CLAUDE.md` while its own user `AGENTS.md` does not + * exist. When teamai delivers the user blocks there for Claude, OpenCode + * already gets them, and a second file would add a duplicate. Returns the + * Claude file in that case, null otherwise. + */ +export async function opencodeClaudeFallback(home: string, targetPaths: readonly string[]): Promise { + const claudeFile = path.join(home, '.claude', 'CLAUDE.md'); + if (!targetPaths.includes(claudeFile)) return null; + if (await pathExists(path.join(home, '.config', 'opencode', 'AGENTS.md'))) return null; + return claudeFile; +} diff --git a/src/uninstall.ts b/src/uninstall.ts index 65a56b365..51f6bc46e 100644 --- a/src/uninstall.ts +++ b/src/uninstall.ts @@ -178,6 +178,12 @@ function hasToolResources(r: ToolResources): boolean { // ─── Helpers ─────────────────────────────────────────── +/** OpenCode's teamai instruction files in each scope (#945). */ +const OPENCODE_CONTEXT_FILES = [ + path.join('.config', 'opencode', 'teamai-context.md'), + path.join('.opencode', 'teamai-context.md'), +]; + const CLAUDEMD_MARKER_PAIRS: Array<[string, string]> = [ [TEAMAI_RULES_START, TEAMAI_RULES_END], [TEAMAI_CULTURE_START, TEAMAI_CULTURE_END], @@ -1060,6 +1066,12 @@ async function executeRemoval(plan: RemovalPlan): Promise { const { changed, warnings } = await clearInstructionFile(claudeMdPath); for (const warning of warnings) log.warn(warning); if (changed) log.success(`Cleaned CLAUDE.md: ${claudeMdPath}`); + // OpenCode loads its file through an `instructions` entry; drop it with the file. + if (OPENCODE_CONTEXT_FILES.some((suffix) => claudeMdPath.endsWith(suffix)) && !await pathExists(claudeMdPath)) { + const { opencodeContextReference, reconcileOpencodeInstructions } = await import('./resources/opencode-config.js'); + const { config, entry } = opencodeContextReference(claudeMdPath, plan.scope, path.dirname(path.dirname(claudeMdPath))); + await reconcileOpencodeInstructions(config, entry, false, 'team instructions'); + } } catch (e) { log.warn(`Failed to clean ${claudeMdPath}: ${(e as Error).message}`); } From b05bbc95fd03b205c09fce9a39eb160fed6ed99d Mon Sep 17 00:00:00 2001 From: Saul Moro Date: Thu, 1 Oct 2026 14:58:50 +0200 Subject: [PATCH 11/41] feat(doctor): check that each tool can load its team instructions (#945) doctor now asks what keeps a tool from loading this member's culture, claudemd and recall blocks, not whether a file was written: each file target must hold the current blocks, OpenCode's file must be listed in its instructions, the Pi and Oh My Pi extensions and the Hermes plugin must be installed as this build writes them (and the plugin enabled), the Hermes section must fit its 4,000-character limit, and no file an earlier release wrote may still hold blocks. --- docs/usage-guide.md | 2 + docs/usage-guide.zh-CN.md | 2 + src/__tests__/doctor-rules-delivery.test.ts | 2 + src/__tests__/e2e/instruction-targets.test.ts | 26 ++++ src/doctor-delivery.ts | 122 +++++++++++++++++- src/doctor.ts | 2 + 6 files changed, 155 insertions(+), 1 deletion(-) diff --git a/docs/usage-guide.md b/docs/usage-guide.md index bf974f44f..49363b47c 100644 --- a/docs/usage-guide.md +++ b/docs/usage-guide.md @@ -1678,6 +1678,8 @@ A pull from an earlier release may have left these blocks in a file listed below - 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` +`teamai doctor` checks that each installed tool can load these blocks: that each file holds the current blocks, that OpenCode's config lists its file, that the Pi or Oh My Pi extension and the Hermes plugin are installed and enabled, that the Hermes section fits its limit, and that no file an earlier release wrote still holds blocks. + A file named like a teamai target that teamai did not write is left alone, and the pull warns about it. teamai does not change `.gitignore`, `.git/info/exclude` or the git index. A team that wants to keep these files out of commits excludes them itself. ### Viewing the result diff --git a/docs/usage-guide.zh-CN.md b/docs/usage-guide.zh-CN.md index ef5dd8cda..f4d2a187c 100644 --- a/docs/usage-guide.zh-CN.md +++ b/docs/usage-guide.zh-CN.md @@ -1556,6 +1556,8 @@ Oh My Pi 把 `RULES.md` 作为始终应用的规则读取,与其唯一的用 - Oh My Pi:`~/.omp/agent/AGENTS.md` 和 `.omp/AGENTS.md`。Oh My Pi 每一层只读取一个上下文文件,因此它们会遮蔽 `~/.agents/AGENTS.md` 和项目的 `AGENTS.md`。 - Pi:项目 `AGENTS.md` +`teamai doctor` 会检查每个已安装工具能否加载这些块:每个文件是否包含当前的块,OpenCode 配置是否列出其文件,Pi 或 Oh My Pi 扩展和 Hermes 插件是否已安装并启用,Hermes 段落是否在限制内,以及早期版本写过的文件中是否仍残留块。 + 与 teamai 目标同名但并非 teamai 写入的文件保持不变,pull 会给出警告。teamai 不修改 `.gitignore`、`.git/info/exclude` 或 git 索引。若团队希望这些文件不进入提交,需要自行排除。 ### 查看效果 diff --git a/src/__tests__/doctor-rules-delivery.test.ts b/src/__tests__/doctor-rules-delivery.test.ts index 5bacade63..58ddf97a5 100644 --- a/src/__tests__/doctor-rules-delivery.test.ts +++ b/src/__tests__/doctor-rules-delivery.test.ts @@ -573,8 +573,10 @@ describe('doctor — rules delivered on disk', () => { expect(forDoctor.filter((n) => !forPull.includes(n)).sort()).toEqual([ 'Agents delivered to claude', + 'No team instruction blocks are left in files no tool loads them from', 'Rules delivered to claude', 'Rules delivered to cursor', + 'Team instructions are current for cursor', ]); // Everything the pull stage keeps is also in the doctor stage. expect(forPull.filter((n) => !forDoctor.includes(n))).toEqual([]); diff --git a/src/__tests__/e2e/instruction-targets.test.ts b/src/__tests__/e2e/instruction-targets.test.ts index 11055c96e..9de5c4a38 100644 --- a/src/__tests__/e2e/instruction-targets.test.ts +++ b/src/__tests__/e2e/instruction-targets.test.ts @@ -578,4 +578,30 @@ describe('instruction block targets on real CLI pull (#945)', () => { expect(fs.existsSync(path.join(fallback.home, '.config', 'opencode', 'teamai-context.md'))).toBe(false); expect(viaClaude.output).toContain('OpenCode reads the team instructions from'); }); + + it('has doctor report what keeps a tool from loading its instructions, not just whether a file was written', async () => { + const sandbox = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'teamai-945-e2e-'))); + sandboxes.push(sandbox); + const member = makeProjectMember(sandbox, makeTeamAndProject(sandbox), 'dev', 'developer', ['.opencode/skills']); + const pulled = await pullAs(member); + expect(pulled.code, pulled.output).toBe(0); + const doctor = async (): Promise> => { + const run = await runCLI(['doctor', '--json'], { HOME: member.home }, member.projectRoot); + const report = JSON.parse(run.stdout) as { checks: Array<{ name: string; ok: boolean; fix?: string }> }; + return new Map(report.checks.map((c) => [c.name, c])); + }; + + const healthy = await doctor(); + expect(healthy.get('Team instructions are current for opencode')?.ok).toBe(true); + expect(healthy.get('Team instructions are listed in opencode instructions')?.ok).toBe(true); + expect(healthy.get('No team instruction blocks are left in files no tool loads them from')?.ok).toBe(true); + + fs.writeFileSync(path.join(member.projectRoot, '.opencode', 'opencode.json'), '{}\n'); + fs.appendFileSync(path.join(member.projectRoot, 'AGENTS.md'), `\n${CLAUDEMD_START}\nold\n${CLAUDEMD_END}\n`); + const broken = await doctor(); + expect(broken.get('Team instructions are listed in opencode instructions')).toMatchObject({ ok: false }); + expect(broken.get('Team instructions are listed in opencode instructions')?.fix).toContain('.opencode/teamai-context.md'); + expect(broken.get('No team instruction blocks are left in files no tool loads them from')).toMatchObject({ ok: false }); + expect(broken.get('No team instruction blocks are left in files no tool loads them from')?.fix).toContain('AGENTS.md'); + }); }); diff --git a/src/doctor-delivery.ts b/src/doctor-delivery.ts index 400148eb8..2906db909 100644 --- a/src/doctor-delivery.ts +++ b/src/doctor-delivery.ts @@ -2,7 +2,7 @@ import path from 'node:path'; import fs from 'node:fs'; import { isDeepStrictEqual } from 'node:util'; import { expandHome, listFilesRecursive, pathExists, readFileSafe } from './utils/fs.js'; -import { getDataHome, getMcpSharing, isAgentExcluded, managedMcpManifestKey } from './types.js'; +import { getDataHome, getMcpSharing, isAgentExcluded, managedMcpManifestKey, resolveToolBaseDir, scopedToolPaths } from './types.js'; import type { DeliveryTarget, LocalConfig, ManagedMcpManifest, ResourceItem, TeamaiConfig } from './types.js'; import type { EntryLayout, EntryResolution } from './namespaced-entries.js'; import { splitFrontmatter } from './utils/frontmatter.js'; @@ -1161,3 +1161,123 @@ function listed(sources: string[]): string { ? sources.join(' and ') : `${sources.slice(0, -1).join(', ')} and ${sources[sources.length - 1]}`; } + +/** + * Team instructions (#945): whether each installed tool can load this + * member's culture, claudemd and recall blocks, not only whether a file was + * written. A file target must hold the current blocks, and OpenCode's must be + * listed in its `instructions`; a hook target needs teamai's extension or + * plugin as this build writes it, and room in its channel; and no file an + * earlier release wrote may still hold blocks. + */ +export async function buildInstructionDeliveryChecks(ctx: DoctorContext): Promise { + const { localConfig, teamConfig } = ctx; + if (!teamConfig) return []; + const { + instructionHookText, instructionTargetPath, planInstructionFiles, resolveInstructionTargets, + } = await import('./instruction-targets.js'); + const { resolveInstructionBlocks } = await import('./pull.js'); + const { buildRolePullContext } = await import('./resources/desired.js'); + const { blocks } = await resolveInstructionBlocks(teamConfig, localConfig, await buildRolePullContext(localConfig)); + const { targets, hooks, stale } = await resolveInstructionTargets(teamConfig, localConfig); + const pullForce = 'Run `teamai pull --force`: a plain pull skips a scope whose team repo has not changed.'; + const checks: Check[] = []; + + const { opencodeClaudeFallback, opencodeContextReference } = await import('./resources/opencode-config.js'); + const claudeFallback = localConfig.scope === 'user' + ? await opencodeClaudeFallback(getUserHome(), targets.map((t) => t.path)) + : null; + for (const target of targets) { + if (claudeFallback && target.tools.includes('opencode')) continue; + const plan = await planInstructionFiles([target], blocks); + checks.push({ + name: `Team instructions are current for ${target.tools.join(', ')}`, + source: 'local', + check: async () => plan.changes.length === 0 && plan.warnings.length === 0, + fix: plan.warnings.length > 0 + ? plan.warnings.join(' ') + : `${target.path} does not hold this member's current team instructions. ${pullForce}`, + }); + } + + const opencodePaths = scopedToolPaths(teamConfig, localConfig).opencode; + const opencodeFile = opencodePaths && instructionTargetPath('opencode', opencodePaths, localConfig); + if (opencodeFile && !claudeFallback && targets.some((t) => t.path === opencodeFile)) { + const { config, entry } = opencodeContextReference(opencodeFile, localConfig.scope, resolveToolBaseDir('opencode', localConfig)); + const instructions = await readOpencodeInstructions(config); + checks.push({ + name: 'Team instructions are listed in opencode instructions', + source: 'local', + check: async () => instructions !== null && instructions.includes(entry), + fix: instructions === null + ? `${config} could not be read as a JSON object, so the pull left it alone and OpenCode never loads ${opencodeFile}. ` + + `Fix the file or add "${entry}" to its "instructions" by hand, then run \`teamai pull --force\`.` + : `${config} does not list "${entry}" under "instructions", and OpenCode reads no file it is not told about. ${pullForce}`, + }); + } + + for (const hook of hooks) { + const length = instructionHookText(blocks, hook.recall).length; + const channel = await instructionHookChannel(hook.tool); + checks.push({ + name: `${hook.tool} adds the team instructions to its prompt`, + source: 'local', + check: async () => channel.ready && (hook.limit === undefined || length <= hook.limit), + fix: !channel.ready + ? channel.fix + : `This member's team instructions for the project are ${length} characters, over the ${hook.limit}-character ` + + `limit of a ${hook.tool} prompt section, so ${hook.tool} skips them. Shorten culture.md or the claudemd/ files for this scope.`, + }); + } + + const leftovers: string[] = []; + for (const file of stale) { + if ((await planInstructionFiles([], {}, [file])).changes.length > 0) leftovers.push(file.path); + } + checks.push({ + name: 'No team instruction blocks are left in files no tool loads them from', + source: 'local', + check: async () => leftovers.length === 0, + fix: `Earlier teamai releases left team instruction blocks in ${nameList(leftovers)}, which can carry another member's selection. ${pullForce}`, + }); + return checks; +} + +/** Whether the extension or plugin that adds a hook tool's team instructions is installed as this build writes it. */ +async function instructionHookChannel(tool: string): Promise<{ ready: boolean; fix: string }> { + const rerun = 'Run `teamai pull --force` to reinstall it; it also comes back after `teamai hooks` restores the built-in hooks.'; + if (tool === 'omp' || tool === 'pi') { + const { buildOmpExtensionSource, resolveOmpExtensionsDir, OMP_HOOK_FILE } = await import('./omp-hooks.js'); + const { buildPiExtensionSource, resolvePiExtensionsDir, PI_HOOK_FILE } = await import('./pi-hooks.js'); + const file = tool === 'omp' ? path.join(resolveOmpExtensionsDir(), OMP_HOOK_FILE) : path.join(resolvePiExtensionsDir(), PI_HOOK_FILE); + const expected = tool === 'omp' ? buildOmpExtensionSource() : buildPiExtensionSource(); + const ready = await readFileSafe(file) === expected; + return { ready, fix: `${file} is missing or out of date, so ${tool} sessions in this project get no team instructions. ${rerun}` }; + } + if (tool === 'hermes') { + const { buildInstructionsPlugin, getInstructionsPluginDir, HERMES_INSTRUCTIONS_PLUGIN } = await import('./hermes-hooks.js'); + const { getHermesConfigPath } = await import('./hermes-config.js'); + const dir = getInstructionsPluginDir(); + const plugin = buildInstructionsPlugin(); + const installed = await readFileSafe(path.join(dir, '__init__.py')) === plugin.init + && await readFileSafe(path.join(dir, 'plugin.yaml')) === plugin.manifest; + const config = await readFileSafe(getHermesConfigPath()) ?? ''; + const YAML = (await import('yaml')).default; + const enabled = (() => { + try { + const list = (YAML.parse(config) as { plugins?: { enabled?: unknown } } | null)?.plugins?.enabled; + return Array.isArray(list) && list.includes(HERMES_INSTRUCTIONS_PLUGIN); + } catch { + return false; + } + })(); + return { + ready: installed && enabled, + fix: !installed + ? `The Hermes plugin ${dir} is missing or out of date, so Hermes sessions in this project get no team instructions. ${rerun}` + : `${HERMES_INSTRUCTIONS_PLUGIN} is not in plugins.enabled of ${getHermesConfigPath()}, and Hermes loads no user plugin that is not listed there. ` + + 'Add it there (or remove it from plugins.disabled), then start a new Hermes session.', + }; + } + return { ready: true, fix: '' }; +} diff --git a/src/doctor.ts b/src/doctor.ts index 5892c955c..a75c24e0e 100644 --- a/src/doctor.ts +++ b/src/doctor.ts @@ -26,6 +26,7 @@ import { buildDeliveryChecks, buildRulesDeliveryChecks, buildAgentsDeliveryChecks, + buildInstructionDeliveryChecks, buildNamespaceNotes, buildMcpDeliveryChecks, buildMcpGitExcludeCheck, @@ -522,6 +523,7 @@ export async function buildChecks(ctx: DoctorContext, stage: CheckStage = 'docto // them, so skipping them post-pull is what keeps the budget for the rest. ...(stage === 'doctor' ? await buildRulesDeliveryChecks(ctx) : []), ...(stage === 'doctor' ? await buildAgentsDeliveryChecks(ctx) : []), + ...(stage === 'doctor' ? await buildInstructionDeliveryChecks(ctx) : []), ...await buildAgentModelChecks(ctx, stage), ...await buildMcpDeliveryChecks(ctx), ...await buildMcpGitExcludeCheck(ctx), From fbbf1caf695f88c7fcc1d00647537ee72c9fe3de Mon Sep 17 00:00:00 2001 From: Saul Moro Date: Thu, 1 Oct 2026 15:00:54 +0200 Subject: [PATCH 12/41] test(e2e): cover two roles with every tool, the task diff and content removal (#945) Three real-CLI tests for the closing criteria of #945: two members of one project with every file-based tool installed keep the shared AGENTS.md byte-identical across a role change and a claudemd edit; with the generated targets excluded by the fixture, a pull that updates the instructions leaves the task diff, the staged diff, the index and the exclude file unchanged; and disabling recall, deleting a claudemd source and leaving a namespace remove that content from files and from the extension's prompt text. Docs drop the remaining wording that named CLAUDE.md or AGENTS.md as the injection target, and the changelog asks teams to upgrade together. --- CHANGELOG.md | 1 + docs/product-overview.md | 2 +- docs/product-overview.zh-CN.md | 2 +- docs/usage-guide.md | 14 +-- docs/usage-guide.zh-CN.md | 14 +-- src/__tests__/e2e/instruction-targets.test.ts | 98 +++++++++++++++++++ 6 files changed, 115 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 32dd805fe..07e7c675e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ All notable changes to this project will be documented in this file. See [standa ### 💥 Breaking Changes +- **Upgrade every member together: team instructions leave the shared `AGENTS.md`.** `teamai pull` no longer writes the culture, `claudemd/` and recall blocks into the project's `AGENTS.md`, `~/AGENTS.md`, `~/.agents/AGENTS.md` or another tool's file, since members with different roles resolve different `claudemd/` selections and the shared file ended up holding whoever pulled last. Each installed tool gets them in its own file or session hook: Claude Code in `.claude/rules/teamai-context.md` (project) and `~/.claude/CLAUDE.md` (user); Cursor in `teamai-context.mdc` under `.cursor/rules` and `~/.cursor/rules`; CodeBuddy and WorkBuddy in one `.codebuddy/rules/teamai-context.md`, and WorkBuddy in `~/.workbuddy/rules/teamai-context.md`; OpenCode in `.opencode/teamai-context.md` and `~/.config/opencode/teamai-context.md`, listed in its `instructions`; Oh My Pi in `~/.omp/agent/RULES.md` and, in a project, through teamai's OMP extension; Pi through teamai's Pi extension in a project; Hermes in `$HERMES_HOME/SOUL.md` and, in a project, through a Hermes plugin teamai installs and enables. Copilot keeps `.github/copilot-instructions.md`. The first pull after updating removes the blocks earlier releases left in `AGENTS.md`, `~/AGENTS.md`, `.claude/CLAUDE.md`, `.codebuddy/CODEBUDDY.md`, `.omp/AGENTS.md` and `~/.omp/agent/AGENTS.md`, keeps everything else in those files, and names each file it changes; a block with a missing or repeated marker is left with a warning. A member still on an older release writes the blocks back into the shared files, so have everyone update. Pull writes only for installed tools, rewrites nothing that is current, removes the recall block when recall is disabled, and `--dry-run` lists the files it would change. Hermes skips project instructions over 4,000 characters, which pull and `teamai doctor` report. `teamai doctor` checks that each tool can load its instructions. teamai does not change `.gitignore`, `.git/info/exclude` or the index; a team that tracks a generated file such as `.github/copilot-instructions.md` still commits one member's selection (for [#945](https://github.com/Tencent/teamai-cli/issues/945)). - An env variable, hook or MCP server with a key its schema does not know, such as a misspelled `role:` or a hand-added `notes:`, is no longer delivered to anyone: the key used to be dropped silently, so a misspelled restriction shipped the entry to every member. `teamai pull`, the list commands, `teamai status` and `teamai doctor` name the file, the entry and the key. `teamai env add` also warns when it updates a variable carrying the unknown key; it, `teamai env remove` and `teamai remove mcp` keep the key when they rewrite the file. A key that a later version adds to these entries is unknown to this one too, so an entry that uses it is not delivered to a member still on this version: upgrade every member before the team uses a new entry key, as for a new `resources:` key (for [#822](https://github.com/Tencent/teamai-cli/issues/822)). - `manifest/projects.yaml` and `manifest/roles.yaml` now reject a resource namespace that is not a single path segment, as a project id already had to be (the id keeps its own narrower ASCII rule). A namespace becomes a directory component (`skills//`, `agents//`, `learnings//`), so `../evil`, `a/b`, `C:evil`, a bare `..`, any name with a trailing `.` or space — which Win32 strips, making `.. ` arrive as `..` and `frontend.` as `frontend` — and a Windows device name such as `CON` or `COM1` under `resources:` no longer parse; the error names the offending entry. Nothing else is rejected: a namespace that is a plain directory name still parses, non-ASCII names and names with a space included. A manifest that fails to parse now reports the offending entry on one line (`Invalid projects manifest: projects.0.resources.skills.1: ...`) instead of dumping a raw validation object. Two namespaces of one resource type that differ only by case (`frontend`, `Frontend`) are rejected too, within a manifest and between the two, since they name one directory on Windows and macOS. A manifest that ships any of these — a device name, a trailing `.`, a case-only pair — parsed before and fails every pull now; rename the directory and the entry together. - **Upgrade every member before a team declares a new axis.** `resources:` in `manifest/roles.yaml` and `manifest/projects.yaml` accepts `env`, `hooks`, `mcp`, `models` and `docs`, but teamai 0.25.0 and the 0.26.0 betas reject a `resources:` key they do not know, so a team that declares one breaks pull for every member still on those versions. From this version on, an unknown `resources:` key prints one warning naming the role or project and the key, and the scope syncs as if the key were absent; `teamai roles` and `teamai projects` keep the key when they save the manifest. `teamai roles|projects add/update --namespaces` never write the new keys, so nothing declares them until an admin does by hand (for [#707](https://github.com/Tencent/teamai-cli/issues/707)). diff --git a/docs/product-overview.md b/docs/product-overview.md index 66b2b2fef..619ab3978 100644 --- a/docs/product-overview.md +++ b/docs/product-overview.md @@ -59,7 +59,7 @@ Each resource is delivered to every agent: | **Rules** | `rules/*.md` | | | **Docs** | `docs/`, `docs//` | Foundational project docs; not all loaded by default (progressive disclosure). A `docs//` that no role or project declares stays shared | | **Agents** | `agents/.yaml`, `agents//.yaml` | | -| **Culture** | `culture.md` | Team mission, values, and working principles — injected into each agent's CLAUDE.md / AGENTS.md so every session inherits them | +| **Culture** | `culture.md` | Team mission, values, and working principles — delivered to each agent's own instruction file or session hook, never the project's shared AGENTS.md, so every session inherits them | | **CLAUDE.md** | `claudemd/*.md` | | | **Env** | `env/env.yaml`, `env//env.yaml` | Shared team-level environment variables and switches; do not put secret values here: declare a secret without its value in `env/secrets.yaml` | | **Hooks** | `hooks/hooks.yaml`, `hooks//hooks.yaml` | | diff --git a/docs/product-overview.zh-CN.md b/docs/product-overview.zh-CN.md index 38c39c93a..e29cb0fad 100644 --- a/docs/product-overview.zh-CN.md +++ b/docs/product-overview.zh-CN.md @@ -59,7 +59,7 @@ teamai push → 创建分支 + MR → reviewer 审批合并 | **Rules** | `rules/*.md` | | | **Docs** | `docs/`、`docs//` | 项目基础文档,默认不全量加载(渐进式披露)。没有任何角色或项目声明的 `docs//` 对所有人共享 | | **Agents** | `agents/.yaml`、`agents//.yaml` | | -| **Culture** | `culture.md` | 团队使命、价值观与协作准则——注入各 Agent 的 CLAUDE.md / AGENTS.md,成为每次会话的行事底色 | +| **Culture** | `culture.md` | 团队使命、价值观与协作准则——写入各 Agent 自己的指令文件或会话钩子(不写入项目共享的 AGENTS.md),成为每次会话的行事底色 | | **CLAUDE.md** | `claudemd/*.md` | | | **Env** | `env/env.yaml`、`env//env.yaml` | 通用环境变量、团队级开关;不要直接放密钥的值:密钥在 `env/secrets.yaml` 中只声明、不写值 | | **Hooks** | `hooks/hooks.yaml`、`hooks//hooks.yaml` | | diff --git a/docs/usage-guide.md b/docs/usage-guide.md index 49363b47c..ad902e461 100644 --- a/docs/usage-guide.md +++ b/docs/usage-guide.md @@ -1426,7 +1426,7 @@ teamai recall status # View the current effective status (team default + use Append `--dry-run` to `enable` or `disable` to preview the config and managed-artifact changes without writing them. -When disabled, `teamai pull` skips deploying the recall subagent, the recall rules injection block, and the TodoWrite reminder hook. Manually running `teamai recall ` to search is not affected by this switch. +When disabled, `teamai pull` skips deploying the recall subagent and the TodoWrite reminder hook, and removes the recall block from the team instructions. Manually running `teamai recall ` to search is not affected by this switch. ### Knowledge Base Maintenance @@ -1643,7 +1643,7 @@ teamai pull The injected content sits between the `` and `` markers, is automatically updated on every `pull`, and does not affect any other content in the file. -A pull writes the culture, shared-instructions and recall blocks only to the files of AI tools that are installed, and leaves a file alone when its blocks are already current. When no installed tool reads a file that an earlier pull wrote these blocks to (for example `~/AGENTS.md` after Hermes and WorkBuddy are gone), the next pull removes the teamai blocks from it and names the file in its output. It deletes the file when nothing else is left, unless git tracks it. A block with a missing or repeated marker is left as it is, with a warning to fix it by hand. `teamai pull --dry-run` lists the files a pull would change without writing them. When recall is disabled, the pull removes the recall block. +A pull writes the culture, shared-instructions and recall blocks only to the files of AI tools that are installed, and leaves a file alone when its blocks are already current. When no installed tool reads a file that an earlier pull wrote these blocks to (for example the project's `AGENTS.md`, which earlier releases wrote for Pi, Hermes and WorkBuddy), the next pull removes the teamai blocks from it and names the file in its output. It deletes the file when nothing else is left, unless git tracks it. A block with a missing or repeated marker is left as it is, with a warning to fix it by hand. `teamai pull --dry-run` lists the files a pull would change without writing them. When recall is disabled, the pull removes the recall block. #### Where the blocks go @@ -1684,7 +1684,7 @@ A file named like a teamai target that teamai did not write is left alone, and t ### Viewing the result -After pulling, you can view the AI tool's CLAUDE.md directly: +After pulling, you can view an AI tool's instruction file directly, for example Claude Code's user file: ```bash teamai pull @@ -2834,7 +2834,7 @@ teamai uninstall --agent claude What gets removed: - TeamAI-managed model settings are restored first when ownership is still intact - teamai hooks in AI tool settings -- The teamai blocks in CLAUDE.md and AGENTS.md (your own content is preserved) +- The teamai culture, shared-instructions and recall blocks 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) - 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 custom agents and CLI built-in agents (your own agents are preserved) @@ -2843,15 +2843,15 @@ What gets removed: ### Uninstall a single tool (`--agent `) -`--agent ` removes only that tool's teamai resources (hooks, CLAUDE.md block, skills, rules, team-synced custom agents, and built-in agents). The tool name is a key of `toolPaths` (e.g. `claude`, `codex`, `codebuddy`) and is matched case-insensitively. An unknown tool name aborts without deleting anything, lists the available tools, and exits with a non-zero status. +`--agent ` removes only that tool's teamai resources (hooks, team instruction blocks, skills, rules, team-synced custom agents, and built-in agents). The tool name is a key of `toolPaths` (e.g. `claude`, `codex`, `codebuddy`) and is matched case-insensitively. An unknown tool name aborts without deleting anything, lists the available tools, and exits with a non-zero status. An instructions file several tools map is cleaned per block: a teamai block stays while a remaining tool on that file still writes it. The project `AGENTS.md` is the common case: with Pi still enabled, `--agent workbuddy` removes the recall block and keeps the culture and shared-instructions blocks Pi writes. The Codex family writes nothing there. A file teamai created goes with its last block; an instructions file you had before stays, even an empty one. Shared resources (the env block, docs directory, and `~/.teamai/`) are removed **only when the target itself has teamai resources AND is the last tool still using teamai** — otherwise they are kept for the remaining tools. (So targeting a tool that has no teamai resources of its own is a no-op and leaves shared resources in place, even if it happens to be the only tool.) -The exclusion is durable: `uninstall --agent ` drops the tool from `enabledAgents` and records it in `disabledAgents`, so a later `pull` (or another tool's session-start hook) will not resurrect its skills, rules, agents, CLAUDE.md block, or hooks. Running `init --agent ` again clears the exclusion and re-enables sync for that tool. +The exclusion is durable: `uninstall --agent ` drops the tool from `enabledAgents` and records it in `disabledAgents`, so a later `pull` (or another tool's session-start hook) will not resurrect its skills, rules, agents, team instruction blocks, or hooks. Running `init --agent ` again clears the exclusion and re-enables sync for that tool. -The same `enabledAgents` whitelist (from `init --agent`) also gates CLI built-in skills/rules/agents and CLAUDE.md-class injects: an already-installed tool outside the list is neither written to nor deleted from, even if its root directory already exists. `teamai remove` respects the same whitelist for agents, rules, and skills, `teamai push` reads no rules or agents from a tool outside it, and `teamai pull` / `teamai mcp inject` respect it for MCP servers. Editing `enabledAgents` without `init` still invalidates the last-pull skip cache for newly added tools. +The same `enabledAgents` whitelist (from `init --agent`) also gates CLI built-in skills/rules/agents and team instruction blocks: an already-installed tool outside the list is neither written to nor deleted from, even if its root directory already exists. `teamai remove` respects the same whitelist for agents, rules, and skills, `teamai push` reads no rules or agents from a tool outside it, and `teamai pull` / `teamai mcp inject` respect it for MCP servers. Editing `enabledAgents` without `init` still invalidates the last-pull skip cache for newly added tools. To rejoin after uninstalling: diff --git a/docs/usage-guide.zh-CN.md b/docs/usage-guide.zh-CN.md index f4d2a187c..d8f9c56f1 100644 --- a/docs/usage-guide.zh-CN.md +++ b/docs/usage-guide.zh-CN.md @@ -1304,7 +1304,7 @@ teamai recall status # 查看当前生效状态(团队默认 + 用户覆 在 `enable` 或 `disable` 后添加 `--dry-run`,可预览配置和托管文件的变化,不会写入磁盘。 -关闭后,`teamai pull` 将跳过部署 recall subagent、recall rules 注入块和 TodoWrite 提醒 hook。手动执行 `teamai recall ` 搜索不受此开关影响。 +关闭后,`teamai pull` 将跳过部署 recall subagent 和 TodoWrite 提醒 hook,并从团队指令中移除 recall 块。手动执行 `teamai recall ` 搜索不受此开关影响。 ### 知识库维护 @@ -1521,7 +1521,7 @@ teamai pull 注入的内容位于 `` 和 `` 标记之间,每次 pull 时自动更新,不会影响文件中的其他内容。 -pull 只把团队文化、共享指令和 recall 块写入已安装 AI 工具的文件;块内容未变化时不改写文件。若之前某次 pull 写过这些块的文件已没有任何已安装工具读取(例如 Hermes 和 WorkBuddy 都已移除后的 `~/AGENTS.md`),下一次 pull 会移除其中的 teamai 块,并在输出中列出该文件。文件中没有其他内容时一并删除该文件,但 git 跟踪的文件不会被删除。标记缺失或重复的块保持原样,并提示手动修复。`teamai pull --dry-run` 只列出将要修改的文件,不写入。recall 关闭时,pull 会移除 recall 块。 +pull 只把团队文化、共享指令和 recall 块写入已安装 AI 工具的文件;块内容未变化时不改写文件。若之前某次 pull 写过这些块的文件已没有任何已安装工具读取(例如早期版本为 Pi、Hermes 和 WorkBuddy 写过的项目 `AGENTS.md`),下一次 pull 会移除其中的 teamai 块,并在输出中列出该文件。文件中没有其他内容时一并删除该文件,但 git 跟踪的文件不会被删除。标记缺失或重复的块保持原样,并提示手动修复。`teamai pull --dry-run` 只列出将要修改的文件,不写入。recall 关闭时,pull 会移除 recall 块。 #### 这些块写到哪里 @@ -1562,7 +1562,7 @@ Oh My Pi 把 `RULES.md` 作为始终应用的规则读取,与其唯一的用 ### 查看效果 -pull 后可以直接查看 AI 工具的 CLAUDE.md: +pull 后可以直接查看 AI 工具的指令文件,例如 Claude Code 的用户文件: ```bash teamai pull @@ -2645,7 +2645,7 @@ teamai uninstall --agent claude 移除内容: - 如果 ownership 仍有效,先恢复 TeamAI 管理的模型配置 - AI 工具 settings 中的 teamai hooks -- CLAUDE.md 和 AGENTS.md 中的 teamai 块(保留用户自写内容) +- 各工具指令文件中的 teamai 文化、共享指令和 recall 块,以及早期版本写过这些块的文件(保留用户自写内容;teamai 写入的 `teamai-context` 文件整体删除,OpenCode 中对应的 `instructions` 条目一并移除) - 团队同步的 skills,包括 OpenClaw workspace skills(保留用户自建 skills) - 团队同步的 rules,包括旧版本留在 `.codex/rules/` 中的副本,团队此后已删除的 rule 的副本也包括在内。清理使用记录的 `toolRoots` 位置和发布者本地的文件名。其中你改过的副本会保留,并在警告中点名。已删除 rule 的副本只有与记录的投递哈希一致时才会删除;没有该记录时也会保留并点名。Codex 的 `*.rules` 文件保留 - 团队同步的自定义 agents 和 CLI 内置 agents(保留用户自建 agents) @@ -2654,15 +2654,15 @@ teamai uninstall --agent claude ### 只卸载单个工具(`--agent `) -`--agent ` 只移除该工具的 teamai 资源(hooks、CLAUDE.md 块、skills、rules、团队同步的自定义 agents、内置 agents)。工具名即 `toolPaths` 的键(如 `claude`、`codex`、`codebuddy`),匹配大小写不敏感。传入未知工具名会直接报错并列出可用工具、不执行任何删除,并以非零状态码退出。 +`--agent ` 只移除该工具的 teamai 资源(hooks、团队指令块、skills、rules、团队同步的自定义 agents、内置 agents)。工具名即 `toolPaths` 的键(如 `claude`、`codex`、`codebuddy`),匹配大小写不敏感。传入未知工具名会直接报错并列出可用工具、不执行任何删除,并以非零状态码退出。 多个工具共同映射的指令文件按区块清理:只要该文件上仍有剩余工具会写入某个 teamai 区块,该区块就保留。最常见的是项目 `AGENTS.md`:Pi 仍启用时,`--agent workbuddy` 会移除 recall 区块,保留 Pi 写入的 culture 和共享指令区块。Codex 系工具不会在那里写入任何内容。teamai 创建的文件随最后一个区块一起删除;你原有的指令文件(即使是空文件)会保留。 跨工具共享资源(shell profile env 块、docs 目录、`~/.teamai/`)**仅当该工具自身存在 teamai 资源、且它是最后一个仍在使用 teamai 的工具时**才一并移除,否则会为其余工具保留。(因此,定向卸载一个自身没有任何 teamai 资源的工具是 no-op,即便它恰好是唯一的工具,也不会删除共享资源。) -该排除是持久的:`uninstall --agent ` 会把该工具从 `enabledAgents` 移除并记入 `disabledAgents`,因此之后的 `pull`(或其他工具的 session-start hook)不会再把它的 skills、rules、agents、CLAUDE.md 块或 hooks 重新装回。重新执行 `init --agent ` 会清除该排除、恢复对该工具的同步。 +该排除是持久的:`uninstall --agent ` 会把该工具从 `enabledAgents` 移除并记入 `disabledAgents`,因此之后的 `pull`(或其他工具的 session-start hook)不会再把它的 skills、rules、agents、团队指令块或 hooks 重新装回。重新执行 `init --agent ` 会清除该排除、恢复对该工具的同步。 -同一套 `enabledAgents` 白名单(来自 `init --agent`)也约束 CLI 内置 skills/rules/agents 以及 CLAUDE.md 类注入:即使工具根目录已经存在,白名单外的已安装工具也不会被写入或删除。`teamai remove` 对 agents、rules 和 skills 同样遵守该白名单,`teamai push` 也不会从白名单外的工具读取 rules 和 agents,`teamai pull` / `teamai mcp inject` 对 MCP servers 也遵守该白名单。不经过 `init` 直接把工具加进 `enabledAgents` 时,last-pull 跳过缓存会对新加入的工具失效。 +同一套 `enabledAgents` 白名单(来自 `init --agent`)也约束 CLI 内置 skills/rules/agents 以及团队指令块:即使工具根目录已经存在,白名单外的已安装工具也不会被写入或删除。`teamai remove` 对 agents、rules 和 skills 同样遵守该白名单,`teamai push` 也不会从白名单外的工具读取 rules 和 agents,`teamai pull` / `teamai mcp inject` 对 MCP servers 也遵守该白名单。不经过 `init` 直接把工具加进 `enabledAgents` 时,last-pull 跳过缓存会对新加入的工具失效。 卸载后如需重新加入: diff --git a/src/__tests__/e2e/instruction-targets.test.ts b/src/__tests__/e2e/instruction-targets.test.ts index 9de5c4a38..027115f38 100644 --- a/src/__tests__/e2e/instruction-targets.test.ts +++ b/src/__tests__/e2e/instruction-targets.test.ts @@ -188,6 +188,16 @@ function makeProjectMember( return { home, projectRoot }; } +/** + * Where a member's project config and team clone live once the first pull has + * moved them out of the project's `.teamai/` into the per-project data home. + */ +function memberData(member: ProjectMember): { config: string; teamRepo: string } { + const projects = path.join(member.home, '.teamai', 'projects'); + const [dir] = fs.readdirSync(projects); + return { config: path.join(projects, dir, 'config.yaml'), teamRepo: path.join(projects, dir, 'team-repo') }; +} + const pullAs = (member: ProjectMember, args: string[] = [], env: Record = {}): Promise => runCLI(['pull', '--force', ...args], { HOME: member.home, ...env }, member.projectRoot); @@ -604,4 +614,92 @@ describe('instruction block targets on real CLI pull (#945)', () => { expect(broken.get('No team instruction blocks are left in files no tool loads them from')).toMatchObject({ ok: false }); expect(broken.get('No team instruction blocks are left in files no tool loads them from')?.fix).toContain('AGENTS.md'); }); + + const ALL_FILE_TOOLS = ['.claude/skills', '.cursor/skills', '.codebuddy/skills', '.workbuddy/skills', '.opencode/skills', '.omp/skills', '.pi/skills']; + + it('keeps the shared AGENTS.md byte-identical for two roles with every tool installed, across role and content changes', async () => { + const sandbox = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'teamai-945-e2e-'))); + sandboxes.push(sandbox); + const fixture = makeTeamAndProject(sandbox); + const developer = makeProjectMember(sandbox, fixture, 'dev', 'developer', ALL_FILE_TOOLS); + const product = makeProjectMember(sandbox, fixture, 'pm', 'product', ALL_FILE_TOOLS); + const agentsMd = (m: ProjectMember) => fs.readFileSync(path.join(m.projectRoot, 'AGENTS.md'), 'utf8'); + const generated = (m: ProjectMember) => fs.readFileSync(path.join(m.projectRoot, '.claude', 'rules', 'teamai-context.md'), 'utf8'); + + for (const member of [developer, product]) { + const result = await pullAs(member); + expect(result.code, result.output).toBe(0); + expect(agentsMd(member)).toBe(PROJECT_AGENTS_MD); + } + expect(generated(developer)).not.toBe(generated(product)); + + // The developer changes role, and the team edits a claudemd file. + const { config, teamRepo } = memberData(developer); + fs.writeFileSync(config, fs.readFileSync(config, 'utf8').replace('primaryRole: developer', 'primaryRole: product')); + fs.writeFileSync(path.join(teamRepo, 'claudemd', 'common.md'), 'COMMON-SENTINEL, revised.\n'); + const again = await pullAs(developer); + expect(again.code, again.output).toBe(0); + expect(agentsMd(developer)).toBe(PROJECT_AGENTS_MD); + expect(generated(developer)).toContain('PRODUCT-SENTINEL'); + expect(generated(developer)).not.toContain('DEVELOPMENT-SENTINEL'); + expect(generated(developer)).toContain('revised'); + }); + + it('leaves an existing task diff and the staged diff unchanged when generated targets are excluded', async () => { + const sandbox = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'teamai-945-e2e-'))); + sandboxes.push(sandbox); + const member = makeProjectMember(sandbox, makeTeamAndProject(sandbox), 'dev', 'developer', ALL_FILE_TOOLS); + const root = member.projectRoot; + // The team's own choice, made by the test, never by teamai. + const exclude = path.join(root, '.git', 'info', 'exclude'); + fs.appendFileSync(exclude, ['.teamai/', '.teamai.bak/', '.claude/', '.cursor/', '.codebuddy/', '.workbuddy/', '.opencode/', '.omp/', '.pi/', ''].join('\n')); + const first = await pullAs(member); + expect(first.code, first.output).toBe(0); + + fs.writeFileSync(path.join(root, 'app.txt'), 'v2 staged\n'); + git(['add', 'app.txt'], root); + fs.writeFileSync(path.join(root, 'app.txt'), 'v3 unstaged\n'); + const snapshot = () => ({ + diff: execFileSync('git', ['diff'], { cwd: root, encoding: 'utf8' }), + staged: execFileSync('git', ['diff', '--cached'], { cwd: root, encoding: 'utf8' }), + status: execFileSync('git', ['status', '--porcelain'], { cwd: root, encoding: 'utf8' }), + index: fs.readFileSync(path.join(root, '.git', 'index')).toString('base64'), + exclude: fs.readFileSync(exclude, 'utf8'), + }); + const before = snapshot(); + + fs.writeFileSync(path.join(memberData(member).teamRepo, 'claudemd', 'common.md'), 'COMMON-SENTINEL, updated instructions.\n'); + const update = await pullAs(member); + expect(update.code, update.output).toBe(0); + + expect(fs.readFileSync(path.join(root, '.claude', 'rules', 'teamai-context.md'), 'utf8')).toContain('updated instructions'); + expect(snapshot()).toEqual(before); + }); + + it('removes the generated content when recall is disabled, a source is deleted, or a namespace is left', async () => { + const sandbox = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'teamai-945-e2e-'))); + sandboxes.push(sandbox); + const member = makeProjectMember(sandbox, makeTeamAndProject(sandbox), 'dev', 'developer', ['.claude/skills', '.omp/skills']); + fs.mkdirSync(path.join(member.home, '.omp'), { recursive: true }); + const context = () => fs.readFileSync(path.join(member.projectRoot, '.claude', 'rules', 'teamai-context.md'), 'utf8'); + expect((await pullAs(member)).code).toBe(0); + expect(context()).toContain(RECALL_START); + expect(await sessionInstructions('omp', member.home, member.projectRoot)).toContain('teamai-recall'); + + const disable = await runCLI(['recall', 'disable'], { HOME: member.home }, member.projectRoot); + expect(disable.code, disable.output).toBe(0); + expect(context()).not.toContain(RECALL_START); + expect(await sessionInstructions('omp', member.home, member.projectRoot)).not.toContain('teamai-recall'); + + const { config, teamRepo } = memberData(member); + fs.rmSync(path.join(teamRepo, 'claudemd', 'common.md')); + fs.writeFileSync(config, fs.readFileSync(config, 'utf8').replace('primaryRole: developer', 'primaryRole: product')); + expect((await pullAs(member)).code).toBe(0); + expect(context()).not.toContain('COMMON-SENTINEL'); + expect(context()).not.toContain('DEVELOPMENT-SENTINEL'); + const omp = await sessionInstructions('omp', member.home, member.projectRoot); + expect(omp).not.toContain('COMMON-SENTINEL'); + expect(omp).not.toContain('DEVELOPMENT-SENTINEL'); + expect(omp).toContain('PRODUCT-SENTINEL'); + }); }); From cba067b03c99478248695061210653709e48385d Mon Sep 17 00:00:00 2001 From: Saul Moro Date: Thu, 1 Oct 2026 15:01:04 +0200 Subject: [PATCH 13/41] docs(usage): name the Copilot limit of Claude's .claude/rules target (#945) --- docs/usage-guide.md | 2 ++ docs/usage-guide.zh-CN.md | 2 ++ 2 files changed, 4 insertions(+) diff --git a/docs/usage-guide.md b/docs/usage-guide.md index ad902e461..415ae6004 100644 --- a/docs/usage-guide.md +++ b/docs/usage-guide.md @@ -1659,6 +1659,8 @@ Two members of the same project can have different roles, so their shared instru | 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 | +Claude Code loads `.claude/rules/teamai-context.md` from the project root and any subdirectory, and still reads the project's `AGENTS.md` or authored `CLAUDE.md` the way it chose to. Copilot CLI 1.0.89 and later also reads a project's `.claude/rules`, so with both tools installed Copilot can get the blocks twice. + Cursor applies both `teamai-context.mdc` files in every session (`alwaysApply: true`). Cursor CLI reads `~/.cursor/rules` when the session starts under your home directory; the Cursor IDE was not checked. The CodeBuddy and WorkBuddy rule files are applied in every session (`alwaysApply: true`). Uninstalling one of the two keeps the shared project copy while the other is still installed. diff --git a/docs/usage-guide.zh-CN.md b/docs/usage-guide.zh-CN.md index d8f9c56f1..99c2771c0 100644 --- a/docs/usage-guide.zh-CN.md +++ b/docs/usage-guide.zh-CN.md @@ -1537,6 +1537,8 @@ pull 只把团队文化、共享指令和 recall 块写入已安装 AI 工具的 | Oh My Pi | `~/.omp/agent/RULES.md` | 由 teamai 的 OMP 扩展加入每轮的系统提示 | | Pi | `~/.pi/agent/AGENTS.md` | 由 teamai 的 Pi 扩展加入每次运行的系统提示 | +Claude Code 会从项目根目录和任意子目录加载 `.claude/rules/teamai-context.md`,并照常读取项目的 `AGENTS.md` 或项目自己编写的 `CLAUDE.md`。Copilot CLI 1.0.89 及更高版本也会读取项目的 `.claude/rules`,因此同时安装这两个工具时,Copilot 可能会读到两份。 + Cursor 在每个会话中应用这两个 `teamai-context.mdc` 文件(`alwaysApply: true`)。Cursor CLI 仅在会话从主目录下启动时读取 `~/.cursor/rules`;Cursor IDE 未经验证。 CodeBuddy 和 WorkBuddy 的规则文件在每个会话中应用(`alwaysApply: true`)。卸载其中一个工具时,只要另一个仍已安装,项目中共用的那份文件就会保留。 From bd9efaf0ce2d1cb81d063f2067e12eb06fb459e9 Mon Sep 17 00:00:00 2001 From: Saul Moro Date: Thu, 1 Oct 2026 15:10:22 +0200 Subject: [PATCH 14/41] fix(pull): deliver Codex's blocks from the same source as every tool (#945) Rebased onto #940, which already moves Codex's project content to its session hooks. The rebase kept this branch's side in conflicting hunks; this commit restores what that dropped of #940 (teamRulesHandler, the fast-path Codex rules sync, uninstall's per-block retention and keptRuleFiles) and joins the two designs: - teamRulesHandler takes Codex's culture, claudemd and recall from resolveInstructionBlocks, as pull and the Pi, OMP and Hermes extensions do, and no longer skips a block found in the project AGENTS.md: pull removes those, and the skip handed Codex another member's stale selection. - The Codex family is a hook target in project scope, with AGENTS.md retired for a team override or an earlier build. Hook text drops block markers for every tool, as #940 did for Codex. - Uninstall keeps #940's per-block retention and clears through the planner; the team-rules block is one of the blocks cleanup knows. - An emptied file goes only when it opens with a teamai block, as #940 decided, and git does not track it. AGENTS.md now assert that nothing does and that uninstall clears the blocks left there. --- docs/usage-guide.md | 6 ++- docs/usage-guide.zh-CN.md | 6 ++- src/__tests__/codex-hook-rules.test.ts | 8 ++-- src/__tests__/codex-instructions.test.ts | 8 ++-- src/__tests__/uninstall.test.ts | 24 +++++------ src/hook-handlers.ts | 37 +++++++++++++++++ src/instruction-targets.ts | 51 +++++++++++++++++++++--- src/pull.ts | 43 +------------------- src/recall-toggle.ts | 2 +- src/uninstall.ts | 14 ++++--- 10 files changed, 119 insertions(+), 80 deletions(-) diff --git a/docs/usage-guide.md b/docs/usage-guide.md index 415ae6004..41c4256b2 100644 --- a/docs/usage-guide.md +++ b/docs/usage-guide.md @@ -1378,6 +1378,7 @@ Recall counts every doc it returns (`recalled_count`). A returned doc is **adopt | Qoder | Yes | Yes (unverified) | | Copilot CLI | Yes | No: the subagent has a session of its own, and no hook links it to its parent | | Cursor | Yes | No: as for Copilot CLI | +| Codex, codex-internal, tcodex | `$CODEX_HOME/AGENTS.md` (and the variants' homes), beside the team rules | Added by the session-start and subagent-start hooks, beside the project's team rules; nothing on resume | | OpenCode | Yes | Yes: the `task` call links the subagent's session to its parent | | OMP | Yes, settled only by the claim of its `bash` call | Yes: the subagent's session file sits under its parent's, whose session header links the two sessions (verified against OMP 18.4.8) | | Pi | Yes | None: TeamAI deploys no subagent to Pi | @@ -1679,6 +1680,7 @@ A pull from an earlier release may have left these blocks in a file listed below - 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` +- Codex family: the project `AGENTS.md`, when a team's `toolPaths` or an earlier build pointed Codex there `teamai doctor` checks that each installed tool can load these blocks: that each file holds the current blocks, that OpenCode's config lists its file, that the Pi or Oh My Pi extension and the Hermes plugin are installed and enabled, that the Hermes section fits its limit, and that no file an earlier release wrote still holds blocks. @@ -2225,7 +2227,7 @@ These paths are verified against the ZCode desktop app: profiles created in its ### Oh My Pi -Oh My Pi (OMP) is available as a built-in target. TeamAI deploys skills, rules, and subagents to OMP's native directories — `.omp/skills/`, `.omp/rules/`, and `.omp/agents/` at project scope, and `~/.omp/agent/skills/`, `~/.omp/agent/rules/`, and `~/.omp/agent/agents/` at user scope (user-scope resources live under the agent directory `~/.omp/agent/`, a different prefix from the project one, so TeamAI switches prefixes with the scope). Team instructions go to `~/.omp/agent/RULES.md` in user scope and, in project scope, into each turn's system prompt through the extension below (see [Where the blocks go](#where-the-blocks-go)), and MCP servers merge into `~/.omp/agent/mcp.json` / `/.omp/mcp.json` (Claude `mcpServers` shape — see the MCP section above). Skills are one-level `/SKILL.md` bundles and TeamAI fills in a `description` on sync, which OMP's native skill provider requires to discover a skill. These paths follow OMP's documented discovery layout (verified against OMP 18.2.5). Hooks ride OMP's extension runner: `teamai pull` writes a single generated extension to `~/.omp/agent/extensions/teamai-hooks.ts` (never a project copy — OMP auto-loads both roots and would double-dispatch every event), which forwards OMP's `session_start` / `session_stop` / `before_agent_start` / `tool_result` events to the same `teamai hook-dispatch` entry point every other agent uses, gated on the session `cwd`. In a project session it also asks for the member's team instructions at `session_start` and appends them to the system prompt in `before_agent_start`. Every event carries the OMP session id (`ctx.sessionManager.getSessionId()`; a subagent has its own), and `tool_result` also the tool's text output and a status from `isError`, so upvote **adoption** runs for OMP's main agent: OMP sets no session variable in its shell, so a recall joins the session of the `bash` call that ran it, and a `read` with a line selector (`x.md:50-200`, `x.md:raw`) counts as a read of the file. From OMP 18.3.2 a subagent's events also carry its `ctx.agent` id and name, so the `teamai-recall` subagent's own reads never count. A doc the main agent opens after a subagent's recall is not upvoted yet: the subagent's session is not linked to its parent. 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. `teamai uninstall` removes the extension. 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. +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. `teamai uninstall` removes the extension. 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. ### DeepSeek Harness @@ -2836,7 +2838,7 @@ teamai uninstall --agent claude What gets removed: - TeamAI-managed model settings are restored first when ownership is still intact - teamai hooks in AI tool settings -- The teamai culture, shared-instructions and recall blocks 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) +- 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) - 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 custom agents and CLI built-in agents (your own agents are preserved) diff --git a/docs/usage-guide.zh-CN.md b/docs/usage-guide.zh-CN.md index 99c2771c0..b90c4ace6 100644 --- a/docs/usage-guide.zh-CN.md +++ b/docs/usage-guide.zh-CN.md @@ -1256,6 +1256,7 @@ recall 会为返回的每篇文档计数(`recalled_count`)。运行 recall | Qoder | 支持 | 支持(未验证) | | Copilot CLI | 支持 | 不支持:subagent 有自己的会话,且没有 hook 将其关联到父会话 | | Cursor | 支持 | 不支持:同 Copilot CLI | +| Codex、codex-internal、tcodex | `$CODEX_HOME/AGENTS.md`(以及各变体的主目录),位于团队规则旁 | 由 session-start 和 subagent-start hook 加入,位于项目团队规则旁;恢复会话时不重复加入 | | OpenCode | 支持 | 支持:`task` 调用将 subagent 的会话关联到父会话 | | OMP | 支持,仅通过其 `bash` 调用的认领确定归属 | 支持:subagent 的会话文件位于父会话文件之下,父会话文件的会话头把两个会话关联起来(对照 OMP 18.4.8 验证) | | Pi | 支持 | 不适用:TeamAI 不向 Pi 部署 subagent | @@ -1557,6 +1558,7 @@ Oh My Pi 把 `RULES.md` 作为始终应用的规则读取,与其唯一的用 - Hermes:`~/AGENTS.md` - Oh My Pi:`~/.omp/agent/AGENTS.md` 和 `.omp/AGENTS.md`。Oh My Pi 每一层只读取一个上下文文件,因此它们会遮蔽 `~/.agents/AGENTS.md` 和项目的 `AGENTS.md`。 - Pi:项目 `AGENTS.md` +- Codex 系列:项目 `AGENTS.md`(当团队的 `toolPaths` 或早期构建把 Codex 指向那里时) `teamai doctor` 会检查每个已安装工具能否加载这些块:每个文件是否包含当前的块,OpenCode 配置是否列出其文件,Pi 或 Oh My Pi 扩展和 Hermes 插件是否已安装并启用,Hermes 段落是否在限制内,以及早期版本写过的文件中是否仍残留块。 @@ -2088,7 +2090,7 @@ ZCode 已作为内置目标支持。Skills 下发到 `.zcode/skills/`(ZCode ### Oh My Pi -Oh My Pi(OMP)已作为内置目标支持。TeamAI 将 Skills、Rules 和 Subagents 下发到 OMP 的原生目录——项目级为 `.omp/skills/`、`.omp/rules/` 和 `.omp/agents/`,用户级为 `~/.omp/agent/skills/`、`~/.omp/agent/rules/` 和 `~/.omp/agent/agents/`(用户级资源位于 agent 目录 `~/.omp/agent/` 下,与项目级前缀不同,TeamAI 会随作用域自动切换)。团队指令在用户范围写入 `~/.omp/agent/RULES.md`,在项目范围由下文的 extension 加入每轮的系统提示(见[这些块写到哪里](#这些块写到哪里));MCP Server 合并进 `~/.omp/agent/mcp.json` / `/.omp/mcp.json`(Claude `mcpServers` 结构,见上文 MCP 章节)。Skills 采用一层 `/SKILL.md` 目录结构,TeamAI 在同步时补全 `description`——OMP 原生 skill 发现要求该字段。以上路径遵循 OMP 官方文档的发现布局(对照 OMP 18.2.5 验证)。Hooks 走 OMP 的 extension runner:`teamai pull` 会生成唯一的 extension 写入 `~/.omp/agent/extensions/teamai-hooks.ts`(绝不写项目副本——OMP 会同时加载两个根并导致每个事件双派发),它把 OMP 的 `session_start` / `session_stop` / `before_agent_start` / `tool_result` 事件转发给所有 agent 共用的 `teamai hook-dispatch` 入口,并按会话 `cwd` 做项目门控。在项目会话中,它还会在 `session_start` 时获取成员的团队指令,并在 `before_agent_start` 中追加到系统提示。每个事件都携带 OMP 会话 id(`ctx.sessionManager.getSessionId()`;subagent 有自己的会话),`tool_result` 还带上工具的文本输出和根据 `isError` 得出的状态,因此 upvote **采纳(adoption)**在 OMP 主 agent 上生效:OMP 不在其 shell 中设置会话变量,所以 recall 归入运行它的那次 `bash` 调用所在的会话;带行选择器的 `read`(`x.md:50-200`、`x.md:raw`)计为对该文件的读取。从 OMP 18.3.2 起,subagent 的事件还会携带其 `ctx.agent` 的 id 和名称,因此 `teamai-recall` subagent 自身的读取从不计入。主 agent 在 subagent recall 之后打开的文档暂不会被 upvote:subagent 的会话尚未关联到父会话。`session_stop` 处理器不返回任何值,分发绝不会强制会话继续;由于 OMP 的工具名是小写(`bash`、`read` 等)且没有 `Skill` / `TodoWrite` 工具,post-tool-use 不做 matcher 定向分发。`teamai uninstall` 会移除该 extension。OMP 的 profile(`OMP_PROFILE` / `PI_CODING_AGENT_DIR` / `PI_CONFIG_DIR`,会迁移 agent 目录)暂不支持,使用默认的 `~/.omp/agent/` 布局。 +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。OMP 的 profile(`OMP_PROFILE` / `PI_CODING_AGENT_DIR` / `PI_CONFIG_DIR`,会迁移 agent 目录)暂不支持,使用默认的 `~/.omp/agent/` 布局。 ### DeepSeek Harness @@ -2647,7 +2649,7 @@ teamai uninstall --agent claude 移除内容: - 如果 ownership 仍有效,先恢复 TeamAI 管理的模型配置 - AI 工具 settings 中的 teamai hooks -- 各工具指令文件中的 teamai 文化、共享指令和 recall 块,以及早期版本写过这些块的文件(保留用户自写内容;teamai 写入的 `teamai-context` 文件整体删除,OpenCode 中对应的 `instructions` 条目一并移除) +- 各工具指令文件中的 teamai 块(文化、共享指令、recall 以及 Codex 的团队规则),以及早期版本写过这些块的文件(保留用户自写内容;teamai 写入的 `teamai-context` 文件整体删除,OpenCode 中对应的 `instructions` 条目一并移除) - 团队同步的 skills,包括 OpenClaw workspace skills(保留用户自建 skills) - 团队同步的 rules,包括旧版本留在 `.codex/rules/` 中的副本,团队此后已删除的 rule 的副本也包括在内。清理使用记录的 `toolRoots` 位置和发布者本地的文件名。其中你改过的副本会保留,并在警告中点名。已删除 rule 的副本只有与记录的投递哈希一致时才会删除;没有该记录时也会保留并点名。Codex 的 `*.rules` 文件保留 - 团队同步的自定义 agents 和 CLI 内置 agents(保留用户自建 agents) diff --git a/src/__tests__/codex-hook-rules.test.ts b/src/__tests__/codex-hook-rules.test.ts index e1e0d53c8..0ea3e8cce 100644 --- a/src/__tests__/codex-hook-rules.test.ts +++ b/src/__tests__/codex-hook-rules.test.ts @@ -127,18 +127,20 @@ projects: expect(text).not.toContain('Billing instructions.'); }); - it('skips a block another tool already wrote into the project AGENTS.md, which Codex reads', async () => { + it('adds the member\'s own blocks even where an earlier release left another member\'s in the project AGENTS.md (#945)', async () => { await fse.outputFile(path.join(repoPath, 'culture.md'), '---\ncompany:\n name: Acme\n---\n\nBe kind to teammates.\n'); await fse.outputFile(path.join(repoPath, 'claudemd', 'shared.md'), 'Shared team instructions.\n'); await fse.outputFile( path.join(tmpDir, 'project', 'AGENTS.md'), - '# Notes\n\n\nBe kind to teammates.\n\n', + '# Notes\n\n\nAnother member\'s selection.\n\n', ); const text = (await context({ hook_event_name: 'SessionStart', source: 'startup' }))!; - expect(text).not.toContain('Be kind to teammates.'); + expect(text).toContain('Be kind to teammates.'); expect(text).toContain('Shared team instructions.'); + expect(text).not.toContain('Another member'); + expect(text).not.toContain('[teamai:'); }); it.each(['# Owners\n', ''])('adds blocks from a shadowed AGENTS.md when AGENTS.override.md contains %j', async (override) => { diff --git a/src/__tests__/codex-instructions.test.ts b/src/__tests__/codex-instructions.test.ts index d0b17c2b3..95de7f204 100644 --- a/src/__tests__/codex-instructions.test.ts +++ b/src/__tests__/codex-instructions.test.ts @@ -535,7 +535,7 @@ describe('a project-scope pull at an unchanged team revision after a CLI upgrade }); }); -describe('Codex leaves the project AGENTS.md to the tools that write it (#938, #945)', () => { +describe('Codex and the other tools leave the project AGENTS.md alone (#938, #945)', () => { const BLOCK_START: Record = { culture: TEAMAI_CULTURE_START, claudemd: TEAMAI_CLAUDEMD_START, @@ -574,13 +574,13 @@ describe('Codex leaves the project AGENTS.md to the tools that write it (#938, # }); it.each([ - ['pi', 'no agents', { skills: '.pi/skills', rules: '.pi/rules', claudemd: 'AGENTS.md' }, ['culture', 'claudemd']], + ['pi', 'no agents', { skills: '.pi/skills', rules: '.pi/rules', claudemd: 'AGENTS.md' }, []], ['workbuddy', 'agents', { skills: '.workbuddy/skills', rules: '.workbuddy/rules', settings: '.workbuddy/settings.json', claudemd: 'AGENTS.md', agents: '.workbuddy/agents', - }, ['culture', 'claudemd', 'recall']], + }, []], ['tcodex', 'the default entry', defaults.tcodex, []], - ])('Codex adds nothing to the AGENTS.md %s (%s) writes, and uninstall --agent codex leaves it', async (tool, _label, entry, written) => { + ])('neither Codex nor %s (%s) writes the project AGENTS.md, and uninstall --agent codex leaves it', async (tool, _label, entry, written: string[]) => { const teamConfig = TeamaiConfigSchema.parse({ team: 'test', repo: 'https://example.invalid/x/team.git', toolPaths: { codex: defaults.codex, [tool]: entry }, }); diff --git a/src/__tests__/uninstall.test.ts b/src/__tests__/uninstall.test.ts index cec2ead80..e24559a79 100644 --- a/src/__tests__/uninstall.test.ts +++ b/src/__tests__/uninstall.test.ts @@ -749,7 +749,7 @@ describe('uninstall', () => { expect(await fse.readFile(agentsMd, 'utf8')).toBe('# My notes\n'); }); - it('a project-scope uninstall leaves the project AGENTS.md to its owners with the default Codex entry', async () => { + it('a project-scope uninstall keeps the owners\' text of the project AGENTS.md and removes the teamai blocks left there (#945)', async () => { const defaults = TeamaiConfigSchema.parse({ team: 't', repo: 'owner/repo' }).toolPaths; const { projectRoot, agentsMd } = await projectFixture({ codex: defaults.codex }, ['codex']); await fse.ensureDir(path.join(projectRoot, '.codex')); @@ -758,7 +758,7 @@ describe('uninstall', () => { await uninstall({ force: true }); - expect(await fse.readFile(agentsMd, 'utf8')).toBe(owners); + expect(await fse.readFile(agentsMd, 'utf8')).toBe('# Project notes\n'); }); async function codexLegacyFixture() { @@ -862,7 +862,7 @@ describe('uninstall', () => { expect(await fse.pathExists(legacy('retired.md'))).toBe(false); }); - it('--agent codex with Pi still active removes the recall block and keeps Pi\'s', async () => { + it('--agent codex removes every teamai block from the project AGENTS.md, which Pi no longer reads (#945)', async () => { const { projectRoot, agentsMd } = await projectFixture({ codex: codexPaths, pi: piPaths }, ['codex', 'pi']); await fse.ensureDir(path.join(projectRoot, '.codex')); await fse.writeJson(path.join(projectRoot, '.codex', 'hooks.json'), {}); @@ -871,15 +871,11 @@ describe('uninstall', () => { await uninstall({ force: true, agent: 'codex' }); - const content = await fse.readFile(agentsMd, 'utf8'); - expect(content).toContain('# Project notes'); - expect(content).toContain(culture); - expect(content).toContain(shared); - // Pi has no `agents`, so it never receives the recall block. - expect(content).not.toContain(TEAMAI_RECALL_RULES_START); + // Pi gets its project blocks from its extension now, so nothing retains these. + expect(await fse.readFile(agentsMd, 'utf8')).toBe('# Project notes\n'); }); - it('--agent codex keeps the culture block while another Codex-family tool maps the same file', async () => { + it('--agent codex removes the culture block another Codex-family tool no longer reads from the project AGENTS.md (#945)', async () => { const { projectRoot, agentsMd } = await projectFixture( { codex: codexPaths, tcodex: { settings: '.tcodex/hooks.json', claudemd: 'AGENTS.md' } }, ['codex', 'tcodex'], @@ -892,10 +888,10 @@ describe('uninstall', () => { await uninstall({ force: true, agent: 'codex' }); - expect(await fse.readFile(agentsMd, 'utf8')).toContain(culture); + expect(await fse.pathExists(agentsMd)).toBe(false); }); - it('--agent workbuddy with Pi remaining removes WorkBuddy\'s recall block, which Pi never writes', async () => { + it('--agent workbuddy removes every teamai block from the project AGENTS.md, which Pi no longer reads (#945)', async () => { const { projectRoot, agentsMd } = await projectFixture({ pi: piPaths, workbuddy: workbuddyPaths }, ['pi', 'workbuddy']); await fse.ensureDir(path.join(projectRoot, '.pi', 'skills')); await fse.ensureDir(path.join(projectRoot, '.workbuddy', 'skills')); @@ -903,9 +899,7 @@ describe('uninstall', () => { await uninstall({ force: true, agent: 'workbuddy' }); - const content = await fse.readFile(agentsMd, 'utf8'); - expect(content).toContain(culture); - expect(content).not.toContain(TEAMAI_RECALL_RULES_START); + expect(await fse.pathExists(agentsMd)).toBe(false); }); }); diff --git a/src/hook-handlers.ts b/src/hook-handlers.ts index ea42b36f8..37029e935 100644 --- a/src/hook-handlers.ts +++ b/src/hook-handlers.ts @@ -715,6 +715,43 @@ const secretsHintHandler: HookHandler = { }, }; +/** + * SessionStart: a project's rules and instruction blocks (culture, shared + * instructions, recall) for a tool with no rules format and no project file of + * its own (the Codex family, #938, #945). The project AGENTS.md is the + * owners' file, and other tools read it too. User-scope content is in the + * tool's own AGENTS.md, so a session outside a project gets nothing here. + * Codex runs SessionStart again after a compaction or a clear; a resumed + * session already holds the content in its history. A subagent fires + * SubagentStart instead, which gets the same content. + */ +const teamRulesHandler: HookHandler = { + name: 'team-rules', + async execute(stdin, tool, config) { + if (!config || config.scope !== 'project' || stdin.source === 'resume') return null; + const { getsRulesFromSessionHook } = await import('./resources/rule-format.js'); + const { isAgentExcluded } = await import('./types.js'); + if (!getsRulesFromSessionHook(tool) || isAgentExcluded(config, tool)) return null; + const { loadTeamConfig } = await import('./config.js'); + const teamConfig = await loadTeamConfig(config.repo.localPath); + if (!teamConfig) return null; + const { instructionHookText } = await import('./instruction-targets.js'); + const { scopedToolPaths } = await import('./types.js'); + const { buildRolePullContext } = await import('./resources/desired.js'); + const { resolveInstructionBlocks } = await import('./pull.js'); + const { teamRulesContext } = await import('./resources/rules.js'); + const { blocks } = await resolveInstructionBlocks(teamConfig, config, await buildRolePullContext(config)); + const text = instructionHookText(blocks, Boolean(scopedToolPaths(teamConfig, config)[tool]?.agents)); + const parts = text ? [text] : []; + const rules = await teamRulesContext(teamConfig, config); + if (rules !== null) parts.push(rules); + if (parts.length === 0) return null; + // Codex rejects output whose hookEventName is not the event it ran. + const hookEventName = stdin.hook_event_name === 'SubagentStart' ? 'SubagentStart' : 'SessionStart'; + return JSON.stringify({ hookSpecificOutput: { hookEventName, additionalContext: parts.join('\n\n') } }); + }, +}; + /** * `instructions`: the culture, claudemd and recall blocks for a tool whose * extension adds them to the prompt instead of reading a file (#945). Resolved diff --git a/src/instruction-targets.ts b/src/instruction-targets.ts index 46fc1c59f..a70a153d9 100644 --- a/src/instruction-targets.ts +++ b/src/instruction-targets.ts @@ -18,6 +18,8 @@ import { TEAMAI_RECALL_RULES_START, TEAMAI_RULES_END, TEAMAI_RULES_START, + TEAMAI_TEAM_RULES_END, + TEAMAI_TEAM_RULES_START, type LocalConfig, type Scope, type TeamaiConfig, @@ -72,6 +74,9 @@ const cursor: TargetEntry = { file: contextRule('.mdc'), header: ALWAYS_APPLY, o * CodeBuddy and WorkBuddy both read the project's .codebuddy/rules, so they * share one copy there; uninstalling one keeps it while the other remains. */ +// A team override or an earlier build could have pointed Codex at the project AGENTS.md. +const codexHook: TargetEntry = { file: () => undefined, hook: true, retired: ['AGENTS.md'] }; + const codebuddyProjectRule = (): string => `.codebuddy/rules/${TEAMAI_CONTEXT_RULE_NAME}.md`; // One line per tool, so a change to one tool's target edits one line. @@ -118,6 +123,11 @@ const PROJECT_TARGETS: Readonly> = { 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: [] }, + // Codex reads no project file only it reads; its session-start and + // subagent-start hooks add the blocks (#938, #940). + codex: codexHook, + 'codex-internal': codexHook, + tcodex: codexHook, // Registered in .opencode/opencode.json `instructions`; the root opencode.json stays the project's. opencode: { file: () => '.opencode/teamai-context.md', owned: true, retired: [] }, }; @@ -129,9 +139,11 @@ const CLAUDEMD: MarkerPair = [TEAMAI_CLAUDEMD_START, TEAMAI_CLAUDEMD_END, 'claud const RECALL: MarkerPair = [TEAMAI_RECALL_RULES_START, TEAMAI_RECALL_RULES_END, 'recall']; /** The rules block releases before per-file rules wrote into the same files. */ const LEGACY_RULES: MarkerPair = [TEAMAI_RULES_START, TEAMAI_RULES_END, 'rules']; +/** The team rules a Codex-family tool reads from its own AGENTS.md in user scope. */ +const TEAM_RULES: MarkerPair = [TEAMAI_TEAM_RULES_START, TEAMAI_TEAM_RULES_END, 'team-rules']; /** Every teamai block a stale target can hold. */ -const STALE_BLOCKS: readonly MarkerPair[] = [CULTURE, CLAUDEMD, RECALL, LEGACY_RULES]; +const STALE_BLOCKS: readonly MarkerPair[] = [CULTURE, CLAUDEMD, RECALL, LEGACY_RULES, TEAM_RULES]; function entryFor(tool: string, scope: Scope): TargetEntry | undefined { return (scope === 'user' ? USER_TARGETS : PROJECT_TARGETS)[tool]; @@ -199,10 +211,21 @@ export function deliversInstructionsByHook(tool: string, scope: Scope): boolean */ export function instructionHookText(blocks: InstructionBlocks, recall: boolean): string { return [blocks.culture, blocks.claudemd, recall ? blocks.recall : null] - .filter((block): block is string => typeof block === 'string' && block !== '') + .filter((block): block is string => typeof block === 'string') + .map(managedBlockBody) + .filter((body) => body !== '') .join('\n\n'); } +/** A block's text without its markers and DO NOT EDIT line, which only a file needs. */ +function managedBlockBody(block: string): string { + return block + .split('\n') + .filter((line) => !/^$/.test(line.trim())) + .join('\n') + .trim(); +} + /** Absolute instruction file of `tool` in the active scope, or undefined when it takes none. */ export function instructionTargetPath( tool: string, @@ -331,6 +354,15 @@ function withoutHeader(content: string, header: string | undefined): string { return header && content.startsWith(header) ? content.substring(header.length) : content; } +/** + * Whether teamai created the file: it writes a new file starting with a block + * (an older release with blank lines first), while a member's file starts + * with their own text. + */ +function createdByTeamai(content: string): boolean { + return content.trimStart().startsWith('$/.test(line.trim())) - .join('\n') - .trim(); -} - -/** - * The culture, shared-instruction and recall blocks a tool whose project - * instructions come from its session-start hook (the Codex family) gets in a - * project, resolved as pull resolves them. In user scope they are in the - * tool's own instructions file instead (#945). A block another tool already - * wrote into the active project instructions file is skipped, since the tool - * reads it too. AGENTS.override.md takes precedence over AGENTS.md. - */ -export async function sessionInstructionBlocks( - teamConfig: TeamaiConfig, - localConfig: LocalConfig, - tool: string, -): Promise { - const projectAgents = localConfig.projectRoot - ? await readFileSafe(path.join(localConfig.projectRoot, 'AGENTS.override.md')) - ?? await readFileSafe(path.join(localConfig.projectRoot, 'AGENTS.md')) ?? '' - : ''; - const blocks: Array<[string, string | null]> = []; - const culture = await readFileSafe(path.join(localConfig.repo.localPath, 'culture.md')); - blocks.push([TEAMAI_CULTURE_START, culture === null ? null : compileCulture(culture)]); - const { contents } = await collectClaudemdFiles(localConfig.repo.localPath, await buildRolePullContext(localConfig)); - blocks.push([TEAMAI_CLAUDEMD_START, compileClaudemd(contents)]); - const toolPath = scopedToolPaths(teamConfig, localConfig)[tool]; - if (toolPath?.agents && isRecallEnabled(localConfig, teamConfig)) { - blocks.push([TEAMAI_RECALL_RULES_START, compileRecallRulesBlock()]); - } - return blocks - .filter((entry): entry is [string, string] => entry[1] !== null && !projectAgents.includes(entry[0])) - .map(([, block]) => managedBlockBody(block)) - .filter((body) => body !== ''); -} - /** * Auto-migrate hooks from old individual format to unified hook-dispatch format. * Runs at session start: if settings.json doesn't contain 'hook-dispatch' commands, diff --git a/src/recall-toggle.ts b/src/recall-toggle.ts index 70f15ac66..dc96f7378 100644 --- a/src/recall-toggle.ts +++ b/src/recall-toggle.ts @@ -8,7 +8,7 @@ import { agentFileExtensionForTool, type ToolName, } from './resources/agent-format.js'; -import { ruleFileExtensionForTool, writesInstructionBlock } from './resources/rule-format.js'; +import { ruleFileExtensionForTool } from './resources/rule-format.js'; import { LEGACY_RECALL_SKILL_NAMES, builtinSkillsTarget, pruneLegacyBuiltinSkills } from './builtin-skills.js'; import { resolveToolBaseDir, diff --git a/src/uninstall.ts b/src/uninstall.ts index 51f6bc46e..a799ddfef 100644 --- a/src/uninstall.ts +++ b/src/uninstall.ts @@ -41,7 +41,6 @@ import { listTeamAgentDirs } from './resources/agents.js'; import { RulesHandler } from './resources/rules.js'; import { deliveredHashes } from './pull.js'; import { isToolInstalledForConfig } from './resources/base.js'; -import { removeClaudeMdSection } from './utils/claudemd.js'; import { BUILTIN_AGENT_NAMES } from './builtin-agents.js'; import { BUILTIN_SKILL_NAMES, @@ -322,7 +321,7 @@ async function discoverToolResources( ): Promise { const res: ToolResources = { hookFiles: [], openclawHookDirs: [], opencodeHookScopes: [], ompHookFile: null, piHookFiles: [], dshHookFile: null, - claudeMdFiles: [], retiredInstructionFiles: [], skillDirs: [], ruleFiles: [], agentFiles: [], + claudeMdFiles: [], retiredInstructionFiles: [], skillDirs: [], ruleFiles: [], keptRuleFiles: [], agentFiles: [], }; // (a) Hooks — settings.json / hooks.json @@ -724,7 +723,10 @@ async function buildRemovalPlan( } // No tool reads a retired file any more, so nothing retains its blocks. for (const file of res.retiredInstructionFiles) { - if (!plan.claudeMdFiles.includes(file)) plan.claudeMdFiles.push(file); + if (plan.claudeMdFiles.some((entry) => entry.path === file)) continue; + const content = await readFileSafe(file) ?? ''; + const blocks = CLAUDEMD_MARKER_PAIRS.filter(([start]) => content.includes(start)); + if (blocks.length > 0) plan.claudeMdFiles.push({ path: file, blocks }); } plan.skillDirs.push(...res.skillDirs); plan.ruleFiles.push(...res.ruleFiles); @@ -1063,9 +1065,11 @@ async function executeRemoval(plan: RemovalPlan): Promise { // (b) Clean CLAUDE.md teamai section blocks for (const { path: claudeMdPath, blocks } of plan.claudeMdFiles) { try { - const { changed, warnings } = await clearInstructionFile(claudeMdPath); + // A file teamai created goes with its last block; a member's file, + // even an empty one, stays. + const { changed, warnings } = await clearInstructionFile(claudeMdPath, blocks.map(([start]) => start)); for (const warning of warnings) log.warn(warning); - if (changed) log.success(`Cleaned CLAUDE.md: ${claudeMdPath}`); + if (changed) log.success(`Cleaned ${claudeMdPath}`); // OpenCode loads its file through an `instructions` entry; drop it with the file. if (OPENCODE_CONTEXT_FILES.some((suffix) => claudeMdPath.endsWith(suffix)) && !await pathExists(claudeMdPath)) { const { opencodeContextReference, reconcileOpencodeInstructions } = await import('./resources/opencode-config.js'); From 1859da4e2125b0f7c88e956b239f2df8c7004973 Mon Sep 17 00:00:00 2001 From: Saul Moro Date: Thu, 1 Oct 2026 15:14:36 +0200 Subject: [PATCH 15/41] fix(doctor): ask for OpenCode's instructions entry only once its file exists (#945) pull registers .opencode/teamai-context.md (or the user file) in instructions only after writing it, so a team with no culture or claudemd/ has no file and no entry. doctor failed that case. --- src/doctor-delivery.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/doctor-delivery.ts b/src/doctor-delivery.ts index 2906db909..bfe158e61 100644 --- a/src/doctor-delivery.ts +++ b/src/doctor-delivery.ts @@ -1202,7 +1202,8 @@ export async function buildInstructionDeliveryChecks(ctx: DoctorContext): Promis const opencodePaths = scopedToolPaths(teamConfig, localConfig).opencode; const opencodeFile = opencodePaths && instructionTargetPath('opencode', opencodePaths, localConfig); - if (opencodeFile && !claudeFallback && targets.some((t) => t.path === opencodeFile)) { + // Only a file that exists needs listing; pull registers it once it writes one. + if (opencodeFile && !claudeFallback && targets.some((t) => t.path === opencodeFile) && await pathExists(opencodeFile)) { const { config, entry } = opencodeContextReference(opencodeFile, localConfig.scope, resolveToolBaseDir('opencode', localConfig)); const instructions = await readOpencodeInstructions(config); checks.push({ From 15f2763a1d60dc3e641341555be0d493f529247c Mon Sep 17 00:00:00 2001 From: Saul Moro Date: Thu, 1 Oct 2026 15:31:52 +0200 Subject: [PATCH 16/41] fix(pull): address review of #945: retired files only, one OpenCode path, channel reports Standards and spec review, round 1: - Cleanup touches only the files earlier releases wrote blocks to, never a tool's current target: a member without Copilot no longer strips a tracked .github/copilot-instructions.md a teammate's pull wrote. - The OpenCode Claude fallback and the instructions registration live in instruction-targets.ts, so pull, recall enable, local-agent and doctor agree; a dry run reports the opencode.json change. - pull (after installing hooks) and init name a Pi or OMP extension or Hermes plugin that is missing, out of date or disabled, and Hermes text over its limit; "Synced" is printed for file targets only. - The Codex team-rules writer stays out of a project file when Codex's session hook carries the rules, even with a team claudemd override. - Uninstall decides which blocks a remaining tool keeps from its target, not from toolPath.claudemd. - One helper resolves hook text for both handlers; doctor and pull share the channel and limit checks and one Hermes plugins.enabled reader. - Docs: the destination table lists every tool and marks the channels no live session has checked; the misplaced rows leave the recall table; the uninstall text in both guides and skill-data describes the shared CodeBuddy/WorkBuddy file; the stale Codex dedupe sentence goes. --- docs/usage-guide.md | 24 +-- docs/usage-guide.zh-CN.md | 24 +-- skill-data/setup/references/uninstall.md | 12 +- src/__tests__/codex-instructions.test.ts | 15 ++ src/__tests__/e2e/instruction-targets.test.ts | 17 +++ src/__tests__/recall-toggle.test.ts | 4 +- src/doctor-delivery.ts | 92 ++---------- src/hermes-config.ts | 6 + src/hook-handlers.ts | 19 +-- src/init.ts | 7 + src/instruction-targets.ts | 141 ++++++++++++++++-- src/local-agent.ts | 38 ++--- src/pull.ts | 73 +++------ src/recall-toggle.ts | 6 +- src/resources/opencode-config.ts | 20 ++- src/resources/rule-format.ts | 8 +- src/resources/rules.ts | 4 + src/uninstall.ts | 14 +- 18 files changed, 306 insertions(+), 218 deletions(-) diff --git a/docs/usage-guide.md b/docs/usage-guide.md index 41c4256b2..37e91d43d 100644 --- a/docs/usage-guide.md +++ b/docs/usage-guide.md @@ -962,7 +962,7 @@ teamai push 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 a new or changed hook only after you approve it in `/hooks`, and until then it gets no 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. The hook leaves out a block already present in the active project instructions file. Codex reads `AGENTS.override.md` when it exists, otherwise `AGENTS.md`, so a block in a shadowed `AGENTS.md` still reaches Codex through the hook. +The culture, shared-instructions and recall blocks follow the same split. In user scope they go to that same `AGENTS.md`, and your own content outside the markers is kept. In a project the session-start hook adds them with the rules, and `pull` leaves the project `AGENTS.md` unchanged. > A `toolPaths` in the team `teamai.yaml` replaces the built-in defaults whole. A team that sets it should give each Codex-family entry `userScope.claudemd: .codex/AGENTS.md` (`.codex-internal/…`, `.tcodex/…`) for the user-scope rules and blocks, and drop its `rules` path, since Codex never reads that directory. A top-level `claudemd` would put the blocks back in the project `AGENTS.md`, so leave it out. In a project the hook needs only the entry's `settings` path, where it is installed. @@ -1378,11 +1378,9 @@ Recall counts every doc it returns (`recalled_count`). A returned doc is **adopt | Qoder | Yes | Yes (unverified) | | Copilot CLI | Yes | No: the subagent has a session of its own, and no hook links it to its parent | | Cursor | Yes | No: as for Copilot CLI | -| Codex, codex-internal, tcodex | `$CODEX_HOME/AGENTS.md` (and the variants' homes), beside the team rules | Added by the session-start and subagent-start hooks, beside the project's team rules; nothing on resume | | OpenCode | Yes | Yes: the `task` call links the subagent's session to its parent | | OMP | Yes, settled only by the claim of its `bash` call | Yes: the subagent's session file sits under its parent's, whose session header links the two sessions (verified against OMP 18.4.8) | | Pi | Yes | None: TeamAI deploys no subagent to Pi | -| 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` | | ZCode | Yes | No: ZCode runs no hooks inside a subagent | | OpenClaw, Hermes, Kiro, JoyCode | No: no PostToolUse hook | No | @@ -1653,12 +1651,18 @@ Two members of the same project can have different roles, so their shared instru | Tool | User scope | Project scope | |---|---|---| | Claude Code | `~/.claude/CLAUDE.md` | `.claude/rules/teamai-context.md` | -| Cursor | `~/.cursor/rules/teamai-context.mdc` | `.cursor/rules/teamai-context.mdc` | -| CodeBuddy | `~/.codebuddy/CODEBUDDY.md` | `.codebuddy/rules/teamai-context.md`, one copy shared with WorkBuddy | -| WorkBuddy | `~/.workbuddy/rules/teamai-context.md` | `.codebuddy/rules/teamai-context.md`, one copy shared with CodeBuddy | -| Hermes | A block in `$HERMES_HOME/SOUL.md`, beside the team rules block | A system prompt section from teamai's Hermes plugin | +| claude-internal, tclaude | `.claude-internal/CLAUDE.md`, `.tclaude/CLAUDE.md` in their homes (unchanged) | The same paths under the project (unchanged, unverified) | +| Codex, codex-internal, tcodex | `$CODEX_HOME/AGENTS.md` (and the variants' homes), beside the team rules | Added by the session-start and subagent-start hooks, beside the project's team rules; nothing on resume | +| Copilot CLI | `$COPILOT_HOME/copilot-instructions.md` (unchanged) | `.github/copilot-instructions.md` (unchanged) | +| Cursor | `~/.cursor/rules/teamai-context.mdc` (unverified) | `.cursor/rules/teamai-context.mdc` (unverified) | +| CodeBuddy | `~/.codebuddy/CODEBUDDY.md` | `.codebuddy/rules/teamai-context.md`, one copy shared with WorkBuddy (unverified) | +| WorkBuddy | `~/.workbuddy/rules/teamai-context.md` (unverified) | `.codebuddy/rules/teamai-context.md`, one copy shared with CodeBuddy (unverified) | +| 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 | +| Pi | `~/.pi/agent/AGENTS.md` | Added to each run's system prompt by teamai's Pi extension (unverified) | +| Hermes | A block in `$HERMES_HOME/SOUL.md`, beside the team rules block (unverified) | A system prompt section from teamai's Hermes plugin (unverified) | + +*Unverified*: built from the tool's documented or source-read loader, not yet checked in a live session. Claude Code, Oh My Pi and OpenCode were checked in live sessions, from the project root and a subdirectory. The recall block goes only to tools with the `teamai-recall` subagent, so Pi and Hermes get culture and shared instructions without it. Claude Code loads `.claude/rules/teamai-context.md` from the project root and any subdirectory, and still reads the project's `AGENTS.md` or authored `CLAUDE.md` the way it chose to. Copilot CLI 1.0.89 and later also reads a project's `.claude/rules`, so with both tools installed Copilot can get the blocks twice. @@ -1666,7 +1670,7 @@ Cursor applies both `teamai-context.mdc` files in every session (`alwaysApply: t The CodeBuddy and WorkBuddy rule files are applied in every session (`alwaysApply: true`). Uninstalling one of the two keeps the shared project copy while the other is still installed. -In a project, teamai installs the Hermes plugin `$HERMES_HOME/plugins/teamai-instructions/` and adds it to `plugins.enabled` in `$HERMES_HOME/config.yaml` (a name you list under `plugins.disabled` stays off). Hermes builds the section once for each new session from the session's directory and keeps it through compression and resume. A section holds at most 4,000 characters, and all plugin sections together at most 8,000. When this member's instructions for the project are longer, Hermes skips them and `teamai pull` says so: teamai does not cut them or write them to `AGENTS.md`. Outside a project the section is empty, and Hermes may log that it skipped an empty section. +In a project, teamai installs the Hermes plugin `$HERMES_HOME/plugins/teamai-instructions/` and adds it to `plugins.enabled` in `$HERMES_HOME/config.yaml` (a name you list under `plugins.disabled` stays off). According to Hermes' documentation it builds the section once for each new session from the session's directory and keeps it through compression and resume. A section holds at most 4,000 characters, and all plugin sections together at most 8,000. When this member's instructions for the project are longer, Hermes skips them and `teamai pull` says so: teamai does not cut them or write them to `AGENTS.md`. Outside a project the section is empty, and Hermes may log that it skipped an empty section. OpenCode loads a file only when its config lists it in `instructions`, so teamai adds that one entry and keeps your other entries and keys; the root `opencode.json` and OpenCode's own `AGENTS.md` files are left alone. While `~/.config/opencode/AGENTS.md` does not exist, OpenCode reads `~/.claude/CLAUDE.md` instead; when Claude Code gets the user blocks there, OpenCode already has them, so teamai writes no second user copy for OpenCode and says so in the pull output. A config file teamai cannot parse as JSON (for example one with comments) is left unchanged with a warning; add the entry by hand. @@ -2849,7 +2853,7 @@ What gets removed: `--agent ` removes only that tool's teamai resources (hooks, team instruction blocks, skills, rules, team-synced custom agents, and built-in agents). The tool name is a key of `toolPaths` (e.g. `claude`, `codex`, `codebuddy`) and is matched case-insensitively. An unknown tool name aborts without deleting anything, lists the available tools, and exits with a non-zero status. -An instructions file several tools map is cleaned per block: a teamai block stays while a remaining tool on that file still writes it. The project `AGENTS.md` is the common case: with Pi still enabled, `--agent workbuddy` removes the recall block and keeps the culture and shared-instructions blocks Pi writes. The Codex family writes nothing there. A file teamai created goes with its last block; an instructions file you had before stays, even an empty one. +An instructions file several tools map is cleaned per block: a teamai block stays while a remaining tool on that file still writes it. The common case is `.codebuddy/rules/teamai-context.md`, which CodeBuddy and WorkBuddy share: `--agent workbuddy` keeps it while CodeBuddy is installed. A file an earlier release wrote the blocks to, such as the project `AGENTS.md`, is read by no tool now, so its teamai blocks go and your own text stays. A file teamai created goes with its last block; an instructions file you had before stays, even an empty one. Shared resources (the env block, docs directory, and `~/.teamai/`) are removed **only when the target itself has teamai resources AND is the last tool still using teamai** — otherwise they are kept for the remaining tools. (So targeting a tool that has no teamai resources of its own is a no-op and leaves shared resources in place, even if it happens to be the only tool.) diff --git a/docs/usage-guide.zh-CN.md b/docs/usage-guide.zh-CN.md index b90c4ace6..326d74bca 100644 --- a/docs/usage-guide.zh-CN.md +++ b/docs/usage-guide.zh-CN.md @@ -883,7 +883,7 @@ teamai push 大多数工具在自己的 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 只在你于 `/hooks` 中批准新增或改动的 hook 后才运行它,在此之前不会得到项目的 rule。 -culture、共享指令和 recall 区块采用同样的划分。user scope 下它们写入同一个 `AGENTS.md`,标记之外你自己的内容保持不变。在项目中,session-start hook 把它们与 rule 一起加入会话,`pull` 不改动项目 `AGENTS.md`。hook 会省略项目当前生效的指令文件中已有的区块。Codex 在 `AGENTS.override.md` 存在时读取它,否则读取 `AGENTS.md`,因此被遮蔽的 `AGENTS.md` 中的区块仍会通过 hook 送达。 +culture、共享指令和 recall 区块采用同样的划分。user scope 下它们写入同一个 `AGENTS.md`,标记之外你自己的内容保持不变。在项目中,session-start hook 把它们与 rule 一起加入会话,`pull` 不改动项目 `AGENTS.md`。 > 团队 `teamai.yaml` 中的 `toolPaths` 会整体替换内置默认值。设置了它的团队应为每个 Codex 系条目加上 `userScope.claudemd: .codex/AGENTS.md`(`.codex-internal/…`、`.tcodex/…`),用于 user scope 的 rule 和区块,并去掉其 `rules` 路径,因为 Codex 从不读取该目录。顶层的 `claudemd` 会把区块重新写进项目 `AGENTS.md`,所以不要设置。在项目中,hook 只需要该条目的 `settings` 路径,它安装在那里。 @@ -1256,11 +1256,9 @@ recall 会为返回的每篇文档计数(`recalled_count`)。运行 recall | Qoder | 支持 | 支持(未验证) | | Copilot CLI | 支持 | 不支持:subagent 有自己的会话,且没有 hook 将其关联到父会话 | | Cursor | 支持 | 不支持:同 Copilot CLI | -| Codex、codex-internal、tcodex | `$CODEX_HOME/AGENTS.md`(以及各变体的主目录),位于团队规则旁 | 由 session-start 和 subagent-start hook 加入,位于项目团队规则旁;恢复会话时不重复加入 | | OpenCode | 支持 | 支持:`task` 调用将 subagent 的会话关联到父会话 | | OMP | 支持,仅通过其 `bash` 调用的认领确定归属 | 支持:subagent 的会话文件位于父会话文件之下,父会话文件的会话头把两个会话关联起来(对照 OMP 18.4.8 验证) | | Pi | 支持 | 不适用:TeamAI 不向 Pi 部署 subagent | -| OpenCode | `~/.config/opencode/teamai-context.md`,以绝对路径列在 `~/.config/opencode/opencode.json` 的 `instructions` 中 | `.opencode/teamai-context.md`,列在 `.opencode/opencode.json` 的 `instructions` 中 | | ZCode | 支持 | 不支持:ZCode 在 subagent 内不运行 hook | | OpenClaw、Hermes、Kiro、JoyCode | 不支持:没有 PostToolUse hook | 不支持 | @@ -1531,12 +1529,18 @@ pull 只把团队文化、共享指令和 recall 块写入已安装 AI 工具的 | 工具 | 用户范围 | 项目范围 | |---|---|---| | Claude Code | `~/.claude/CLAUDE.md` | `.claude/rules/teamai-context.md` | -| Cursor | `~/.cursor/rules/teamai-context.mdc` | `.cursor/rules/teamai-context.mdc` | -| CodeBuddy | `~/.codebuddy/CODEBUDDY.md` | `.codebuddy/rules/teamai-context.md`,与 WorkBuddy 共用一份 | -| WorkBuddy | `~/.workbuddy/rules/teamai-context.md` | `.codebuddy/rules/teamai-context.md`,与 CodeBuddy 共用一份 | -| Hermes | `$HERMES_HOME/SOUL.md` 中的一个块,位于团队规则块旁 | teamai 的 Hermes 插件提供的系统提示段落 | +| claude-internal、tclaude | 各自主目录下的 `.claude-internal/CLAUDE.md`、`.tclaude/CLAUDE.md`(未改变) | 项目下的相同路径(未改变,未验证) | +| Codex、codex-internal、tcodex | `$CODEX_HOME/AGENTS.md`(以及各变体的主目录),位于团队规则旁 | 由 session-start 和 subagent-start hook 加入,位于项目团队规则旁;恢复会话时不重复加入 | +| Copilot CLI | `$COPILOT_HOME/copilot-instructions.md`(未改变) | `.github/copilot-instructions.md`(未改变) | +| Cursor | `~/.cursor/rules/teamai-context.mdc`(未验证) | `.cursor/rules/teamai-context.mdc`(未验证) | +| CodeBuddy | `~/.codebuddy/CODEBUDDY.md` | `.codebuddy/rules/teamai-context.md`,与 WorkBuddy 共用一份(未验证) | +| WorkBuddy | `~/.workbuddy/rules/teamai-context.md`(未验证) | `.codebuddy/rules/teamai-context.md`,与 CodeBuddy 共用一份(未验证) | +| 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 扩展加入每次运行的系统提示 | +| Pi | `~/.pi/agent/AGENTS.md` | 由 teamai 的 Pi 扩展加入每次运行的系统提示(未验证) | +| Hermes | `$HERMES_HOME/SOUL.md` 中的一个块,位于团队规则块旁(未验证) | teamai 的 Hermes 插件提供的系统提示段落(未验证) | + +*未验证*:依据工具的文档或源码中的加载逻辑实现,尚未在实际会话中检查。Claude Code、Oh My Pi 和 OpenCode 已在实际会话中从项目根目录和子目录检查过。recall 块只发给有 `teamai-recall` subagent 的工具,因此 Pi 和 Hermes 获得文化和共享指令,但没有 recall 块。 Claude Code 会从项目根目录和任意子目录加载 `.claude/rules/teamai-context.md`,并照常读取项目的 `AGENTS.md` 或项目自己编写的 `CLAUDE.md`。Copilot CLI 1.0.89 及更高版本也会读取项目的 `.claude/rules`,因此同时安装这两个工具时,Copilot 可能会读到两份。 @@ -1544,7 +1548,7 @@ Cursor 在每个会话中应用这两个 `teamai-context.mdc` 文件(`alwaysAp CodeBuddy 和 WorkBuddy 的规则文件在每个会话中应用(`alwaysApply: true`)。卸载其中一个工具时,只要另一个仍已安装,项目中共用的那份文件就会保留。 -在项目中,teamai 安装 Hermes 插件 `$HERMES_HOME/plugins/teamai-instructions/`,并把它加入 `$HERMES_HOME/config.yaml` 的 `plugins.enabled`(列在 `plugins.disabled` 中的名称保持关闭)。Hermes 在每个新会话开始时根据会话目录生成该段落,并在压缩和恢复后保留。一个段落最多 4,000 个字符,所有插件段落合计最多 8,000 个字符。当该成员在此项目的指令更长时,Hermes 会跳过它们,`teamai pull` 会给出提示:teamai 不会截断它们,也不会写入 `AGENTS.md`。在项目之外段落为空,Hermes 可能记录它跳过了一个空段落。 +在项目中,teamai 安装 Hermes 插件 `$HERMES_HOME/plugins/teamai-instructions/`,并把它加入 `$HERMES_HOME/config.yaml` 的 `plugins.enabled`(列在 `plugins.disabled` 中的名称保持关闭)。根据 Hermes 的文档,它在每个新会话开始时根据会话目录生成该段落,并在压缩和恢复后保留。一个段落最多 4,000 个字符,所有插件段落合计最多 8,000 个字符。当该成员在此项目的指令更长时,Hermes 会跳过它们,`teamai pull` 会给出提示:teamai 不会截断它们,也不会写入 `AGENTS.md`。在项目之外段落为空,Hermes 可能记录它跳过了一个空段落。 OpenCode 只加载配置中 `instructions` 列出的文件,因此 teamai 只添加这一项,并保留你的其他条目和键;根目录的 `opencode.json` 以及 OpenCode 自己的 `AGENTS.md` 文件保持不变。当 `~/.config/opencode/AGENTS.md` 不存在时,OpenCode 会改为读取 `~/.claude/CLAUDE.md`;若 Claude Code 的用户块已在那里,OpenCode 已经获得它们,因此 teamai 不会为 OpenCode 再写一份用户副本,并在 pull 输出中说明。teamai 无法按 JSON 解析的配置文件(例如带注释的文件)保持不变并给出警告,请手动添加该条目。 @@ -2660,7 +2664,7 @@ teamai uninstall --agent claude `--agent ` 只移除该工具的 teamai 资源(hooks、团队指令块、skills、rules、团队同步的自定义 agents、内置 agents)。工具名即 `toolPaths` 的键(如 `claude`、`codex`、`codebuddy`),匹配大小写不敏感。传入未知工具名会直接报错并列出可用工具、不执行任何删除,并以非零状态码退出。 -多个工具共同映射的指令文件按区块清理:只要该文件上仍有剩余工具会写入某个 teamai 区块,该区块就保留。最常见的是项目 `AGENTS.md`:Pi 仍启用时,`--agent workbuddy` 会移除 recall 区块,保留 Pi 写入的 culture 和共享指令区块。Codex 系工具不会在那里写入任何内容。teamai 创建的文件随最后一个区块一起删除;你原有的指令文件(即使是空文件)会保留。 +多个工具共同映射的指令文件按区块清理:只要该文件上仍有剩余工具会写入某个 teamai 区块,该区块就保留。最常见的是 CodeBuddy 与 WorkBuddy 共用的 `.codebuddy/rules/teamai-context.md`:只要 CodeBuddy 仍已安装,`--agent workbuddy` 就会保留它。早期版本写过这些块的文件(例如项目 `AGENTS.md`)现在没有任何工具读取,因此其中的 teamai 区块会被移除,你自己的内容保留。teamai 创建的文件随最后一个区块一起删除;你原有的指令文件(即使是空文件)会保留。 跨工具共享资源(shell profile env 块、docs 目录、`~/.teamai/`)**仅当该工具自身存在 teamai 资源、且它是最后一个仍在使用 teamai 的工具时**才一并移除,否则会为其余工具保留。(因此,定向卸载一个自身没有任何 teamai 资源的工具是 no-op,即便它恰好是唯一的工具,也不会删除共享资源。) diff --git a/skill-data/setup/references/uninstall.md b/skill-data/setup/references/uninstall.md index 0495129fd..aae4600d1 100644 --- a/skill-data/setup/references/uninstall.md +++ b/skill-data/setup/references/uninstall.md @@ -14,11 +14,13 @@ machine** (all tools)?"* - **Just this tool** → `--agent ` (use the tool this conversation runs in, e.g. `claude`). Shared resources are removed only if it is the last tool using - them. An instructions file several tools read (the project `AGENTS.md` of Pi, - Hermes and WorkBuddy) is cleaned block by block: a teamai block stays while - a remaining tool on that file still writes it, so `--agent workbuddy` with Pi - still enabled removes the recall block and keeps the rest. A file teamai - created goes with its last block; one the user had before stays, even if empty. + 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 + 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. - **Whole machine** → no `--agent` flag. Reassure them (in their language): *"This only removes things from your computer. diff --git a/src/__tests__/codex-instructions.test.ts b/src/__tests__/codex-instructions.test.ts index 95de7f204..2e3fd8512 100644 --- a/src/__tests__/codex-instructions.test.ts +++ b/src/__tests__/codex-instructions.test.ts @@ -126,6 +126,21 @@ describe('a project-scope rules sync writes no team rules for Codex (#938)', () expect(await fse.pathExists(path.join(projectRoot, `.${tool}`, 'rules'))).toBe(false); }); + it.each(CODEX_FAMILY)('writes no team rules for %s into /AGENTS.md even when the team points its claudemd there (#945)', async (tool) => { + await fse.ensureDir(path.join(projectRoot, `.${tool}`)); + teamConfig = TeamaiConfigSchema.parse({ + team: 'test', + repo: 'https://example.invalid/x/team.git', + toolPaths: { [tool]: { skills: `.${tool}/skills`, settings: `.${tool}/hooks.json`, claudemd: 'AGENTS.md' } }, + }); + localConfig = { ...localConfig, enabledAgents: [tool] } as LocalConfig; + await fse.writeFile(agentsMd(), '# Project notes\n'); + + await handler.pullAllRules(teamConfig, localConfig); + + expect(await fse.readFile(agentsMd(), 'utf8')).toBe('# Project notes\n'); + }); + it('keeps the rule files to the member\'s projects after `remove rules`', async () => { await fse.outputFile(path.join(repoPath, 'manifest', 'projects.yaml'), ` version: 1 diff --git a/src/__tests__/e2e/instruction-targets.test.ts b/src/__tests__/e2e/instruction-targets.test.ts index 027115f38..8a0c1c389 100644 --- a/src/__tests__/e2e/instruction-targets.test.ts +++ b/src/__tests__/e2e/instruction-targets.test.ts @@ -702,4 +702,21 @@ describe('instruction block targets on real CLI pull (#945)', () => { expect(omp).not.toContain('DEVELOPMENT-SENTINEL'); expect(omp).toContain('PRODUCT-SENTINEL'); }); + + it('leaves a tracked Copilot file alone for a member without Copilot', async () => { + const sandbox = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'teamai-945-e2e-'))); + sandboxes.push(sandbox); + const member = makeProjectMember(sandbox, makeTeamAndProject(sandbox), 'dev', 'developer', ['.claude/skills']); + const copilot = path.join(member.projectRoot, '.github', 'copilot-instructions.md'); + fs.mkdirSync(path.dirname(copilot), { recursive: true }); + // A teammate with Copilot committed their selection; this member has no Copilot. + fs.writeFileSync(copilot, `# Copilot notes\n\n${CLAUDEMD_START}\nthe teammate's selection\n${CLAUDEMD_END}\n`); + git(['add', '.github/copilot-instructions.md'], member.projectRoot); + git(['commit', '-q', '-m', 'copilot instructions'], member.projectRoot); + + const result = await pullAs(member); + expect(result.code, result.output).toBe(0); + + expect(execFileSync('git', ['status', '--porcelain', '--', '.github', 'AGENTS.md'], { cwd: member.projectRoot, encoding: 'utf8' })).toBe(''); + }); }); diff --git a/src/__tests__/recall-toggle.test.ts b/src/__tests__/recall-toggle.test.ts index 8beb33e9f..45179ef59 100644 --- a/src/__tests__/recall-toggle.test.ts +++ b/src/__tests__/recall-toggle.test.ts @@ -197,8 +197,8 @@ describe('recall toggle native agent cleanup', () => { // `enabledAgents` (from `teamai init --agent`) is documented as gating the CLI // built-in skills/rules/agents and CLAUDE.md-class injects. recallEnable deploys // all four, but only the first three went through the whitelist — the CLAUDE.md -// recall block was still injected into excluded tools. Same loop and guard as -// injectRecallBlockIntoTools (src/pull.ts). +// recall block was still injected into excluded tools. Same targets as pull +// (resolveInstructionTargets, src/instruction-targets.ts). describe('recall toggle honors the enabledAgents whitelist', () => { let tmpDir: string; let homeDir: string; diff --git a/src/doctor-delivery.ts b/src/doctor-delivery.ts index bfe158e61..16af0d683 100644 --- a/src/doctor-delivery.ts +++ b/src/doctor-delivery.ts @@ -325,7 +325,8 @@ async function buildRulesActivationChecks(ctx: DoctorContext, items: ResourceIte const opencode = await handler.opencodeInstructionsTarget(teamConfig, localConfig); if (opencode !== null) { - const instructions = await readOpencodeInstructions(opencode.configFile); + const { readOpencodeInstructionList } = await import('./resources/opencode-config.js'); + const instructions = await readOpencodeInstructionList(opencode.configFile); const active = instructions !== null && instructions.includes(opencode.glob); checks.push({ name: 'Team rules are active in opencode', @@ -435,24 +436,6 @@ async function buildCodexUserRulesChecks(ctx: DoctorContext, items: ResourceItem return checks; } -/** - * The `instructions` entries of an opencode.json, or null when the file is - * missing or is not a JSON object — the two cases in which the pull leaves it - * strictly alone and the glob never lands. - */ -async function readOpencodeInstructions(configFile: string): Promise { - const raw = await readFileSafe(configFile); - if (raw === null) return null; - if (raw.trim() === '') return []; - try { - const parsed: unknown = JSON.parse(raw); - if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) return null; - const { instructions } = parsed as { instructions?: unknown }; - return Array.isArray(instructions) ? instructions : []; - } catch { - return null; - } -} /** * Build one delivery check per tool that receives agents. @@ -1174,21 +1157,18 @@ export async function buildInstructionDeliveryChecks(ctx: DoctorContext): Promis const { localConfig, teamConfig } = ctx; if (!teamConfig) return []; const { - instructionHookText, instructionTargetPath, planInstructionFiles, resolveInstructionTargets, + hookLimitProblem, instructionHookChannel, instructionHookTextFor, instructionTargetPath, + planInstructionFiles, resolveInstructionTargets, } = await import('./instruction-targets.js'); const { resolveInstructionBlocks } = await import('./pull.js'); const { buildRolePullContext } = await import('./resources/desired.js'); + const { opencodeContextReference, readOpencodeInstructionList } = await import('./resources/opencode-config.js'); const { blocks } = await resolveInstructionBlocks(teamConfig, localConfig, await buildRolePullContext(localConfig)); const { targets, hooks, stale } = await resolveInstructionTargets(teamConfig, localConfig); - const pullForce = 'Run `teamai pull --force`: a plain pull skips a scope whose team repo has not changed.'; + const pullNow = 'Run `teamai pull`.'; const checks: Check[] = []; - const { opencodeClaudeFallback, opencodeContextReference } = await import('./resources/opencode-config.js'); - const claudeFallback = localConfig.scope === 'user' - ? await opencodeClaudeFallback(getUserHome(), targets.map((t) => t.path)) - : null; for (const target of targets) { - if (claudeFallback && target.tools.includes('opencode')) continue; const plan = await planInstructionFiles([target], blocks); checks.push({ name: `Team instructions are current for ${target.tools.join(', ')}`, @@ -1196,38 +1176,35 @@ export async function buildInstructionDeliveryChecks(ctx: DoctorContext): Promis check: async () => plan.changes.length === 0 && plan.warnings.length === 0, fix: plan.warnings.length > 0 ? plan.warnings.join(' ') - : `${target.path} does not hold this member's current team instructions. ${pullForce}`, + : `${target.path} does not hold this member's current team instructions. ${pullNow}`, }); } const opencodePaths = scopedToolPaths(teamConfig, localConfig).opencode; const opencodeFile = opencodePaths && instructionTargetPath('opencode', opencodePaths, localConfig); // Only a file that exists needs listing; pull registers it once it writes one. - if (opencodeFile && !claudeFallback && targets.some((t) => t.path === opencodeFile) && await pathExists(opencodeFile)) { + if (opencodeFile && targets.some((t) => t.path === opencodeFile) && await pathExists(opencodeFile)) { const { config, entry } = opencodeContextReference(opencodeFile, localConfig.scope, resolveToolBaseDir('opencode', localConfig)); - const instructions = await readOpencodeInstructions(config); + const instructions = await readOpencodeInstructionList(config); checks.push({ name: 'Team instructions are listed in opencode instructions', source: 'local', check: async () => instructions !== null && instructions.includes(entry), fix: instructions === null ? `${config} could not be read as a JSON object, so the pull left it alone and OpenCode never loads ${opencodeFile}. ` - + `Fix the file or add "${entry}" to its "instructions" by hand, then run \`teamai pull --force\`.` - : `${config} does not list "${entry}" under "instructions", and OpenCode reads no file it is not told about. ${pullForce}`, + + `Fix the file or add "${entry}" to its "instructions" by hand, then run \`teamai pull\`.` + : `${config} does not list "${entry}" under "instructions", and OpenCode reads no file it is not told about. ${pullNow}`, }); } for (const hook of hooks) { - const length = instructionHookText(blocks, hook.recall).length; const channel = await instructionHookChannel(hook.tool); + const overLimit = hookLimitProblem(hook, await instructionHookTextFor(teamConfig, localConfig, hook.tool)); checks.push({ name: `${hook.tool} adds the team instructions to its prompt`, source: 'local', - check: async () => channel.ready && (hook.limit === undefined || length <= hook.limit), - fix: !channel.ready - ? channel.fix - : `This member's team instructions for the project are ${length} characters, over the ${hook.limit}-character ` - + `limit of a ${hook.tool} prompt section, so ${hook.tool} skips them. Shorten culture.md or the claudemd/ files for this scope.`, + check: async () => channel.ready && overLimit === null, + fix: channel.ready ? overLimit ?? '' : channel.fix, }); } @@ -1239,46 +1216,7 @@ export async function buildInstructionDeliveryChecks(ctx: DoctorContext): Promis name: 'No team instruction blocks are left in files no tool loads them from', source: 'local', check: async () => leftovers.length === 0, - fix: `Earlier teamai releases left team instruction blocks in ${nameList(leftovers)}, which can carry another member's selection. ${pullForce}`, + fix: `Earlier teamai releases left team instruction blocks in ${nameList(leftovers)}, which can carry another member's selection. ${pullNow}`, }); return checks; } - -/** Whether the extension or plugin that adds a hook tool's team instructions is installed as this build writes it. */ -async function instructionHookChannel(tool: string): Promise<{ ready: boolean; fix: string }> { - const rerun = 'Run `teamai pull --force` to reinstall it; it also comes back after `teamai hooks` restores the built-in hooks.'; - if (tool === 'omp' || tool === 'pi') { - const { buildOmpExtensionSource, resolveOmpExtensionsDir, OMP_HOOK_FILE } = await import('./omp-hooks.js'); - const { buildPiExtensionSource, resolvePiExtensionsDir, PI_HOOK_FILE } = await import('./pi-hooks.js'); - const file = tool === 'omp' ? path.join(resolveOmpExtensionsDir(), OMP_HOOK_FILE) : path.join(resolvePiExtensionsDir(), PI_HOOK_FILE); - const expected = tool === 'omp' ? buildOmpExtensionSource() : buildPiExtensionSource(); - const ready = await readFileSafe(file) === expected; - return { ready, fix: `${file} is missing or out of date, so ${tool} sessions in this project get no team instructions. ${rerun}` }; - } - if (tool === 'hermes') { - const { buildInstructionsPlugin, getInstructionsPluginDir, HERMES_INSTRUCTIONS_PLUGIN } = await import('./hermes-hooks.js'); - const { getHermesConfigPath } = await import('./hermes-config.js'); - const dir = getInstructionsPluginDir(); - const plugin = buildInstructionsPlugin(); - const installed = await readFileSafe(path.join(dir, '__init__.py')) === plugin.init - && await readFileSafe(path.join(dir, 'plugin.yaml')) === plugin.manifest; - const config = await readFileSafe(getHermesConfigPath()) ?? ''; - const YAML = (await import('yaml')).default; - const enabled = (() => { - try { - const list = (YAML.parse(config) as { plugins?: { enabled?: unknown } } | null)?.plugins?.enabled; - return Array.isArray(list) && list.includes(HERMES_INSTRUCTIONS_PLUGIN); - } catch { - return false; - } - })(); - return { - ready: installed && enabled, - fix: !installed - ? `The Hermes plugin ${dir} is missing or out of date, so Hermes sessions in this project get no team instructions. ${rerun}` - : `${HERMES_INSTRUCTIONS_PLUGIN} is not in plugins.enabled of ${getHermesConfigPath()}, and Hermes loads no user plugin that is not listed there. ` - + 'Add it there (or remove it from plugins.disabled), then start a new Hermes session.', - }; - } - return { ready: true, fix: '' }; -} diff --git a/src/hermes-config.ts b/src/hermes-config.ts index f258888c6..ba9e9a6b1 100644 --- a/src/hermes-config.ts +++ b/src/hermes-config.ts @@ -327,3 +327,9 @@ export async function disableHermesPlugin(name: string): Promise { if (YAML.isMap(plugins) && plugins.items.length === 0) doc.deleteIn(['plugins']); await writeConfigDoc(doc); } + +/** Whether `name` is in `plugins.enabled` of the Hermes config.yaml. */ +export async function isHermesPluginEnabled(name: string): Promise { + const enabled = (await readConfigDoc()).getIn(['plugins', 'enabled']); + return YAML.isSeq(enabled) && (enabled.toJSON() as unknown[]).includes(name); +} diff --git a/src/hook-handlers.ts b/src/hook-handlers.ts index 37029e935..3790f338f 100644 --- a/src/hook-handlers.ts +++ b/src/hook-handlers.ts @@ -735,13 +735,9 @@ const teamRulesHandler: HookHandler = { const { loadTeamConfig } = await import('./config.js'); const teamConfig = await loadTeamConfig(config.repo.localPath); if (!teamConfig) return null; - const { instructionHookText } = await import('./instruction-targets.js'); - const { scopedToolPaths } = await import('./types.js'); - const { buildRolePullContext } = await import('./resources/desired.js'); - const { resolveInstructionBlocks } = await import('./pull.js'); + const { instructionHookTextFor } = await import('./instruction-targets.js'); const { teamRulesContext } = await import('./resources/rules.js'); - const { blocks } = await resolveInstructionBlocks(teamConfig, config, await buildRolePullContext(config)); - const text = instructionHookText(blocks, Boolean(scopedToolPaths(teamConfig, config)[tool]?.agents)); + const text = await instructionHookTextFor(teamConfig, config, tool); const parts = text ? [text] : []; const rules = await teamRulesContext(teamConfig, config); if (rules !== null) parts.push(rules); @@ -762,16 +758,13 @@ const instructionsHandler: HookHandler = { name: 'instructions', async execute(_stdin, tool, config) { if (!config) return null; - const { deliversInstructionsByHook, instructionHookText } = await import('./instruction-targets.js'); - const { isAgentExcluded, scopedToolPaths } = await import('./types.js'); + const { deliversInstructionsByHook, instructionHookTextFor } = await import('./instruction-targets.js'); + const { isAgentExcluded } = await import('./types.js'); if (!deliversInstructionsByHook(tool, config.scope) || isAgentExcluded(config, tool)) return null; const { loadTeamConfig } = await import('./config.js'); const teamConfig = await loadTeamConfig(config.repo.localPath); if (!teamConfig) return null; - const { buildRolePullContext } = await import('./resources/desired.js'); - const { resolveInstructionBlocks } = await import('./pull.js'); - const { blocks } = await resolveInstructionBlocks(teamConfig, config, await buildRolePullContext(config)); - const text = instructionHookText(blocks, Boolean(scopedToolPaths(teamConfig, config)[tool]?.agents)); + const text = await instructionHookTextFor(teamConfig, config, tool); if (!text) return null; return JSON.stringify({ hookSpecificOutput: { hookEventName: 'SessionStart', additionalContext: text } }); }, @@ -893,7 +886,7 @@ export function buildHandlerRegistry(): HandlerRegistration[] { { event: 'session-start', matcher: '*', handler: packageHintHandler, timeoutMs: FOREGROUND_HOOK_TIMEOUT_MS, requiresConfig: true }, { event: 'session-start', matcher: '*', handler: secretsHintHandler, timeoutMs: FOREGROUND_HOOK_TIMEOUT_MS, requiresConfig: true }, { event: 'session-start', matcher: '*', handler: localAgentHandler, timeoutMs: FOREGROUND_HOOK_TIMEOUT_MS }, - // Asked for by the Pi and OMP extensions, which add the result to the prompt. + // Asked for by the Pi and OMP extensions and the Hermes plugin, which add the result to the prompt. { event: 'instructions', matcher: '*', handler: instructionsHandler, timeoutMs: FOREGROUND_HOOK_TIMEOUT_MS, requiresConfig: true }, { event: 'session-start', matcher: '*', handler: webhookHandler, timeoutMs: FOREGROUND_HOOK_TIMEOUT_MS, background: true, requiresConfig: true }, diff --git a/src/init.ts b/src/init.ts index 26d9ba803..c29258ca6 100644 --- a/src/init.ts +++ b/src/init.ts @@ -2081,6 +2081,13 @@ export async function init(options: GlobalOptions & { } await reconcileHooksForInit(reloadedTeamConfig, localConfig, filterAgents); + // Name what keeps a tool from getting the team instructions (#945). + try { + const { instructionChannelProblems } = await import('./instruction-targets.js'); + for (const problem of await instructionChannelProblems(reloadedTeamConfig, localConfig)) log.warn(problem); + } catch (e) { + log.debug(`Team instruction check skipped: ${(e as Error).message}`); + } // Step 7.5: Deploy the built-in discovery stub immediately so the teamai // skill is available in the IDE right after init, without waiting for the diff --git a/src/instruction-targets.ts b/src/instruction-targets.ts index a70a153d9..d73c39d95 100644 --- a/src/instruction-targets.ts +++ b/src/instruction-targets.ts @@ -1,6 +1,10 @@ import path from 'node:path'; import { isToolInstalledForConfig } from './resources/base.js'; import { pathExists, readFileSafe, remove, writeFile } from './utils/fs.js'; +import { getUserHome } from './utils/home.js'; +import { + opencodeClaudeFallback, opencodeContextReference, readOpencodeInstructionList, reconcileOpencodeInstructions, +} from './resources/opencode-config.js'; import { gitTracking, gitTracks } from './mcp-git-exclude.js'; import { TEAMAI_CONTEXT_RULE_NAME } from './builtin-rules.js'; import { getHermesHome } from './hermes-home.js'; @@ -70,13 +74,13 @@ const contextRule = (extension: string) => (paths: ToolPaths): string | undefine const ALWAYS_APPLY = '---\nalwaysApply: true\n---\n'; const cursor: TargetEntry = { file: contextRule('.mdc'), header: ALWAYS_APPLY, owned: true, retired: [] }; +// A team override or an earlier build could have pointed Codex at the project AGENTS.md. +const codexHook: TargetEntry = { file: () => undefined, hook: true, retired: ['AGENTS.md'] }; + /** * CodeBuddy and WorkBuddy both read the project's .codebuddy/rules, so they * share one copy there; uninstalling one keeps it while the other remains. */ -// A team override or an earlier build could have pointed Codex at the project AGENTS.md. -const codexHook: TargetEntry = { file: () => undefined, hook: true, retired: ['AGENTS.md'] }; - const codebuddyProjectRule = (): string => `.codebuddy/rules/${TEAMAI_CONTEXT_RULE_NAME}.md`; // One line per tool, so a change to one tool's target edits one line. @@ -137,7 +141,11 @@ type MarkerPair = readonly [start: string, end: string, name: string]; const CULTURE: MarkerPair = [TEAMAI_CULTURE_START, TEAMAI_CULTURE_END, 'culture']; const CLAUDEMD: MarkerPair = [TEAMAI_CLAUDEMD_START, TEAMAI_CLAUDEMD_END, 'claudemd']; const RECALL: MarkerPair = [TEAMAI_RECALL_RULES_START, TEAMAI_RECALL_RULES_END, 'recall']; -/** The rules block releases before per-file rules wrote into the same files. */ +/** + * The rules block releases before per-file rules wrote into instruction files. + * Hermes still writes it live in SOUL.md, which is only ever a target here, + * never a retired file, so cleanup never reaches that copy. + */ const LEGACY_RULES: MarkerPair = [TEAMAI_RULES_START, TEAMAI_RULES_END, 'rules']; /** The team rules a Codex-family tool reads from its own AGENTS.md in user scope. */ const TEAM_RULES: MarkerPair = [TEAMAI_TEAM_RULES_START, TEAMAI_TEAM_RULES_END, 'team-rules']; @@ -174,6 +182,8 @@ export interface InstructionTargets { hooks: InstructionHook[]; /** Known targets no installed tool reads: a pull strips teamai blocks from them. */ stale: InstructionTarget[]; + /** Claude's user file when OpenCode reads the blocks from it, so OpenCode gets no file of its own. */ + opencodeFallback?: string | null; } /** @@ -217,6 +227,77 @@ export function instructionHookText(blocks: InstructionBlocks, recall: boolean): .join('\n\n'); } +/** The text a session hook adds for `tool`, resolved for the member, project and scope in `localConfig`. */ +export async function instructionHookTextFor(teamConfig: TeamaiConfig, localConfig: LocalConfig, tool: string): Promise { + const { buildRolePullContext } = await import('./resources/desired.js'); + const { resolveInstructionBlocks } = await import('./pull.js'); + const { blocks } = await resolveInstructionBlocks(teamConfig, localConfig, await buildRolePullContext(localConfig)); + return instructionHookText(blocks, Boolean(scopedToolPaths(teamConfig, localConfig)[tool]?.agents)); +} + +/** The message for hook text over its channel's limit, or null when it fits. */ +export function hookLimitProblem(hook: InstructionHook, text: string): string | null { + if (hook.limit === undefined || text.length <= hook.limit) return null; + return `${hook.tool} cannot load this project's team instructions: they are ${text.length} characters, over the ` + + `${hook.limit}-character limit of its prompt section, so ${hook.tool} skips them. Shorten culture.md or the ` + + 'claudemd/ files for this scope. teamai does not cut them or write them to AGENTS.md.'; +} + +/** + * Whether the extension or plugin that adds a hook tool's team instructions is + * installed as this build writes it, and if not, what to do. + */ +export async function instructionHookChannel(tool: string): Promise<{ ready: boolean; fix: string }> { + const rerun = 'Run `teamai pull` to reinstall it; `teamai hooks remove` takes it away.'; + if (tool === 'omp' || tool === 'pi') { + const { buildOmpExtensionSource, resolveOmpExtensionsDir, OMP_HOOK_FILE } = await import('./omp-hooks.js'); + const { buildPiExtensionSource, resolvePiExtensionsDir, PI_HOOK_FILE } = await import('./pi-hooks.js'); + const file = tool === 'omp' ? path.join(resolveOmpExtensionsDir(), OMP_HOOK_FILE) : path.join(resolvePiExtensionsDir(), PI_HOOK_FILE); + const expected = tool === 'omp' ? buildOmpExtensionSource() : buildPiExtensionSource(); + return { + ready: await readFileSafe(file) === expected, + fix: `${file} is missing or out of date, so ${tool} sessions in this project get no team instructions. ${rerun}`, + }; + } + if (tool === 'hermes') { + const { buildInstructionsPlugin, getInstructionsPluginDir, HERMES_INSTRUCTIONS_PLUGIN } = await import('./hermes-hooks.js'); + const { getHermesConfigPath, isHermesPluginEnabled } = await import('./hermes-config.js'); + const dir = getInstructionsPluginDir(); + const plugin = buildInstructionsPlugin(); + const installed = await readFileSafe(path.join(dir, '__init__.py')) === plugin.init + && await readFileSafe(path.join(dir, 'plugin.yaml')) === plugin.manifest; + if (!installed) { + return { ready: false, fix: `The Hermes plugin ${dir} is missing or out of date, so Hermes sessions in this project get no team instructions. ${rerun}` }; + } + return { + ready: await isHermesPluginEnabled(HERMES_INSTRUCTIONS_PLUGIN), + fix: `${HERMES_INSTRUCTIONS_PLUGIN} is not in plugins.enabled of ${getHermesConfigPath()}, and Hermes loads no user plugin that is not listed there. ` + + 'Add it there (or remove it from plugins.disabled), then start a new Hermes session.', + }; + } + return { ready: true, fix: '' }; +} + +/** + * What keeps an installed hook tool from getting this member's team + * instructions in the scope: its extension or plugin, or the size of the text. + * pull and init print these after installing the hooks; doctor checks them. + */ +export async function instructionChannelProblems(teamConfig: TeamaiConfig, localConfig: LocalConfig): Promise { + const { hooks } = await resolveInstructionTargets(teamConfig, localConfig); + const problems: string[] = []; + for (const hook of hooks) { + const channel = await instructionHookChannel(hook.tool); + if (!channel.ready) { + problems.push(channel.fix); + continue; + } + const overLimit = hookLimitProblem(hook, await instructionHookTextFor(teamConfig, localConfig, hook.tool)); + if (overLimit) problems.push(overLimit); + } + return problems; +} + /** A block's text without its markers and DO NOT EDIT line, which only a file needs. */ function managedBlockBody(block: string): string { return block @@ -243,15 +324,12 @@ export function instructionTargetAt(tool: string, file: string, scope: Scope): I } /** - * Every file teamai may have written instruction blocks to in the active - * scope: each tool's current target plus the targets earlier releases used. + * The files earlier releases wrote instruction blocks to in the active scope. + * A tool's current target is not among them: a tool that is not installed + * here may still be installed by a teammate who shares the file (#945). */ -function knownInstructionTargets(teamConfig: TeamaiConfig, localConfig: LocalConfig): Map { +function retiredTargets(localConfig: LocalConfig): Map { const known = new Map(); - for (const [tool, paths] of Object.entries(scopedToolPaths(teamConfig, localConfig))) { - const file = instructionTargetPath(tool, paths, localConfig); - if (file && !known.has(file)) known.set(file, instructionTargetAt(tool, file, localConfig.scope)); - } const table = localConfig.scope === 'user' ? USER_TARGETS : PROJECT_TARGETS; for (const [tool, entry] of Object.entries(table)) { const baseDir = resolveToolBaseDir(tool, localConfig); @@ -302,8 +380,45 @@ export async function resolveInstructionTargets( if (paths.agents) target.recall = true; targets.set(file, target); } - const stale = [...knownInstructionTargets(teamConfig, localConfig).values()].filter((t) => !inUse.has(t.path)); - return { targets: [...targets.values()], hooks, stale }; + const stale = [...retiredTargets(localConfig).values()].filter((t) => !inUse.has(t.path)); + // OpenCode reads ~/.claude/CLAUDE.md while its own user AGENTS.md does not + // exist; when Claude's blocks are there, a second copy would duplicate them. + const opencode = [...targets.values()].find((target) => target.tools.includes('opencode')); + const opencodeFallback = localConfig.scope === 'user' && opencode !== undefined + ? await opencodeClaudeFallback(getUserHome(), [...targets.keys()]) + : null; + if (opencode && opencodeFallback) { + targets.delete(opencode.path); + stale.push(opencode); + } + return { targets: [...targets.values()], hooks, stale, opencodeFallback }; +} + +/** + * List teamai's OpenCode instruction file in OpenCode's `instructions` while + * it exists, and drop the entry once it is gone: OpenCode reads no file it is + * not told about. Returns what it did or, with `dryRun`, would do. + */ +export async function registerOpencodeContext( + teamConfig: TeamaiConfig, + localConfig: LocalConfig, + resolved: Pick, + dryRun: boolean, +): Promise { + const paths = scopedToolPaths(teamConfig, localConfig).opencode; + const contextFile = paths && instructionTargetPath('opencode', paths, localConfig); + if (!contextFile) return null; + const wanted = resolved.targets.some((target) => target.path === contextFile); + if (!wanted && !resolved.stale.some((target) => target.path === contextFile)) return null; + const present = wanted && (dryRun || await pathExists(contextFile)); + const { config, entry } = opencodeContextReference(contextFile, localConfig.scope, resolveToolBaseDir('opencode', localConfig)); + if (dryRun) { + const listed = (await readOpencodeInstructionList(config))?.includes(entry) ?? false; + if (listed === present) return null; + return `Would ${present ? 'add' : 'remove'} "${entry}" ${present ? 'to' : 'from'} the instructions of ${config}`; + } + const changed = await reconcileOpencodeInstructions(config, entry, present, 'team instructions'); + return changed ? `${present ? 'Added' : 'Removed'} "${entry}" ${present ? 'to' : 'from'} the instructions of ${config}` : null; } // ─── Planning file contents ──────────────────────────── diff --git a/src/local-agent.ts b/src/local-agent.ts index 3d0b4e066..03daa3504 100644 --- a/src/local-agent.ts +++ b/src/local-agent.ts @@ -52,7 +52,8 @@ import { } from './mcp-reconcile.js'; import { normalizeAgentType } from './utils/tool-names.js'; import { logHttpRequest, logHttpResponse } from './utils/http-log.js'; -import { applyInstructionPlan, instructionTargetAt, instructionTargetFile, planInstructionFiles } from './instruction-targets.js'; +import { applyInstructionPlan, instructionTargetAt, instructionTargetFile, planInstructionFiles, registerOpencodeContext } from './instruction-targets.js'; +import { opencodeClaudeFallback } from './resources/opencode-config.js'; import { reconcilePlugins, teardownAllPlugins, parseGetConfig, substituteVars, unresolvedPlaceholders, type ReconcileDeps, type PluginState } from './plugin-lifecycle.js'; import { resolveBaseDir, @@ -2057,24 +2058,6 @@ async function uninstallResource(input: { await saveManifest(manifest); } -async function resolveHermesUserBaseDir(): Promise { - try { - const envWs = process.env.TEAMAI_HERMES_WORKSPACE; - if (envWs && path.isAbsolute(envWs)) return envWs; - const cfg = await readJson(getConfigPath()); - const bindings = cfg?.workspaceBindings; - if (bindings && typeof bindings === 'object') { - const entries = Object.entries(bindings) - .filter(([p, v]) => path.isAbsolute(p) && v?.ideType === 'hermes') - .sort((a, b) => (b[1].boundAt ?? '').localeCompare(a[1].boundAt ?? '')); - for (const [p] of entries) { - if (await pathExists(path.join(p, '.hermes'))) return p; - } - } - } catch { /* fall through */ } - return undefined; -} - async function syncClaudemd( teamConfig: TeamaiConfig, localConfig: LocalConfig, @@ -2105,12 +2088,6 @@ async function syncClaudemd( if (openclawWs) { resolvedAbsPath = path.join(openclawWs, path.basename(targetFile)); } - } else if (tool === 'hermes' && localConfig.scope !== 'project') { - const hermesBase = workspacePath ?? await resolveHermesUserBaseDir(); - if (hermesBase) { - baseDir = hermesBase; - log.debug(`local-agent: hermes user-scope baseDir resolved to ${baseDir}`); - } } const toolInstalled = resolvedAbsPath @@ -2128,13 +2105,22 @@ async function syncClaudemd( } const claudeMdPath = resolvedAbsPath ?? path.resolve(baseDir, targetFile); - const plan = await planInstructionFiles([instructionTargetAt(tool, claudeMdPath, localConfig.scope)], { claudemd: block }); + // OpenCode's Claude fallback already carries the blocks, as in pull (#945). + const claudeUserFile = path.join(getUserHome(), '.claude', 'CLAUDE.md'); + if (tool === 'opencode' && localConfig.scope === 'user' && await pathExists(claudeUserFile) + && await opencodeClaudeFallback(getUserHome(), [claudeUserFile])) { + log.debug(`local-agent: OpenCode reads the team instructions from ${claudeUserFile}; skipped`); + continue; + } + const target = instructionTargetAt(tool, claudeMdPath, localConfig.scope); + const plan = await planInstructionFiles([target], { claudemd: block }); for (const warning of plan.warnings) log.warn(warning); const { failures } = await applyInstructionPlan(plan, { dryRun: false }); if (failures.length > 0) { log.warn(`Failed to sync CLAUDE.md instructions to ${tool}: ${failures.join(' ')}`); continue; } + if (tool === 'opencode') await registerOpencodeContext(teamConfig, localConfig, { targets: [target], stale: [] }, false); log.debug(`local-agent: ${block ? 'synced' : 'removed'} CLAUDE.md instructions for ${tool}`); syncedAny = true; } diff --git a/src/pull.ts b/src/pull.ts index 8ef3b8680..27ade5f98 100644 --- a/src/pull.ts +++ b/src/pull.ts @@ -14,7 +14,7 @@ import { indexableLearningsRoots } from './utils/learnings-roots.js'; import { log, spinner } from './utils/logger.js'; import { pathExists, remove, listFiles, listDirs, listFilesRecursive, readFileSafe, dirContentEqual, hasVcsMetadataRecursive } from './utils/fs.js'; import { reconcilePlacementRecords } from './utils/pending-push.js'; -import { applyInstructionPlan, instructionHookText, instructionTargetPath, planInstructionFiles, resolveInstructionTargets, type InstructionBlocks, type InstructionTarget } from './instruction-targets.js'; +import { applyInstructionPlan, planInstructionFiles, registerOpencodeContext, resolveInstructionTargets, type InstructionBlocks } from './instruction-targets.js'; import { getHandler, RulesHandler, DocsHandler, EnvHandler, AgentsHandler } from './resources/index.js'; import { reportHeldAgents, type RedeployedCopy } from './resources/agents.js'; import { listStaleDocDirectories, resolveDesiredDocs, resolveDocsDestination } from './resources/docs.js'; @@ -57,7 +57,6 @@ import { declaredSecretKeys } from './resources/secrets.js'; import { envShVariables, resolveTeamEnv, variablesKeptWarning, type TeamEnv } from './env-resolution.js'; import { describeEnvAdvisory, envAdvisories } from './env-advisories.js'; import { getUserHome } from './utils/home.js'; -import { opencodeClaudeFallback, opencodeContextReference, reconcileOpencodeInstructions } from './resources/opencode-config.js'; import { acquireLock, releaseLock } from './update.js'; import { mirrorLearnings } from './utils/learnings-mirror.js'; import { withTimeout } from './utils/async.js'; @@ -1877,9 +1876,9 @@ export async function resolveInstructionBlocks( * tool's target, and strip them from files no installed tool loads them from * (#945). Runs on the "Already synced" fast path too, so a CLI upgrade that * moves a target or ships a new recall block takes effect without a repo - * change. The recall block goes to targets whose tool has the `teamai-recall` - * subagent; the block itself tells an agent without one to run - * `teamai recall` directly. A dry run reports the files it would change. + * change. The recall block goes only to targets whose tool has the + * `teamai-recall` subagent, since it tells the agent to call that subagent. + * A dry run reports the files it would change. */ async function syncManagedInstructions( config: TeamaiConfig, @@ -1890,16 +1889,9 @@ async function syncManagedInstructions( ): Promise { const { blocks, claudemdFiles } = await resolveInstructionBlocks(config, localConfig, roleContext); const resolved = await resolveInstructionTargets(config, localConfig); - const { hooks, stale } = resolved; - let { targets } = resolved; - const opencodeTarget = targets.find((target) => target.tools.includes('opencode')); - if (opencodeTarget && localConfig.scope === 'user') { - const claudeFile = await opencodeClaudeFallback(getUserHome(), targets.map((target) => target.path)); - if (claudeFile) { - log.info(`[${scopeLabel}] OpenCode reads the team instructions from ${claudeFile}, its fallback while ~/.config/opencode/AGENTS.md does not exist, so teamai adds no second copy for it. Create that AGENTS.md to have teamai deliver them to ${opencodeTarget.path} instead.`); - targets = targets.filter((target) => target !== opencodeTarget); - stale.push(opencodeTarget); - } + const { targets, stale, opencodeFallback } = resolved; + if (opencodeFallback) { + log.info(`[${scopeLabel}] OpenCode reads the team instructions from ${opencodeFallback}, its fallback while ~/.config/opencode/AGENTS.md does not exist, so teamai adds no second copy for it. Create that AGENTS.md to have teamai deliver them to OpenCode's own file instead.`); } const plan = await planInstructionFiles(targets, blocks, stale); for (const warning of plan.warnings) log.warn(`[${scopeLabel}] ${warning}`); @@ -1910,41 +1902,18 @@ async function syncManagedInstructions( else log.debug(line); } for (const failure of failures) log.warn(`[${scopeLabel}] ${failure}`); - if (!dryRun) await registerOpencodeContext(config, localConfig, targets, stale); - for (const hook of hooks) { - const length = instructionHookText(blocks, hook.recall).length; - if (hook.limit !== undefined && length > hook.limit) { - log.warn(`[${scopeLabel}] ${hook.tool} cannot load this project's team instructions: they are ${length} characters, over the ${hook.limit}-character limit of its prompt section, so ${hook.tool} skips them. Shorten culture.md or the claudemd/ files for this scope. teamai does not cut them or write them to AGENTS.md.`); - } - } - if (dryRun || targets.length + hooks.length === 0) return; - if (blocks.culture) log.success('Synced team culture'); - if (blocks.claudemd) log.success(`[${scopeLabel}] Synced shared instructions (${claudemdFiles} file(s))`); -} - -/** - * Point OpenCode's `instructions` at teamai's instruction file while it - * exists, and drop the entry once it is gone: OpenCode reads no file it is - * not told about (#945). - */ -async function registerOpencodeContext( - config: TeamaiConfig, - localConfig: LocalConfig, - targets: readonly InstructionTarget[], - stale: readonly InstructionTarget[], -): Promise { - const paths = scopedToolPaths(config, localConfig).opencode; - const contextFile = paths && instructionTargetPath('opencode', paths, localConfig); - if (!contextFile) return; - const present = targets.some((target) => target.path === contextFile) && await pathExists(contextFile); - if (!present && !stale.some((target) => target.path === contextFile)) return; - const projectRoot = resolveToolBaseDir('opencode', localConfig); - const { config: configFile, entry } = opencodeContextReference(contextFile, localConfig.scope, projectRoot); try { - await reconcileOpencodeInstructions(configFile, entry, present, 'team instructions'); + const registered = await registerOpencodeContext(config, localConfig, resolved, dryRun); + if (registered && dryRun) log.info(`[dry-run] ${registered}`); + else if (registered) log.debug(registered); } catch (e) { - log.warn(`Failed to update ${configFile}: ${(e as Error).message}. Add "${entry}" to its "instructions" by hand so OpenCode loads the team instructions.`); + log.warn(`[${scopeLabel}] Failed to update OpenCode's instructions: ${(e as Error).message}. Add teamai-context.md to its "instructions" by hand so OpenCode loads the team instructions.`); } + // Hook targets (Pi, OMP, Hermes, Codex) are reported after the hooks are + // reconciled, since that is what installs their extensions and plugins. + if (dryRun || targets.length === 0) return; + if (blocks.culture) log.success('Synced team culture'); + if (blocks.claudemd) log.success(`[${scopeLabel}] Synced shared instructions (${claudemdFiles} file(s))`); } /** @@ -1953,8 +1922,8 @@ async function registerOpencodeContext( * involves code changes / troubleshooting / design. * 2. Declare which doc_ids were actually consulted at task completion. * - * Only injected for Tier-1 tools (those with both `agents` and `claudemd` - * paths configured) — see pull.ts Step 3.8. + * Delivered to the instruction targets of tools with the subagent (an + * `agents` path) — see syncManagedInstructions. */ export function compileRecallRulesBlock(): string { const lines = [ @@ -2529,6 +2498,12 @@ async function reconcileHooksAllScopes( // claim a reconcile that did not happen. log.debug(`[${localConfig.scope}] ${options.dryRun ? 'Would apply' : 'Reconciled'} ${reconciled.defs.length} team hook(s)`); } + // The hooks install the extensions and plugins that add team + // instructions for Pi, OMP and Hermes (#945); say which cannot. + if (!options.dryRun) { + const { instructionChannelProblems } = await import('./instruction-targets.js'); + for (const problem of await instructionChannelProblems(teamConfig, localConfig)) log.warn(`[${localConfig.scope}] ${problem}`); + } } catch (e) { log.debug(`[${localConfig.scope}] Hook reconcile skipped: ${(e as Error).message}`); } diff --git a/src/recall-toggle.ts b/src/recall-toggle.ts index dc96f7378..c0c9e5833 100644 --- a/src/recall-toggle.ts +++ b/src/recall-toggle.ts @@ -2,7 +2,7 @@ import path from 'node:path'; import { autoDetectInit, saveLocalConfigForScope } from './config.js'; import { log } from './utils/logger.js'; import { remove, pathExists } from './utils/fs.js'; -import { applyInstructionPlan, planInstructionFiles, resolveInstructionTargets } from './instruction-targets.js'; +import { applyInstructionPlan, planInstructionFiles, registerOpencodeContext, resolveInstructionTargets } from './instruction-targets.js'; import { ALL_SUPPORTED_TOOLS, agentFileExtensionForTool, @@ -70,7 +70,8 @@ async function removeRecallArtifacts(teamConfig: TeamaiConfig, localConfig: Loca /** Set (`block`) or remove (`null`) the recall block wherever teamai delivers instruction blocks. */ async function writeRecallBlock(teamConfig: TeamaiConfig, localConfig: LocalConfig, block: string | null): Promise { - const { targets, stale } = await resolveInstructionTargets(teamConfig, localConfig); + const resolved = await resolveInstructionTargets(teamConfig, localConfig); + const { targets, stale } = resolved; // Removal also reaches files no installed tool reads any more, but leaves // their other blocks to the next pull's cleanup. const files = block === null ? [...targets, ...stale] : targets; @@ -79,6 +80,7 @@ async function writeRecallBlock(teamConfig: TeamaiConfig, localConfig: LocalConf const { report, failures } = await applyInstructionPlan(plan, { dryRun: false }); for (const line of report) log.debug(line); for (const failure of failures) log.warn(failure); + if (block !== null) await registerOpencodeContext(teamConfig, localConfig, resolved, false); } async function deployRecallArtifacts(teamConfig: TeamaiConfig, localConfig: LocalConfig): Promise { diff --git a/src/resources/opencode-config.ts b/src/resources/opencode-config.ts index 5e310032b..3a8809908 100644 --- a/src/resources/opencode-config.ts +++ b/src/resources/opencode-config.ts @@ -104,12 +104,30 @@ export async function reconcileOpencodeInstructions( } await writeJsonAtomic(configFileAbs, data); - log.debug(`${present ? 'Added' : 'Removed'} teamai rules glob in ${configFileAbs}`); + log.debug(`${present ? 'Added' : 'Removed'} teamai ${purpose} entry in ${configFileAbs}`); return true; } // ─── OpenCode team instructions (#945) ─────────────────────── +/** + * The `instructions` entries of an opencode.json, or null when the file is + * missing or is not a JSON object, the two cases in which pull leaves it alone. + */ +export async function readOpencodeInstructionList(configFile: string): Promise { + const raw = await readFileSafe(configFile); + if (raw === null) return null; + if (raw.trim() === '') return []; + try { + const parsed: unknown = JSON.parse(raw); + if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) return null; + const { instructions } = parsed as { instructions?: unknown }; + return Array.isArray(instructions) ? instructions : []; + } catch { + return null; + } +} + /** * Where OpenCode is told to load teamai's instruction file: the config file * and its `instructions` entry. In user scope the user config holds the diff --git a/src/resources/rule-format.ts b/src/resources/rule-format.ts index 4fcabda62..754b85726 100644 --- a/src/resources/rule-format.ts +++ b/src/resources/rule-format.ts @@ -50,10 +50,10 @@ export type InstructionBlock = 'culture' | 'claudemd' | 'recall' | 'team-rules'; * Whether pull writes `block` into this tool's instructions file: culture and * shared instructions for every tool that has one, recall for a tool that * also has `agents`, the team rules for a tool with no rules format (the - * Codex family has an instructions file in user scope only). Pull's writers - * and uninstall both ask this, so what uninstall keeps for a remaining tool is - * what that tool's next pull refreshes. Whether the tool is installed is a - * separate question (`instructionFileInstallProbe`). + * Codex family has an instructions file in user scope only). The team-rules + * writer and doctor ask this; culture, shared instructions and recall follow + * the targets in instruction-targets.ts (#945). Whether the tool is installed + * is a separate question (`instructionFileInstallProbe`). */ export function writesInstructionBlock( tool: string, toolPath: ToolPath, block: 'recall', diff --git a/src/resources/rules.ts b/src/resources/rules.ts index 8434f4729..6c75b7eb7 100644 --- a/src/resources/rules.ts +++ b/src/resources/rules.ts @@ -623,6 +623,10 @@ export class RulesHandler extends ResourceHandler { const block = await teamRulesBlock(rules); for (const [tool, toolPath] of Object.entries(scopedToolPaths(teamConfig, localConfig))) { if (!writesInstructionBlock(tool, toolPath, 'team-rules')) continue; + // Its session hook carries the rules in this scope, and a project file + // such as AGENTS.md belongs to the project (#945). + const { deliversInstructionsByHook } = await import('../instruction-targets.js'); + if (deliversInstructionsByHook(tool, localConfig.scope)) continue; const file = path.join(resolveToolBaseDir(tool, localConfig), toolPath.claudemd); const probe = instructionFileInstallProbe(tool, toolPath); const active = !isAgentExcluded(localConfig, tool) diff --git a/src/uninstall.ts b/src/uninstall.ts index a799ddfef..c8b37adc9 100644 --- a/src/uninstall.ts +++ b/src/uninstall.ts @@ -199,13 +199,15 @@ const INSTRUCTION_BLOCK_STARTS: Record = { }; /** - * Start markers of the blocks a pull writes into a tool's `claudemd` file, - * as pull's writers decide it (`writesInstructionBlock`). Nobody writes the - * legacy `[teamai:rules]` block any more. + * Start markers of the blocks a pull writes into a tool's instruction target + * (#945): culture and claudemd always, recall when the tool has the + * `teamai-recall` subagent, and team rules where `writesInstructionBlock` + * says so. Nobody writes the legacy `[teamai:rules]` block any more. */ function instructionBlocksWrittenBy(tool: string, toolPath: TeamaiConfig['toolPaths'][string]): string[] { return (Object.keys(INSTRUCTION_BLOCK_STARTS) as InstructionBlock[]) - .filter((block) => writesInstructionBlock(tool, toolPath, block)) + .filter((block) => block === 'culture' || block === 'claudemd' + || (block === 'recall' ? toolPath.agents !== undefined : writesInstructionBlock(tool, toolPath, block))) .map((block) => INSTRUCTION_BLOCK_STARTS[block]); } @@ -687,8 +689,8 @@ async function buildRemovalPlan( scope: localConfig.scope, }; - // A single instruction file can be the native target for several agents - // (for example project `AGENTS.md` is shared by Pi, Hermes, WorkBuddy, Codex). + // A single instruction file can be the target of several agents (for + // example CodeBuddy and WorkBuddy share `.codebuddy/rules/teamai-context.md`). // Keep a TeamAI block when another enabled, installed agent that maps the // same file would write that block; the rest go, since no remaining agent's // pull would ever refresh or remove them. From 266d88bec39cdc6aebf51ee2b79a71649a99a3c8 Mon Sep 17 00:00:00 2001 From: Saul Moro Date: Thu, 1 Oct 2026 15:50:38 +0200 Subject: [PATCH 17/41] fix(pull): address review round 2 of #945 - Docs: pull cleans only the files earlier releases wrote, not a tool's current file; the Cursor and CodeBuddy notes say what the loader reads instead of claiming live delivery. - init reports instruction channel problems on every path that installs hooks; a silent session-start pull leaves them to doctor; a failed check no longer logs as a skipped hook reconcile; the fix names `teamai hooks inject`. - A dry run promises an OpenCode instructions entry only when it would write the file, and a failed registration points at doctor. - recall disable drops OpenCode's entry once its file goes; local-agent defers to OpenCode's Claude fallback only when Claude's file holds the blocks. - Tests: uninstall keeps the shared .codebuddy rule for a WorkBuddy entry without claudemd; the channel check names a missing Pi extension; recall enable writes no OpenCode copy beside the fallback. --- docs/usage-guide.md | 6 ++-- docs/usage-guide.zh-CN.md | 6 ++-- src/__tests__/e2e/instruction-targets.test.ts | 3 ++ src/__tests__/instruction-targets.test.ts | 32 +++++++++++++++++++ src/__tests__/uninstall.test.ts | 29 +++++++++++++++++ src/init.ts | 15 +++++---- src/instruction-targets.ts | 7 ++-- src/local-agent.ts | 3 +- src/pull.ts | 18 +++++++---- src/recall-toggle.ts | 2 +- 10 files changed, 97 insertions(+), 24 deletions(-) diff --git a/docs/usage-guide.md b/docs/usage-guide.md index 37e91d43d..8d6466654 100644 --- a/docs/usage-guide.md +++ b/docs/usage-guide.md @@ -1642,7 +1642,7 @@ teamai pull The injected content sits between the `` and `` markers, is automatically updated on every `pull`, and does not affect any other content in the file. -A pull writes the culture, shared-instructions and recall blocks only to the files of AI tools that are installed, and leaves a file alone when its blocks are already current. When no installed tool reads a file that an earlier pull wrote these blocks to (for example the project's `AGENTS.md`, which earlier releases wrote for Pi, Hermes and WorkBuddy), the next pull removes the teamai blocks from it and names the file in its output. It deletes the file when nothing else is left, unless git tracks it. A block with a missing or repeated marker is left as it is, with a warning to fix it by hand. `teamai pull --dry-run` lists the files a pull would change without writing them. When recall is disabled, the pull removes the recall block. +A pull writes the culture, shared-instructions and recall blocks only to the files of AI tools that are installed, and leaves a file alone when its blocks are already current. Earlier releases wrote these blocks to files that tools now share or that hide other instructions (listed under [Where the blocks go](#where-the-blocks-go)); while no installed tool reads such a file, the next pull removes the teamai blocks from it and names the file in its output. A tool's current file is never cleaned on its own: `teamai uninstall --agent ` removes those blocks. It deletes the file when nothing else is left, unless git tracks it. A block with a missing or repeated marker is left as it is, with a warning to fix it by hand. `teamai pull --dry-run` lists the files a pull would change without writing them. When recall is disabled, the pull removes the recall block. #### Where the blocks go @@ -1666,9 +1666,9 @@ Two members of the same project can have different roles, so their shared instru Claude Code loads `.claude/rules/teamai-context.md` from the project root and any subdirectory, and still reads the project's `AGENTS.md` or authored `CLAUDE.md` the way it chose to. Copilot CLI 1.0.89 and later also reads a project's `.claude/rules`, so with both tools installed Copilot can get the blocks twice. -Cursor applies both `teamai-context.mdc` files in every session (`alwaysApply: true`). Cursor CLI reads `~/.cursor/rules` when the session starts under your home directory; the Cursor IDE was not checked. +Both `teamai-context.mdc` files carry `alwaysApply: true`, which Cursor's rule loader reads as always applied. Cursor CLI reads `~/.cursor/rules` when the session starts under your home directory; the Cursor IDE was not checked. -The CodeBuddy and WorkBuddy rule files are applied in every session (`alwaysApply: true`). Uninstalling one of the two keeps the shared project copy while the other is still installed. +The CodeBuddy and WorkBuddy rule files carry `alwaysApply: true`, which CodeBuddy's rule parser reads as always applied. Uninstalling one of the two keeps the shared project copy while the other is still installed. In a project, teamai installs the Hermes plugin `$HERMES_HOME/plugins/teamai-instructions/` and adds it to `plugins.enabled` in `$HERMES_HOME/config.yaml` (a name you list under `plugins.disabled` stays off). According to Hermes' documentation it builds the section once for each new session from the session's directory and keeps it through compression and resume. A section holds at most 4,000 characters, and all plugin sections together at most 8,000. When this member's instructions for the project are longer, Hermes skips them and `teamai pull` says so: teamai does not cut them or write them to `AGENTS.md`. Outside a project the section is empty, and Hermes may log that it skipped an empty section. diff --git a/docs/usage-guide.zh-CN.md b/docs/usage-guide.zh-CN.md index 326d74bca..553afb2d6 100644 --- a/docs/usage-guide.zh-CN.md +++ b/docs/usage-guide.zh-CN.md @@ -1520,7 +1520,7 @@ teamai pull 注入的内容位于 `` 和 `` 标记之间,每次 pull 时自动更新,不会影响文件中的其他内容。 -pull 只把团队文化、共享指令和 recall 块写入已安装 AI 工具的文件;块内容未变化时不改写文件。若之前某次 pull 写过这些块的文件已没有任何已安装工具读取(例如早期版本为 Pi、Hermes 和 WorkBuddy 写过的项目 `AGENTS.md`),下一次 pull 会移除其中的 teamai 块,并在输出中列出该文件。文件中没有其他内容时一并删除该文件,但 git 跟踪的文件不会被删除。标记缺失或重复的块保持原样,并提示手动修复。`teamai pull --dry-run` 只列出将要修改的文件,不写入。recall 关闭时,pull 会移除 recall 块。 +pull 只把团队文化、共享指令和 recall 块写入已安装 AI 工具的文件;块内容未变化时不改写文件。早期版本把这些块写进了如今由多个工具共用或会遮蔽其他指令的文件(见[这些块写到哪里](#这些块写到哪里));只要没有已安装的工具读取这类文件,下一次 pull 就会移除其中的 teamai 块,并在输出中列出该文件。工具当前的目标文件不会被单独清理:`teamai uninstall --agent ` 会移除其中的块。文件中没有其他内容时一并删除该文件,但 git 跟踪的文件不会被删除。标记缺失或重复的块保持原样,并提示手动修复。`teamai pull --dry-run` 只列出将要修改的文件,不写入。recall 关闭时,pull 会移除 recall 块。 #### 这些块写到哪里 @@ -1544,9 +1544,9 @@ pull 只把团队文化、共享指令和 recall 块写入已安装 AI 工具的 Claude Code 会从项目根目录和任意子目录加载 `.claude/rules/teamai-context.md`,并照常读取项目的 `AGENTS.md` 或项目自己编写的 `CLAUDE.md`。Copilot CLI 1.0.89 及更高版本也会读取项目的 `.claude/rules`,因此同时安装这两个工具时,Copilot 可能会读到两份。 -Cursor 在每个会话中应用这两个 `teamai-context.mdc` 文件(`alwaysApply: true`)。Cursor CLI 仅在会话从主目录下启动时读取 `~/.cursor/rules`;Cursor IDE 未经验证。 +这两个 `teamai-context.mdc` 文件都带有 `alwaysApply: true`,Cursor 的规则加载器将其视为始终应用。Cursor CLI 仅在会话从主目录下启动时读取 `~/.cursor/rules`;Cursor IDE 未经验证。 -CodeBuddy 和 WorkBuddy 的规则文件在每个会话中应用(`alwaysApply: true`)。卸载其中一个工具时,只要另一个仍已安装,项目中共用的那份文件就会保留。 +CodeBuddy 和 WorkBuddy 的规则文件带有 `alwaysApply: true`,CodeBuddy 的规则解析器将其视为始终应用。卸载其中一个工具时,只要另一个仍已安装,项目中共用的那份文件就会保留。 在项目中,teamai 安装 Hermes 插件 `$HERMES_HOME/plugins/teamai-instructions/`,并把它加入 `$HERMES_HOME/config.yaml` 的 `plugins.enabled`(列在 `plugins.disabled` 中的名称保持关闭)。根据 Hermes 的文档,它在每个新会话开始时根据会话目录生成该段落,并在压缩和恢复后保留。一个段落最多 4,000 个字符,所有插件段落合计最多 8,000 个字符。当该成员在此项目的指令更长时,Hermes 会跳过它们,`teamai pull` 会给出提示:teamai 不会截断它们,也不会写入 `AGENTS.md`。在项目之外段落为空,Hermes 可能记录它跳过了一个空段落。 diff --git a/src/__tests__/e2e/instruction-targets.test.ts b/src/__tests__/e2e/instruction-targets.test.ts index 8a0c1c389..8d9e56678 100644 --- a/src/__tests__/e2e/instruction-targets.test.ts +++ b/src/__tests__/e2e/instruction-targets.test.ts @@ -587,6 +587,9 @@ describe('instruction block targets on real CLI pull (#945)', () => { expect(fs.readFileSync(path.join(fallback.home, '.claude', 'CLAUDE.md'), 'utf8')).toContain(CLAUDEMD_START); expect(fs.existsSync(path.join(fallback.home, '.config', 'opencode', 'teamai-context.md'))).toBe(false); expect(viaClaude.output).toContain('OpenCode reads the team instructions from'); + const recall = await runCLI(['recall', 'enable'], { HOME: fallback.home }, fallback.sandbox); + expect(recall.code, recall.output).toBe(0); + expect(fs.existsSync(path.join(fallback.home, '.config', 'opencode', 'teamai-context.md'))).toBe(false); }); it('has doctor report what keeps a tool from loading its instructions, not just whether a file was written', async () => { diff --git a/src/__tests__/instruction-targets.test.ts b/src/__tests__/instruction-targets.test.ts index 2438546a3..50c24f584 100644 --- a/src/__tests__/instruction-targets.test.ts +++ b/src/__tests__/instruction-targets.test.ts @@ -6,10 +6,14 @@ import path from 'node:path'; import { applyInstructionPlan, clearInstructionFile, + instructionChannelProblems, planInstructionFiles, type InstructionTarget, } from '../instruction-targets.js'; +import { injectPiHooks } from '../pi-hooks.js'; import { + TeamaiConfigSchema, + type LocalConfig, TEAMAI_CLAUDEMD_END, TEAMAI_CLAUDEMD_START, TEAMAI_CULTURE_END, @@ -152,3 +156,31 @@ describe('instruction file planning (#945)', () => { expect(fs.readFileSync(file, 'utf8')).toBe('# Mine\n'); }); }); + +describe('instruction channel problems (#945)', () => { + it('names a missing Pi extension in a project, and nothing once it is installed', async () => { + const root = fs.realpathSync.native(fs.mkdtempSync(path.join(os.tmpdir(), 'teamai-945-channel-'))); + const prevHome = process.env.HOME; + process.env.HOME = path.join(root, 'home'); + try { + const projectRoot = path.join(root, 'project'); + const repo = path.join(root, 'repo'); + fs.mkdirSync(path.join(projectRoot, '.pi', 'skills'), { recursive: true }); + fs.mkdirSync(path.join(repo, 'claudemd'), { recursive: true }); + fs.writeFileSync(path.join(repo, 'claudemd', 'shared.md'), 'Shared.\n'); + const teamConfig = TeamaiConfigSchema.parse({ team: 't', repo: 'https://example.invalid/t.git' }); + const localConfig = { + repo: { localPath: repo, remote: 'https://example.invalid/t.git' }, + username: 'u', additionalRoles: [], scope: 'project', projectRoot, enabledAgents: ['pi'], + } as unknown as LocalConfig; + + expect((await instructionChannelProblems(teamConfig, localConfig)).join('\n')).toMatch(/teamai-hooks\.ts is missing or out of date, so pi sessions/); + + await injectPiHooks(); + expect(await instructionChannelProblems(teamConfig, localConfig)).toEqual([]); + } finally { + process.env.HOME = prevHome; + fs.rmSync(root, { recursive: true, force: true }); + } + }); +}); diff --git a/src/__tests__/uninstall.test.ts b/src/__tests__/uninstall.test.ts index e24559a79..d43979ebe 100644 --- a/src/__tests__/uninstall.test.ts +++ b/src/__tests__/uninstall.test.ts @@ -616,6 +616,35 @@ describe('uninstall', () => { expect(await fse.readFile(sharedInstructions, 'utf8')).toContain(TEAMAI_CULTURE_START); }); + it('targeted CodeBuddy uninstall keeps the shared .codebuddy rule for a WorkBuddy entry without claudemd (#945)', async () => { + const { homeDir, repoPath } = await setupFixture(tmpDir); + const projectRoot = path.join(tmpDir, 'business-repo'); + vi.stubEnv('HOME', homeDir); + vi.stubEnv('SHELL', '/bin/zsh'); + const sharedInstructions = path.join(projectRoot, '.codebuddy', 'rules', 'teamai-context.md'); + await fse.ensureDir(path.join(projectRoot, '.codebuddy', 'skills')); + await fse.ensureDir(path.join(projectRoot, '.workbuddy', 'skills')); + await fse.ensureDir(path.dirname(sharedInstructions)); + await fse.writeFile(sharedInstructions, `---\nalwaysApply: true\n---\n\n${TEAMAI_CULTURE_START}\nculture\n${TEAMAI_CULTURE_END}\n`); + const teamConfig = makeTeamConfig({ + toolPaths: { + codebuddy: { skills: '.codebuddy/skills', rules: '.codebuddy/rules', claudemd: '.codebuddy/CODEBUDDY.md' }, + workbuddy: { skills: '.workbuddy/skills', rules: '.workbuddy/rules' }, + }, + }); + const localConfig = makeLocalConfig(homeDir, repoPath, { + scope: 'project', + projectRoot, + enabledAgents: ['codebuddy', 'workbuddy'], + repo: { localPath: repoPath, remote: '', kind: 'self', businessRepoRoot: projectRoot }, + }); + mockAutoDetectInit.mockResolvedValue({ localConfig, teamConfig }); + + await uninstall({ force: true, agent: 'codebuddy' }); + + expect(await fse.readFile(sharedInstructions, 'utf8')).toContain(TEAMAI_CULTURE_START); + }); + it('targeted Pi uninstall removes shared AGENTS.md when the other tool sharing it was never installed', async () => { const { homeDir, repoPath } = await setupFixture(tmpDir); const projectRoot = path.join(tmpDir, 'business-repo'); diff --git a/src/init.ts b/src/init.ts index c29258ca6..807066afd 100644 --- a/src/init.ts +++ b/src/init.ts @@ -790,6 +790,14 @@ async function reconcileHooksForInit( ): Promise { const reconciled = await reconcileTeamHooksForConfig(teamConfig, localConfig, { filterAgents }); if (!reconciled.ok) log.warn(describeUnappliedTeamHooks(reconciled)); + // The hooks install the extensions and plugins that add team instructions + // for Pi, OMP and Hermes; name what keeps a tool from getting them (#945). + try { + const { instructionChannelProblems } = await import('./instruction-targets.js'); + for (const problem of await instructionChannelProblems(teamConfig, localConfig)) log.warn(problem); + } catch (e) { + log.debug(`Team instruction check skipped: ${(e as Error).message}`); + } } /** @@ -2081,13 +2089,6 @@ export async function init(options: GlobalOptions & { } await reconcileHooksForInit(reloadedTeamConfig, localConfig, filterAgents); - // Name what keeps a tool from getting the team instructions (#945). - try { - const { instructionChannelProblems } = await import('./instruction-targets.js'); - for (const problem of await instructionChannelProblems(reloadedTeamConfig, localConfig)) log.warn(problem); - } catch (e) { - log.debug(`Team instruction check skipped: ${(e as Error).message}`); - } // Step 7.5: Deploy the built-in discovery stub immediately so the teamai // skill is available in the IDE right after init, without waiting for the diff --git a/src/instruction-targets.ts b/src/instruction-targets.ts index d73c39d95..3c5764e4e 100644 --- a/src/instruction-targets.ts +++ b/src/instruction-targets.ts @@ -180,7 +180,7 @@ export interface InstructionTargets { /** Targets of installed, non-excluded tools, one per file. */ targets: InstructionTarget[]; hooks: InstructionHook[]; - /** Known targets no installed tool reads: a pull strips teamai blocks from them. */ + /** Files earlier releases wrote blocks to that no installed tool reads now: a pull strips teamai blocks from them. */ stale: InstructionTarget[]; /** Claude's user file when OpenCode reads the blocks from it, so OpenCode gets no file of its own. */ opencodeFallback?: string | null; @@ -248,7 +248,7 @@ export function hookLimitProblem(hook: InstructionHook, text: string): string | * installed as this build writes it, and if not, what to do. */ export async function instructionHookChannel(tool: string): Promise<{ ready: boolean; fix: string }> { - const rerun = 'Run `teamai pull` to reinstall it; `teamai hooks remove` takes it away.'; + const rerun = 'Run `teamai hooks inject` to reinstall it; `teamai hooks remove` takes it away.'; if (tool === 'omp' || tool === 'pi') { const { buildOmpExtensionSource, resolveOmpExtensionsDir, OMP_HOOK_FILE } = await import('./omp-hooks.js'); const { buildPiExtensionSource, resolvePiExtensionsDir, PI_HOOK_FILE } = await import('./pi-hooks.js'); @@ -404,13 +404,14 @@ export async function registerOpencodeContext( localConfig: LocalConfig, resolved: Pick, dryRun: boolean, + planned: readonly string[] = [], ): Promise { const paths = scopedToolPaths(teamConfig, localConfig).opencode; const contextFile = paths && instructionTargetPath('opencode', paths, localConfig); if (!contextFile) return null; const wanted = resolved.targets.some((target) => target.path === contextFile); if (!wanted && !resolved.stale.some((target) => target.path === contextFile)) return null; - const present = wanted && (dryRun || await pathExists(contextFile)); + const present = wanted && (await pathExists(contextFile) || (dryRun && planned.includes(contextFile))); const { config, entry } = opencodeContextReference(contextFile, localConfig.scope, resolveToolBaseDir('opencode', localConfig)); if (dryRun) { const listed = (await readOpencodeInstructionList(config))?.includes(entry) ?? false; diff --git a/src/local-agent.ts b/src/local-agent.ts index 03daa3504..8bca2ad1b 100644 --- a/src/local-agent.ts +++ b/src/local-agent.ts @@ -2107,7 +2107,8 @@ async function syncClaudemd( const claudeMdPath = resolvedAbsPath ?? path.resolve(baseDir, targetFile); // OpenCode's Claude fallback already carries the blocks, as in pull (#945). const claudeUserFile = path.join(getUserHome(), '.claude', 'CLAUDE.md'); - if (tool === 'opencode' && localConfig.scope === 'user' && await pathExists(claudeUserFile) + if (tool === 'opencode' && localConfig.scope === 'user' + && (await readFileSafe(claudeUserFile))?.includes(TEAMAI_CLAUDEMD_START) && await opencodeClaudeFallback(getUserHome(), [claudeUserFile])) { log.debug(`local-agent: OpenCode reads the team instructions from ${claudeUserFile}; skipped`); continue; diff --git a/src/pull.ts b/src/pull.ts index 27ade5f98..438775b91 100644 --- a/src/pull.ts +++ b/src/pull.ts @@ -1903,11 +1903,12 @@ async function syncManagedInstructions( } for (const failure of failures) log.warn(`[${scopeLabel}] ${failure}`); try { - const registered = await registerOpencodeContext(config, localConfig, resolved, dryRun); + const planned = plan.changes.filter((change) => change.content !== null).map((change) => change.path); + const registered = await registerOpencodeContext(config, localConfig, resolved, dryRun, planned); if (registered && dryRun) log.info(`[dry-run] ${registered}`); else if (registered) log.debug(registered); } catch (e) { - log.warn(`[${scopeLabel}] Failed to update OpenCode's instructions: ${(e as Error).message}. Add teamai-context.md to its "instructions" by hand so OpenCode loads the team instructions.`); + log.warn(`[${scopeLabel}] Failed to list the team instructions in OpenCode's config: ${(e as Error).message}. Run \`teamai doctor\`, which names the config file and the entry to add by hand.`); } // Hook targets (Pi, OMP, Hermes, Codex) are reported after the hooks are // reconciled, since that is what installs their extensions and plugins. @@ -2499,10 +2500,15 @@ async function reconcileHooksAllScopes( log.debug(`[${localConfig.scope}] ${options.dryRun ? 'Would apply' : 'Reconciled'} ${reconciled.defs.length} team hook(s)`); } // The hooks install the extensions and plugins that add team - // instructions for Pi, OMP and Hermes (#945); say which cannot. - if (!options.dryRun) { - const { instructionChannelProblems } = await import('./instruction-targets.js'); - for (const problem of await instructionChannelProblems(teamConfig, localConfig)) log.warn(`[${localConfig.scope}] ${problem}`); + // instructions for Pi, OMP and Hermes (#945); say which cannot. The + // session-start pull is silent, and doctor reports the same. + if (!options.dryRun && !options.silent) { + try { + const { instructionChannelProblems } = await import('./instruction-targets.js'); + for (const problem of await instructionChannelProblems(teamConfig, localConfig)) log.warn(`[${localConfig.scope}] ${problem}`); + } catch (e) { + log.debug(`[${localConfig.scope}] Team instruction check skipped: ${(e as Error).message}`); + } } } catch (e) { log.debug(`[${localConfig.scope}] Hook reconcile skipped: ${(e as Error).message}`); diff --git a/src/recall-toggle.ts b/src/recall-toggle.ts index c0c9e5833..3bc9811a9 100644 --- a/src/recall-toggle.ts +++ b/src/recall-toggle.ts @@ -80,7 +80,7 @@ async function writeRecallBlock(teamConfig: TeamaiConfig, localConfig: LocalConf const { report, failures } = await applyInstructionPlan(plan, { dryRun: false }); for (const line of report) log.debug(line); for (const failure of failures) log.warn(failure); - if (block !== null) await registerOpencodeContext(teamConfig, localConfig, resolved, false); + await registerOpencodeContext(teamConfig, localConfig, resolved, false); } async function deployRecallArtifacts(teamConfig: TeamaiConfig, localConfig: LocalConfig): Promise { From 6ac8fcb1db14b4e33d3d0db1edc3757483ba76b3 Mon Sep 17 00:00:00 2001 From: Saul Moro Date: Thu, 1 Oct 2026 16:44:57 +0200 Subject: [PATCH 18/41] feat(recall): tell tools without the recall subagent to run teamai recall (#945) Pi, Hermes and OpenClaw have no teamai-recall subagent, so they got no recall block. They now get one that tells the agent to run `teamai recall ""` itself before code, debugging or design work, with the subagent block's skip conditions. It uses the same markers, so recall disable, uninstall, cleanup and doctor treat both blocks alike; each target and hook gets the one that matches its tool. Verified with Pi 0.99.2 against a local capture server: the block reaches each request once from the project root and a subdirectory. Pi's README row now shows learnings, codebase and teamwiki, the same criterion Hermes, OpenClaw and DeepSeek Harness already meet. --- CHANGELOG.md | 1 + README.ja.md | 2 +- README.ko.md | 2 +- README.md | 2 +- README.th.md | 2 +- README.zh-CN.md | 2 +- docs/usage-guide.md | 4 +- docs/usage-guide.zh-CN.md | 4 +- src/__tests__/e2e/instruction-targets.test.ts | 4 ++ src/__tests__/instruction-targets.test.ts | 11 ++++ src/__tests__/recall-rules.test.ts | 14 +++++- src/instruction-targets.ts | 13 +++-- src/pull.ts | 50 +++++++++++++++++-- src/recall-toggle.ts | 16 +++--- 14 files changed, 104 insertions(+), 23 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 07e7c675e..0097dabdb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ All notable changes to this project will be documented in this file. See [standa ### ✨ Features +- A tool without the `teamai-recall` subagent (Pi, Hermes, OpenClaw) now gets a recall block too, which tells the agent to run `teamai recall ""` itself before code, debugging or design work, with the same skip conditions as the subagent block. It shares the subagent block's markers, so `recall disable`, uninstall and doctor treat both alike. Pi's README row now shows ✓ for learnings, codebase and teamwiki (for [#945](https://github.com/Tencent/teamai-cli/issues/945)). - A YAML agent can set `model: strong`, `model: fast`, or an alias the team defines in an optional `models/aliases.yaml`, which maps each alias per tool to that tool's own model value and an optional effort. `teamai pull` writes the mapped model into every tool's agent file, and the effort into that tool's own field: `effort` for Claude, claude-internal, tclaude, CodeBuddy, Qoder and Qoder CN, `model_reasoning_effort` for Codex, codex-internal and tcodex, `variant` for OpenCode. Cursor receives its model string as written, so its bracket form (`claude-opus-5[effort=high]`) carries the effort; Copilot receives the first entry as one model string. Copilot, Cursor, Kiro, WorkBuddy, JoyCode, ZCode and OMP get no effort field: an effort mapped for them is dropped, and pull warns once, naming the alias and the tool. The `-internal` and `t` variants use the `claude` or `codex` entry and Qoder CN the `qoder` entry unless the alias has their own key; no other tool inherits one. A tool the alias does not map, or any tool when the team has no aliases file, gets no `model` field and runs on its default, never a literal `strong`. `tool_extras..model` skips the alias for that tool, and an extras effort alone overrides the alias's effort. A `model` that is not a string now makes the agent fail to parse; a legacy `.md` agent whose `model` is an alias is copied as is with a warning. While `models/aliases.yaml` cannot be read, pull holds every agent that sets a `model`, since the file may define any name, and push skips them. Pull records the model each agent copy received, so an ordinary pull applies a changed resolution even when the team repo has not moved, which fixes an agent an older CLI wrote with `model: strong` as is, and delivers a copy that is missing; a copy the member edited is kept, and that pull names it with how to take the new model. Pull warns when an alias an agent resolved through is removed, also where the alias gave a tool no model field (for [#830](https://github.com/Tencent/teamai-cli/issues/830)). - A member overrides a team model alias on their own machine in `~/.teamai/models/aliases.yaml`, in the same `aliases:` shape. An entry replaces the team's whole entry for that tool, effort included, and `~` or `default` gives the tool no model field and no effort; only `tool_extras..model` wins over it. Keys are `strong`, `fast` or an alias the team defines, so a member can map `strong` before the team has an aliases file, and any other name has no effect. The file applies in every scope and to every team using that alias name, and an ordinary pull applies an edit to it. While it cannot be read, pull holds the agents whose `model` is an alias, naming the file, and push skips them; since the file can make no name an alias, agents with a concrete model are delivered and pushed as usual. In the team file, `default` is written as a model value and `~` is an error. When pull keeps an edited copy whose deployed version changed, it now says `the version teamai would deploy there ... has changed since` instead of blaming the team, since the change may be the member's override (for [#830](https://github.com/Tencent/teamai-cli/issues/830)). - On a tool switched with `teamai models switch`, an agent whose `model` is an alias no longer sends an account model to the gateway: Claude keeps a resolved `opus`, `sonnet` or `haiku`, which the switch routes to gateway models, and drops any other model; Codex, OpenCode, CodeBuddy and WorkBuddy get no `model` field, so they use their native inheritance (for Codex, `[agents].default_subagent_model` or the parent session's model), not the profile's model. No switched tool gets an effort, the alias's or one set in `tool_extras.`, unless `tool_extras.` also pins a model. `tool_extras..model`, a concrete `model` and a member's `~`/`default` are unchanged, and claude-internal, tclaude, codex-internal and tcodex are never treated as switched. A tool counts as switched only while its live settings path matches the recorded switch and still holds what TeamAI wrote, the checks `models restore` makes, so a stale record for another `CODEX_HOME` or `CLAUDE_CONFIG_DIR` has no effect. An ordinary pull after `models switch` or `models restore` rewrites the affected agents. While the switch records or a switched tool's settings cannot be read, pull holds alias agents in the tools concerned and says why (for [#830](https://github.com/Tencent/teamai-cli/issues/830)). diff --git a/README.ja.md b/README.ja.md index 8ea08a2cb..6f5e30863 100644 --- a/README.ja.md +++ b/README.ja.md @@ -129,7 +129,7 @@ Git を基盤に、3 層の能力を構築します: CodeBuddy✓✓✓✓✓✓✓✓✓✓✓✓✓✓ WorkBuddy✓✓✓✓—✓✓✓✓✓✓✓✓✓ OpenCode✓✓✓✓✓✓✓✓✓✓✓——— - Pi Coding Agent✓✓✓——✓✓✓—————— + Pi Coding Agent✓✓✓——✓✓✓✓✓✓——— OpenClaw✓✓✓✓————✓✓✓——— Hermes✓—✓✓————✓✓✓——— DeepSeek Harness✓—✓—————✓✓✓——— diff --git a/README.ko.md b/README.ko.md index f6c95032f..adffb37a3 100644 --- a/README.ko.md +++ b/README.ko.md @@ -129,7 +129,7 @@ Git을 기반으로 세 층의 역량을 구축합니다: CodeBuddy✓✓✓✓✓✓✓✓✓✓✓✓✓✓ WorkBuddy✓✓✓✓—✓✓✓✓✓✓✓✓✓ OpenCode✓✓✓✓✓✓✓✓✓✓✓——— - Pi Coding Agent✓✓✓——✓✓✓—————— + Pi Coding Agent✓✓✓——✓✓✓✓✓✓——— OpenClaw✓✓✓✓————✓✓✓——— Hermes✓—✓✓————✓✓✓——— DeepSeek Harness✓—✓—————✓✓✓——— diff --git a/README.md b/README.md index a0eba87a2..343a45c73 100644 --- a/README.md +++ b/README.md @@ -129,7 +129,7 @@ Three layers of capability, built on Git: CodeBuddy✓✓✓✓✓✓✓✓✓✓✓✓✓✓ WorkBuddy✓✓✓✓—✓✓✓✓✓✓✓✓✓ OpenCode✓✓✓✓✓✓✓✓✓✓✓——— - Pi Coding Agent✓✓✓——✓✓✓—————— + Pi Coding Agent✓✓✓——✓✓✓✓✓✓——— OpenClaw✓✓✓✓————✓✓✓——— Hermes✓—✓✓————✓✓✓——— DeepSeek Harness✓—✓—————✓✓✓——— diff --git a/README.th.md b/README.th.md index 72c95840b..05838c6f8 100644 --- a/README.th.md +++ b/README.th.md @@ -129,7 +129,7 @@ teamai init https://github.com/your-org/your-repo --scope user CodeBuddy✓✓✓✓✓✓✓✓✓✓✓✓✓✓ WorkBuddy✓✓✓✓—✓✓✓✓✓✓✓✓✓ OpenCode✓✓✓✓✓✓✓✓✓✓✓——— - Pi Coding Agent✓✓✓——✓✓✓—————— + Pi Coding Agent✓✓✓——✓✓✓✓✓✓——— OpenClaw✓✓✓✓————✓✓✓——— Hermes✓—✓✓————✓✓✓——— DeepSeek Harness✓—✓—————✓✓✓——— diff --git a/README.zh-CN.md b/README.zh-CN.md index 12a005bb0..2af3f6d25 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -135,7 +135,7 @@ teamai init https://github.com/your-org/your-repo --scope user CodeBuddy✓✓✓✓✓✓✓✓✓✓✓✓✓✓ WorkBuddy✓✓✓✓—✓✓✓✓✓✓✓✓✓ OpenCode✓✓✓✓✓✓✓✓✓✓✓——— - Pi Coding Agent✓✓✓——✓✓✓—————— + Pi Coding Agent✓✓✓——✓✓✓✓✓✓——— OpenClaw✓✓✓✓————✓✓✓——— Hermes✓—✓✓————✓✓✓——— DeepSeek Harness✓—✓—————✓✓✓——— diff --git a/docs/usage-guide.md b/docs/usage-guide.md index 8d6466654..9cc6dd175 100644 --- a/docs/usage-guide.md +++ b/docs/usage-guide.md @@ -1659,10 +1659,10 @@ Two members of the same project can have different roles, so their shared instru | WorkBuddy | `~/.workbuddy/rules/teamai-context.md` (unverified) | `.codebuddy/rules/teamai-context.md`, one copy shared with CodeBuddy (unverified) | | 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 (unverified) | +| Pi | `~/.pi/agent/AGENTS.md` | Added to each run's system prompt by teamai's Pi extension | | Hermes | A block in `$HERMES_HOME/SOUL.md`, beside the team rules block (unverified) | A system prompt section from teamai's Hermes plugin (unverified) | -*Unverified*: built from the tool's documented or source-read loader, not yet checked in a live session. Claude Code, Oh My Pi and OpenCode were checked in live sessions, from the project root and a subdirectory. The recall block goes only to tools with the `teamai-recall` subagent, so Pi and Hermes get culture and shared instructions without it. +*Unverified*: built from the tool's documented or source-read loader, not yet checked in a live session. Claude Code, Oh My Pi, OpenCode and Pi (project scope) were checked in live sessions, from the project root and a subdirectory. A tool with the `teamai-recall` subagent gets a recall block that calls it; a tool without one (Pi, Hermes, OpenClaw) gets a recall block that tells the agent to run `teamai recall` directly. Claude Code loads `.claude/rules/teamai-context.md` from the project root and any subdirectory, and still reads the project's `AGENTS.md` or authored `CLAUDE.md` the way it chose to. Copilot CLI 1.0.89 and later also reads a project's `.claude/rules`, so with both tools installed Copilot can get the blocks twice. diff --git a/docs/usage-guide.zh-CN.md b/docs/usage-guide.zh-CN.md index 553afb2d6..36323f1bf 100644 --- a/docs/usage-guide.zh-CN.md +++ b/docs/usage-guide.zh-CN.md @@ -1537,10 +1537,10 @@ pull 只把团队文化、共享指令和 recall 块写入已安装 AI 工具的 | WorkBuddy | `~/.workbuddy/rules/teamai-context.md`(未验证) | `.codebuddy/rules/teamai-context.md`,与 CodeBuddy 共用一份(未验证) | | 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 扩展加入每次运行的系统提示(未验证) | +| Pi | `~/.pi/agent/AGENTS.md` | 由 teamai 的 Pi 扩展加入每次运行的系统提示 | | Hermes | `$HERMES_HOME/SOUL.md` 中的一个块,位于团队规则块旁(未验证) | teamai 的 Hermes 插件提供的系统提示段落(未验证) | -*未验证*:依据工具的文档或源码中的加载逻辑实现,尚未在实际会话中检查。Claude Code、Oh My Pi 和 OpenCode 已在实际会话中从项目根目录和子目录检查过。recall 块只发给有 `teamai-recall` subagent 的工具,因此 Pi 和 Hermes 获得文化和共享指令,但没有 recall 块。 +*未验证*:依据工具的文档或源码中的加载逻辑实现,尚未在实际会话中检查。Claude Code、Oh My Pi、OpenCode 和 Pi(项目范围)已在实际会话中从项目根目录和子目录检查过。有 `teamai-recall` subagent 的工具获得调用该 subagent 的 recall 块;没有的工具(Pi、Hermes、OpenClaw)获得提示 agent 直接运行 `teamai recall` 的 recall 块。 Claude Code 会从项目根目录和任意子目录加载 `.claude/rules/teamai-context.md`,并照常读取项目的 `AGENTS.md` 或项目自己编写的 `CLAUDE.md`。Copilot CLI 1.0.89 及更高版本也会读取项目的 `.claude/rules`,因此同时安装这两个工具时,Copilot 可能会读到两份。 diff --git a/src/__tests__/e2e/instruction-targets.test.ts b/src/__tests__/e2e/instruction-targets.test.ts index 8d9e56678..0d9db017c 100644 --- a/src/__tests__/e2e/instruction-targets.test.ts +++ b/src/__tests__/e2e/instruction-targets.test.ts @@ -500,6 +500,9 @@ describe('instruction block targets on real CLI pull (#945)', () => { expect(context).toContain('PRODUCT-SENTINEL'); expect(context).not.toContain('DEVELOPMENT-SENTINEL'); expect(context).toContain('Acme'); + // Pi has no recall subagent: it is told to run the command itself. + expect(context).toContain('teamai recall "'); + expect(context).not.toContain('teamai-recall'); }); it('gives Hermes its project blocks through its plugin and frees the project AGENTS.md', async () => { @@ -525,6 +528,7 @@ describe('instruction block targets on real CLI pull (#945)', () => { expect(context).toContain('DEVELOPMENT-SENTINEL'); expect(context).not.toContain('PRODUCT-SENTINEL'); expect(context).not.toContain('teamai-recall'); + expect(context).toContain('teamai recall "'); }); it('says Hermes cannot load project instructions over its 4,000-character section, without cutting them or using AGENTS.md', async () => { diff --git a/src/__tests__/instruction-targets.test.ts b/src/__tests__/instruction-targets.test.ts index 50c24f584..6eb1fe965 100644 --- a/src/__tests__/instruction-targets.test.ts +++ b/src/__tests__/instruction-targets.test.ts @@ -41,6 +41,17 @@ describe('instruction file planning (#945)', () => { ...extra, }); + it('gives the recall block to a target whose tool has the subagent, and the direct variant otherwise', async () => { + const recall = '\nuse the subagent\n'; + const direct = '\nrun teamai recall\n'; + const plan = await planInstructionFiles( + [target('a.md', { recall: true }), target('b.md', { recall: false })], + { recall, directRecall: direct }, + ); + + expect(plan.changes.map((c) => c.content)).toEqual([`${recall}\n`, `${direct}\n`]); + }); + it('creates a missing target with the blocks only', async () => { const plan = await planInstructionFiles([target('CLAUDE.local.md')], { culture: culture('c'), claudemd: claudemd('s') }); await applyInstructionPlan(plan, { dryRun: false }); diff --git a/src/__tests__/recall-rules.test.ts b/src/__tests__/recall-rules.test.ts index 3d9c30eb2..ab8754a75 100644 --- a/src/__tests__/recall-rules.test.ts +++ b/src/__tests__/recall-rules.test.ts @@ -19,7 +19,7 @@ vi.mock('../utils/logger.js', () => ({ })), })); -import { compileRecallRulesBlock } from '../pull.js'; +import { compileDirectRecallRulesBlock, compileRecallRulesBlock } from '../pull.js'; import { injectClaudeMdSection } from '../utils/claudemd.js'; import { TEAMAI_RECALL_RULES_START, TEAMAI_RECALL_RULES_END } from '../types.js'; @@ -38,6 +38,18 @@ describe('compileRecallRulesBlock', () => { }); }); +describe('compileDirectRecallRulesBlock (#945)', () => { + it('tells a tool without the recall subagent to run teamai recall itself, inside the same markers', () => { + const block = compileDirectRecallRulesBlock(); + expect(block.startsWith(TEAMAI_RECALL_RULES_START)).toBe(true); + expect(block.endsWith(TEAMAI_RECALL_RULES_END)).toBe(true); + expect(block).toContain('teamai recall "'); + expect(block).toMatch(/Before/); + expect(block).not.toContain('teamai-recall'); + expect(block).not.toMatch(/[\u4e00-\u9fff]/); + }); +}); + describe('injectClaudeMdSection — recall rules block lifecycle', () => { let tmpDir: string; let claudeMdPath: string; diff --git a/src/instruction-targets.ts b/src/instruction-targets.ts index 3c5764e4e..c914f52d3 100644 --- a/src/instruction-targets.ts +++ b/src/instruction-targets.ts @@ -162,7 +162,7 @@ export interface InstructionTarget { /** Absolute path. */ path: string; tools: string[]; - /** Whether a tool reading this file has the `teamai-recall` subagent, so the recall block belongs here. */ + /** Whether a tool reading this file has the `teamai-recall` subagent, which decides the recall block it gets. */ recall: boolean; header?: string; owned?: boolean; @@ -193,7 +193,10 @@ export interface InstructionTargets { export interface InstructionBlocks { culture?: string | null; claudemd?: string | null; + /** For a tool with the `teamai-recall` subagent. */ recall?: string | null; + /** For a tool without it: the agent runs `teamai recall` itself. Same markers. */ + directRecall?: string | null; } /** @@ -217,10 +220,11 @@ export function deliversInstructionsByHook(tool: string, scope: Scope): boolean /** * The text a session hook adds to the prompt: the same blocks a file target - * holds, recall included only for a tool with the `teamai-recall` subagent. + * holds, with the recall block that matches whether the tool has the + * `teamai-recall` subagent. */ export function instructionHookText(blocks: InstructionBlocks, recall: boolean): string { - return [blocks.culture, blocks.claudemd, recall ? blocks.recall : null] + return [blocks.culture, blocks.claudemd, recall ? blocks.recall : blocks.directRecall] .filter((block): block is string => typeof block === 'string') .map(managedBlockBody) .filter((body) => body !== '') @@ -568,7 +572,8 @@ export async function planInstructionFiles( const edits: Array = []; if (blocks.culture !== undefined) edits.push([CULTURE, blocks.culture]); if (blocks.claudemd !== undefined) edits.push([CLAUDEMD, blocks.claudemd]); - if (blocks.recall !== undefined) edits.push([RECALL, target.recall ? blocks.recall : null]); + const recall = target.recall ? blocks.recall : blocks.directRecall; + if (recall !== undefined) edits.push([RECALL, recall]); const change = await planFile(target, edits, 'write', warnings); if (change) changes.push(change); } diff --git a/src/pull.ts b/src/pull.ts index 438775b91..e7fc5f99a 100644 --- a/src/pull.ts +++ b/src/pull.ts @@ -1844,8 +1844,10 @@ export async function resolveInstructionBlocks( roleContext: RolePullContext | null, ): Promise<{ blocks: InstructionBlocks; claudemdFiles: number }> { const culturePath = path.join(localConfig.repo.localPath, 'culture.md'); + const recallEnabled = isRecallEnabled(localConfig, config); const blocks: InstructionBlocks = { - recall: isRecallEnabled(localConfig, config) ? compileRecallRulesBlock() : null, + recall: recallEnabled ? compileRecallRulesBlock() : null, + directRecall: recallEnabled ? compileDirectRecallRulesBlock() : null, }; try { const cultureContent = await readFile(culturePath, 'utf8'); @@ -1876,8 +1878,8 @@ export async function resolveInstructionBlocks( * tool's target, and strip them from files no installed tool loads them from * (#945). Runs on the "Already synced" fast path too, so a CLI upgrade that * moves a target or ships a new recall block takes effect without a repo - * change. The recall block goes only to targets whose tool has the - * `teamai-recall` subagent, since it tells the agent to call that subagent. + * change. A target whose tool has the `teamai-recall` subagent gets the block + * that calls it; any other gets the one that runs `teamai recall` directly. * A dry run reports the files it would change. */ async function syncManagedInstructions( @@ -1917,6 +1919,48 @@ async function syncManagedInstructions( if (blocks.claudemd) log.success(`[${scopeLabel}] Synced shared instructions (${claudemdFiles} file(s))`); } +/** + * The recall block for a tool without the `teamai-recall` subagent (#945): + * the agent runs `teamai recall` itself. Same markers as + * compileRecallRulesBlock, so every reader and remover treats both alike. + */ +export function compileDirectRecallRulesBlock(): string { + return [ + TEAMAI_RECALL_RULES_START, + '', + '', + '## Team Knowledge Recall (teamai)', + '', + '**Before** starting a task that involves code changes, debugging,', + 'or design decisions, you **SHOULD** search the team knowledge base', + '(learnings, docs, skills, rules and the codebase wiki) by running:', + '', + '```bash', + 'teamai recall "<3-6 high-signal keywords from the task>"', + '```', + '', + 'unless one of these skip conditions applies:', + '', + '1. **User already provided context** — the user referenced specific files,', + ' gave a solution, or said "the answer is in this directory/file".', + '2. **Local files have the answer** — the task info is directly available', + ' from the current workspace (e.g. fixing an obvious bug in the current file).', + '3. **Trivial/local change** — small modifications to known files (typo fix,', + ' parameter tweak, formatting) that need no additional knowledge.', + '4. **Task domain is outside team knowledge coverage** — the task is', + ' unrelated to this team\'s systems/workflows. `teamai recall --check ""`', + ' answers `RELEVANT` or `NOT_RELEVANT` without reading anything.', + '', + 'Matching is lexical: give each domain term in every language the team', + 'writes in, and keep names, identifiers, error codes and paths as they are.', + 'Read the files recall returns for their full content. If its output contains', + '`Nothing was searched:`, show that line to the user instead of concluding the', + 'team has no knowledge on the topic.', + '', + TEAMAI_RECALL_RULES_END, + ].join('\n'); +} + /** * Build the CLAUDE.md block that instructs the main conversation to: * 1. Invoke the `teamai-recall` subagent before starting any task that diff --git a/src/recall-toggle.ts b/src/recall-toggle.ts index 3bc9811a9..8b94cb280 100644 --- a/src/recall-toggle.ts +++ b/src/recall-toggle.ts @@ -68,14 +68,18 @@ async function removeRecallArtifacts(teamConfig: TeamaiConfig, localConfig: Loca await writeRecallBlock(teamConfig, localConfig, null); } -/** Set (`block`) or remove (`null`) the recall block wherever teamai delivers instruction blocks. */ -async function writeRecallBlock(teamConfig: TeamaiConfig, localConfig: LocalConfig, block: string | null): Promise { +/** Set or remove (`null`) the recall blocks wherever teamai delivers instruction blocks. */ +async function writeRecallBlock( + teamConfig: TeamaiConfig, + localConfig: LocalConfig, + blocks: { recall: string; directRecall: string } | null, +): Promise { const resolved = await resolveInstructionTargets(teamConfig, localConfig); const { targets, stale } = resolved; // Removal also reaches files no installed tool reads any more, but leaves // their other blocks to the next pull's cleanup. - const files = block === null ? [...targets, ...stale] : targets; - const plan = await planInstructionFiles(files, { recall: block }); + const files = blocks === null ? [...targets, ...stale] : targets; + const plan = await planInstructionFiles(files, blocks ?? { recall: null, directRecall: null }); for (const warning of plan.warnings) log.warn(warning); const { report, failures } = await applyInstructionPlan(plan, { dryRun: false }); for (const line of report) log.debug(line); @@ -92,8 +96,8 @@ async function deployRecallArtifacts(teamConfig: TeamaiConfig, localConfig: Loca await deployBuiltinAgents(teamConfig, localConfig, { skipRecall: false }); await deployBuiltinSkills(teamConfig, localConfig); - const { compileRecallRulesBlock } = await import('./pull.js'); - await writeRecallBlock(teamConfig, localConfig, compileRecallRulesBlock()); + const { compileDirectRecallRulesBlock, compileRecallRulesBlock } = await import('./pull.js'); + await writeRecallBlock(teamConfig, localConfig, { recall: compileRecallRulesBlock(), directRecall: compileDirectRecallRulesBlock() }); } export async function recallDisable(opts: GlobalOptions): Promise { From affe1260f034635b7dbca06cfc61f1a1640f5ef4 Mon Sep 17 00:00:00 2001 From: Saul Moro Date: Thu, 1 Oct 2026 16:56:54 +0200 Subject: [PATCH 19/41] fix(pull): address review round 3 of #945 - OMP counts as installed for its team instructions only where ~/.omp exists, which is where teamai installs its extension: a member without OMP in a project that has .omp/ no longer gets a warning on every pull and a failing doctor check that hooks inject cannot fix. - The Hermes over-limit message names the recall block and `teamai recall disable`, since the recall block now counts too. - Uninstall keeps the recall block for every remaining tool on a shared file, as pull now writes one to every tool. - Tests: recall disable removes the direct block from Pi's hook text; a project with .omp/ and no ~/.omp reports no OMP problem. --- src/__tests__/e2e/instruction-targets.test.ts | 15 ++++++++++++++- src/instruction-targets.ts | 6 +++++- src/uninstall.ts | 10 +++++----- 3 files changed, 24 insertions(+), 7 deletions(-) diff --git a/src/__tests__/e2e/instruction-targets.test.ts b/src/__tests__/e2e/instruction-targets.test.ts index 0d9db017c..515943f5f 100644 --- a/src/__tests__/e2e/instruction-targets.test.ts +++ b/src/__tests__/e2e/instruction-targets.test.ts @@ -686,17 +686,19 @@ describe('instruction block targets on real CLI pull (#945)', () => { it('removes the generated content when recall is disabled, a source is deleted, or a namespace is left', async () => { const sandbox = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'teamai-945-e2e-'))); sandboxes.push(sandbox); - const member = makeProjectMember(sandbox, makeTeamAndProject(sandbox), 'dev', 'developer', ['.claude/skills', '.omp/skills']); + const member = makeProjectMember(sandbox, makeTeamAndProject(sandbox), 'dev', 'developer', ['.claude/skills', '.omp/skills', '.pi/skills']); fs.mkdirSync(path.join(member.home, '.omp'), { recursive: true }); const context = () => fs.readFileSync(path.join(member.projectRoot, '.claude', 'rules', 'teamai-context.md'), 'utf8'); expect((await pullAs(member)).code).toBe(0); expect(context()).toContain(RECALL_START); expect(await sessionInstructions('omp', member.home, member.projectRoot)).toContain('teamai-recall'); + expect(await sessionInstructions('pi', member.home, member.projectRoot)).toContain('teamai recall "'); const disable = await runCLI(['recall', 'disable'], { HOME: member.home }, member.projectRoot); expect(disable.code, disable.output).toBe(0); expect(context()).not.toContain(RECALL_START); expect(await sessionInstructions('omp', member.home, member.projectRoot)).not.toContain('teamai-recall'); + expect(await sessionInstructions('pi', member.home, member.projectRoot)).not.toContain('teamai recall "'); const { config, teamRepo } = memberData(member); fs.rmSync(path.join(teamRepo, 'claudemd', 'common.md')); @@ -710,6 +712,17 @@ describe('instruction block targets on real CLI pull (#945)', () => { expect(omp).toContain('PRODUCT-SENTINEL'); }); + it('reports no OMP channel problem for a member without OMP in a project that has .omp/', async () => { + const sandbox = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'teamai-945-e2e-'))); + sandboxes.push(sandbox); + const member = makeProjectMember(sandbox, makeTeamAndProject(sandbox), 'dev', 'developer', ['.claude/skills', '.omp/skills']); + + const result = await pullAs(member); + expect(result.code, result.output).toBe(0); + + expect(result.output).not.toMatch(/omp sessions in this project get no team instructions/); + }); + it('leaves a tracked Copilot file alone for a member without Copilot', async () => { const sandbox = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'teamai-945-e2e-'))); sandboxes.push(sandbox); diff --git a/src/instruction-targets.ts b/src/instruction-targets.ts index c914f52d3..431e11f3d 100644 --- a/src/instruction-targets.ts +++ b/src/instruction-targets.ts @@ -244,7 +244,8 @@ export function hookLimitProblem(hook: InstructionHook, text: string): string | if (hook.limit === undefined || text.length <= hook.limit) return null; return `${hook.tool} cannot load this project's team instructions: they are ${text.length} characters, over the ` + `${hook.limit}-character limit of its prompt section, so ${hook.tool} skips them. Shorten culture.md or the ` - + 'claudemd/ files for this scope. teamai does not cut them or write them to AGENTS.md.'; + + 'claudemd/ files for this scope, or run `teamai recall disable`, which drops the recall block from them. ' + + 'teamai does not cut them or write them to AGENTS.md.'; } /** @@ -353,6 +354,9 @@ function retiredTargets(localConfig: LocalConfig): Map { // Hermes lives in $HERMES_HOME, which ~/.hermes need not be. if (tool === 'hermes') return pathExists(getHermesHome()); + // teamai installs the OMP extension only where ~/.omp exists; a project's + // own .omp/ says nothing about this member using OMP. + if (tool === 'omp') return pathExists(path.join(getUserHome(), '.omp')); const probe = paths.skills ?? paths.rules ?? paths.agents ?? paths.settings; return probe !== undefined && isToolInstalledForConfig(tool, probe, localConfig); } diff --git a/src/uninstall.ts b/src/uninstall.ts index c8b37adc9..ddd1c19b1 100644 --- a/src/uninstall.ts +++ b/src/uninstall.ts @@ -200,14 +200,14 @@ const INSTRUCTION_BLOCK_STARTS: Record = { /** * Start markers of the blocks a pull writes into a tool's instruction target - * (#945): culture and claudemd always, recall when the tool has the - * `teamai-recall` subagent, and team rules where `writesInstructionBlock` - * says so. Nobody writes the legacy `[teamai:rules]` block any more. + * (#945): culture, claudemd and recall always (a tool without the + * `teamai-recall` subagent gets the direct variant, under the same markers), + * and team rules where `writesInstructionBlock` says so. Nobody writes the + * legacy `[teamai:rules]` block any more. */ function instructionBlocksWrittenBy(tool: string, toolPath: TeamaiConfig['toolPaths'][string]): string[] { return (Object.keys(INSTRUCTION_BLOCK_STARTS) as InstructionBlock[]) - .filter((block) => block === 'culture' || block === 'claudemd' - || (block === 'recall' ? toolPath.agents !== undefined : writesInstructionBlock(tool, toolPath, block))) + .filter((block) => block !== 'team-rules' || writesInstructionBlock(tool, toolPath, block)) .map((block) => INSTRUCTION_BLOCK_STARTS[block]); } From 0145a05ffa2032bd2dc96f26d8185439d7444cf9 Mon Sep 17 00:00:00 2001 From: Saul Moro Date: Fri, 2 Oct 2026 07:15:19 +0200 Subject: [PATCH 20/41] fix(hooks): keep Codex's hook adding the member's blocks over AGENTS.override.md (#945) #947 taught the Codex session hook to skip a block already present in AGENTS.override.md. This branch removed that skip for AGENTS.md: a teamai block in a project instructions file holds whoever pulled last, so the hook adds the member's own selection instead. The rebase keeps that rule for AGENTS.override.md too, inverts #947's skip test, and drops the skip sentence from the unreleased #938 changelog entry. --- CHANGELOG.md | 2 +- src/__tests__/codex-hook-rules.test.ts | 9 ++++++--- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0097dabdb..2910e97dd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -45,7 +45,7 @@ All notable changes to this project will be documented in this file. See [standa ### 🐛 Bug Fixes -- 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. The hook omits blocks already present in the active project instructions file, reading `AGENTS.override.md` before `AGENTS.md`. 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)). +- 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)). - `teamai pull` keeps a skill, rule or agent you changed since teamai delivered it instead of overwriting it, and names it: `Kept : you changed it since teamai delivered it`, with a warning when the team version has changed since, which `teamai push` repeats for that copy, since the SessionStart pull is silent. Pull records the sha256 of what it writes at each path in the checkout's record, and the pre-push sync records its writes too, so a copy counts as changed only against that record; a skill counts as one copy, and files only you added do not count. The copies of other tools still update. `--force` keeps these copies, `--dry-run` prints `Would keep `, and a copy of an item the team removed stays when you changed it. To take the team version, delete your copy and run `teamai pull --force`. There is no record before the first full pull on this version, or in a new worktree, so that pull overwrites as before. A file you added to a skill at a path another team version of it has is no longer named on every pull, and `teamai doctor` no longer fails a rules or agents check, or points at `teamai pull --force`, for a kept copy: it lists one as `changed by you (kept by pull)` beside any other problem (for [#822](https://github.com/Tencent/teamai-cli/issues/822)). diff --git a/src/__tests__/codex-hook-rules.test.ts b/src/__tests__/codex-hook-rules.test.ts index 0ea3e8cce..02d31a005 100644 --- a/src/__tests__/codex-hook-rules.test.ts +++ b/src/__tests__/codex-hook-rules.test.ts @@ -164,12 +164,15 @@ projects: expect(await fse.readFile(path.join(tmpDir, 'project', 'AGENTS.override.md'), 'utf8')).toBe(override); }); - it('skips a block already present in the active AGENTS.override.md', async () => { + it('adds the member\'s own blocks even where AGENTS.override.md holds a teamai block (#945)', async () => { await fse.outputFile(path.join(repoPath, 'culture.md'), '---\ncompany:\n name: Acme\n---\n\nBe kind to teammates.\n'); await fse.outputFile(path.join(tmpDir, 'project', 'AGENTS.override.md'), - '\nBe kind to teammates.\n\n'); + '\nAnother member\'s culture.\n\n'); - expect(await context({ hook_event_name: 'SessionStart', source: 'startup' })).not.toContain('Be kind to teammates.'); + const text = await context({ hook_event_name: 'SessionStart', source: 'startup' }); + + expect(text).toContain('Be kind to teammates.'); + expect(text).not.toContain('Another member'); }); it('adds no rules from a scope that does not enable the tool', async () => { From 27a21a16cd06a5d5a2a5d91bd281dfe088db4d17 Mon Sep 17 00:00:00 2001 From: Saul Moro Date: Fri, 2 Oct 2026 07:15:19 +0200 Subject: [PATCH 21/41] fix(pull): address review round 4 of #945 - Retired files include the claudemd a team's toolPaths gives a tool whose target moved, so a team override such as claude.claudemd: CLAUDE.md no longer keeps another member's blocks after the upgrade. A path that is any tool's current target is never retired; uninstall keeps the blocks a remaining tool still writes there. - A team rule named teamai-context is not delivered, since it would land on teamai's own context rule file and block the instructions; pull names it. - OpenCode's instructions list teamai-context.md only while it holds teamai's blocks, so a same-named file of the member's is not activated; doctor asks for the entry under the same condition. --- CHANGELOG.md | 2 +- docs/usage-guide.md | 3 +- docs/usage-guide.zh-CN.md | 3 +- src/__tests__/e2e/instruction-targets.test.ts | 67 ++++++++++++++++++- src/doctor-delivery.ts | 6 +- src/instruction-targets.ts | 50 +++++++++----- src/resources/rules.ts | 10 ++- src/uninstall.ts | 8 ++- 8 files changed, 121 insertions(+), 28 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2910e97dd..a81734acb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ All notable changes to this project will be documented in this file. See [standa ### 💥 Breaking Changes -- **Upgrade every member together: team instructions leave the shared `AGENTS.md`.** `teamai pull` no longer writes the culture, `claudemd/` and recall blocks into the project's `AGENTS.md`, `~/AGENTS.md`, `~/.agents/AGENTS.md` or another tool's file, since members with different roles resolve different `claudemd/` selections and the shared file ended up holding whoever pulled last. Each installed tool gets them in its own file or session hook: Claude Code in `.claude/rules/teamai-context.md` (project) and `~/.claude/CLAUDE.md` (user); Cursor in `teamai-context.mdc` under `.cursor/rules` and `~/.cursor/rules`; CodeBuddy and WorkBuddy in one `.codebuddy/rules/teamai-context.md`, and WorkBuddy in `~/.workbuddy/rules/teamai-context.md`; OpenCode in `.opencode/teamai-context.md` and `~/.config/opencode/teamai-context.md`, listed in its `instructions`; Oh My Pi in `~/.omp/agent/RULES.md` and, in a project, through teamai's OMP extension; Pi through teamai's Pi extension in a project; Hermes in `$HERMES_HOME/SOUL.md` and, in a project, through a Hermes plugin teamai installs and enables. Copilot keeps `.github/copilot-instructions.md`. The first pull after updating removes the blocks earlier releases left in `AGENTS.md`, `~/AGENTS.md`, `.claude/CLAUDE.md`, `.codebuddy/CODEBUDDY.md`, `.omp/AGENTS.md` and `~/.omp/agent/AGENTS.md`, keeps everything else in those files, and names each file it changes; a block with a missing or repeated marker is left with a warning. A member still on an older release writes the blocks back into the shared files, so have everyone update. Pull writes only for installed tools, rewrites nothing that is current, removes the recall block when recall is disabled, and `--dry-run` lists the files it would change. Hermes skips project instructions over 4,000 characters, which pull and `teamai doctor` report. `teamai doctor` checks that each tool can load its instructions. teamai does not change `.gitignore`, `.git/info/exclude` or the index; a team that tracks a generated file such as `.github/copilot-instructions.md` still commits one member's selection (for [#945](https://github.com/Tencent/teamai-cli/issues/945)). +- **Upgrade every member together: team instructions leave the shared `AGENTS.md`.** `teamai pull` no longer writes the culture, `claudemd/` and recall blocks into the project's `AGENTS.md`, `~/AGENTS.md`, `~/.agents/AGENTS.md` or another tool's file, since members with different roles resolve different `claudemd/` selections and the shared file ended up holding whoever pulled last. Each installed tool gets them in its own file or session hook: Claude Code in `.claude/rules/teamai-context.md` (project) and `~/.claude/CLAUDE.md` (user); Cursor in `teamai-context.mdc` under `.cursor/rules` and `~/.cursor/rules`; CodeBuddy and WorkBuddy in one `.codebuddy/rules/teamai-context.md`, and WorkBuddy in `~/.workbuddy/rules/teamai-context.md`; OpenCode in `.opencode/teamai-context.md` and `~/.config/opencode/teamai-context.md`, listed in its `instructions`; Oh My Pi in `~/.omp/agent/RULES.md` and, in a project, through teamai's OMP extension; Pi through teamai's Pi extension in a project; Hermes in `$HERMES_HOME/SOUL.md` and, in a project, through a Hermes plugin teamai installs and enables. Copilot keeps `.github/copilot-instructions.md`. The first pull after updating removes the blocks earlier releases left in `AGENTS.md`, `~/AGENTS.md`, `.claude/CLAUDE.md`, `.codebuddy/CODEBUDDY.md`, `.omp/AGENTS.md`, `~/.omp/agent/AGENTS.md` and a moved tool's `claudemd` from the team's `toolPaths`, keeps everything else in those files, and names each file it changes; a block with a missing or repeated marker is left with a warning. A member still on an older release writes the blocks back into the shared files, so have everyone update. Pull writes only for installed tools, rewrites nothing that is current, removes the recall block when recall is disabled, and `--dry-run` lists the files it would change. Hermes skips project instructions over 4,000 characters, which pull and `teamai doctor` report. `teamai doctor` checks that each tool can load its instructions. A team rule named `teamai-context` is not delivered, since it would land on teamai's own file. teamai does not change `.gitignore`, `.git/info/exclude` or the index; a team that tracks a generated file such as `.github/copilot-instructions.md` still commits one member's selection (for [#945](https://github.com/Tencent/teamai-cli/issues/945)). - An env variable, hook or MCP server with a key its schema does not know, such as a misspelled `role:` or a hand-added `notes:`, is no longer delivered to anyone: the key used to be dropped silently, so a misspelled restriction shipped the entry to every member. `teamai pull`, the list commands, `teamai status` and `teamai doctor` name the file, the entry and the key. `teamai env add` also warns when it updates a variable carrying the unknown key; it, `teamai env remove` and `teamai remove mcp` keep the key when they rewrite the file. A key that a later version adds to these entries is unknown to this one too, so an entry that uses it is not delivered to a member still on this version: upgrade every member before the team uses a new entry key, as for a new `resources:` key (for [#822](https://github.com/Tencent/teamai-cli/issues/822)). - `manifest/projects.yaml` and `manifest/roles.yaml` now reject a resource namespace that is not a single path segment, as a project id already had to be (the id keeps its own narrower ASCII rule). A namespace becomes a directory component (`skills//`, `agents//`, `learnings//`), so `../evil`, `a/b`, `C:evil`, a bare `..`, any name with a trailing `.` or space — which Win32 strips, making `.. ` arrive as `..` and `frontend.` as `frontend` — and a Windows device name such as `CON` or `COM1` under `resources:` no longer parse; the error names the offending entry. Nothing else is rejected: a namespace that is a plain directory name still parses, non-ASCII names and names with a space included. A manifest that fails to parse now reports the offending entry on one line (`Invalid projects manifest: projects.0.resources.skills.1: ...`) instead of dumping a raw validation object. Two namespaces of one resource type that differ only by case (`frontend`, `Frontend`) are rejected too, within a manifest and between the two, since they name one directory on Windows and macOS. A manifest that ships any of these — a device name, a trailing `.`, a case-only pair — parsed before and fails every pull now; rename the directory and the entry together. - **Upgrade every member before a team declares a new axis.** `resources:` in `manifest/roles.yaml` and `manifest/projects.yaml` accepts `env`, `hooks`, `mcp`, `models` and `docs`, but teamai 0.25.0 and the 0.26.0 betas reject a `resources:` key they do not know, so a team that declares one breaks pull for every member still on those versions. From this version on, an unknown `resources:` key prints one warning naming the role or project and the key, and the scope syncs as if the key were absent; `teamai roles` and `teamai projects` keep the key when they save the manifest. `teamai roles|projects add/update --namespaces` never write the new keys, so nothing declares them until an admin does by hand (for [#707](https://github.com/Tencent/teamai-cli/issues/707)). diff --git a/docs/usage-guide.md b/docs/usage-guide.md index 9cc6dd175..aaf704a73 100644 --- a/docs/usage-guide.md +++ b/docs/usage-guide.md @@ -1685,10 +1685,11 @@ A pull from an earlier release may have left these blocks in a file listed below - 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` - 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 `teamai doctor` checks that each installed tool can load these blocks: that each file holds the current blocks, that OpenCode's config lists its file, that the Pi or Oh My Pi extension and the Hermes plugin are installed and enabled, that the Hermes section fits its limit, and that no file an earlier release wrote still holds blocks. -A file named like a teamai target that teamai did not write is left alone, and the pull warns about it. teamai does not change `.gitignore`, `.git/info/exclude` or the git index. A team that wants to keep these files out of commits excludes them itself. +A file named like a teamai target that teamai did not write is left alone and not listed in OpenCode's `instructions`, and the pull warns about it. A team rule named `teamai-context` is not delivered, since it would land on that file; the pull names it. teamai does not change `.gitignore`, `.git/info/exclude` or the git index. A team that wants to keep these files out of commits excludes them itself. ### Viewing the result diff --git a/docs/usage-guide.zh-CN.md b/docs/usage-guide.zh-CN.md index 36323f1bf..a04da1191 100644 --- a/docs/usage-guide.zh-CN.md +++ b/docs/usage-guide.zh-CN.md @@ -1563,10 +1563,11 @@ Oh My Pi 把 `RULES.md` 作为始终应用的规则读取,与其唯一的用 - Oh My Pi:`~/.omp/agent/AGENTS.md` 和 `.omp/AGENTS.md`。Oh My Pi 每一层只读取一个上下文文件,因此它们会遮蔽 `~/.agents/AGENTS.md` 和项目的 `AGENTS.md`。 - Pi:项目 `AGENTS.md` - Codex 系列:项目 `AGENTS.md`(当团队的 `toolPaths` 或早期构建把 Codex 指向那里时) +- 目标文件已改变的任一工具:团队 `toolPaths` 为它设置的 `claudemd` 路径,除非现在另一个工具的块写在那里 `teamai doctor` 会检查每个已安装工具能否加载这些块:每个文件是否包含当前的块,OpenCode 配置是否列出其文件,Pi 或 Oh My Pi 扩展和 Hermes 插件是否已安装并启用,Hermes 段落是否在限制内,以及早期版本写过的文件中是否仍残留块。 -与 teamai 目标同名但并非 teamai 写入的文件保持不变,pull 会给出警告。teamai 不修改 `.gitignore`、`.git/info/exclude` 或 git 索引。若团队希望这些文件不进入提交,需要自行排除。 +与 teamai 目标同名但并非 teamai 写入的文件保持不变,也不会被列入 OpenCode 的 `instructions`,pull 会给出警告。名为 `teamai-context` 的团队 rule 不会被分发,因为它会落在该文件上;pull 会指出它。teamai 不修改 `.gitignore`、`.git/info/exclude` 或 git 索引。若团队希望这些文件不进入提交,需要自行排除。 ### 查看效果 diff --git a/src/__tests__/e2e/instruction-targets.test.ts b/src/__tests__/e2e/instruction-targets.test.ts index 515943f5f..c423d7568 100644 --- a/src/__tests__/e2e/instruction-targets.test.ts +++ b/src/__tests__/e2e/instruction-targets.test.ts @@ -105,14 +105,21 @@ const PROJECT_AGENTS_MD = '# Project\n\nAuthored project instructions.\n'; * A team whose roles select different `claudemd/` namespaces, and a project * repo with an authored, committed AGENTS.md (#945). */ -function makeTeamAndProject(sandbox: string): { remote: string; projectOrigin: string } { +function makeTeamAndProject( + sandbox: string, + options: { teamYaml?: string[]; files?: Record } = {}, +): { remote: string; projectOrigin: string } { const seed = path.join(sandbox, 'team-seed'); const remote = path.join(sandbox, 'team.git'); const write = (rel: string, text: string): void => { fs.mkdirSync(path.dirname(path.join(seed, rel)), { recursive: true }); fs.writeFileSync(path.join(seed, rel), text); }; - write('teamai.yaml', ['team: issue-945-project-e2e', `repo: ${remote}`, 'provider: git', 'sharing:', ' recall:', ' enabled: true', ''].join('\n')); + write('teamai.yaml', [ + 'team: issue-945-project-e2e', `repo: ${remote}`, 'provider: git', 'sharing:', ' recall:', ' enabled: true', + ...options.teamYaml ?? [], '', + ].join('\n')); + for (const [rel, text] of Object.entries(options.files ?? {})) write(rel, text); write('culture.md', '---\ncompany:\n name: Acme\n---\n\nBe kind.\n'); write('claudemd/common.md', 'COMMON-SENTINEL shared by every role.\n'); write('claudemd/development/dev.md', 'DEVELOPMENT-SENTINEL for developers.\n'); @@ -739,4 +746,60 @@ describe('instruction block targets on real CLI pull (#945)', () => { expect(execFileSync('git', ['status', '--porcelain', '--', '.github', 'AGENTS.md'], { cwd: member.projectRoot, encoding: 'utf8' })).toBe(''); }); + + it('strips the blocks an earlier release left in a claudemd path the team configured', async () => { + const sandbox = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'teamai-945-e2e-'))); + sandboxes.push(sandbox); + const fixture = makeTeamAndProject(sandbox, { + teamYaml: ['toolPaths:', ' claude:', ' skills: .claude/skills', ' rules: .claude/rules', ' claudemd: CLAUDE.md'], + }); + const member = makeProjectMember(sandbox, fixture, 'dev', 'developer', ['.claude/skills']); + // An earlier release wrote another member's selection to the configured path. + const configured = path.join(member.projectRoot, 'CLAUDE.md'); + fs.writeFileSync(configured, `# Project notes\n\n${CLAUDEMD_START}\nPRODUCT-SENTINEL from the last pull\n${CLAUDEMD_END}\n`); + + const result = await pullAs(member); + expect(result.code, result.output).toBe(0); + + expect(result.output).toContain(`Removed teamai instruction blocks from ${configured}`); + expect(fs.readFileSync(configured, 'utf8')).toBe('# Project notes\n'); + expect(fs.readFileSync(path.join(member.projectRoot, '.claude', 'rules', 'teamai-context.md'), 'utf8')).toContain('DEVELOPMENT-SENTINEL'); + }); + + it('delivers the team instructions, not a team rule named teamai-context, to the context rule file', async () => { + const sandbox = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'teamai-945-e2e-'))); + sandboxes.push(sandbox); + const fixture = makeTeamAndProject(sandbox, { files: { 'rules/teamai-context.md': 'RESERVED-RULE-SENTINEL\n' } }); + const member = makeProjectMember(sandbox, fixture, 'dev', 'developer', ['.claude/skills', '.cursor/skills']); + + const result = await pullAs(member); + expect(result.code, result.output).toBe(0); + + expect(result.output).toMatch(/rules\/teamai-context\.md is not delivered: teamai-context is the name of teamai's own instruction file/); + for (const file of [path.join('.claude', 'rules', 'teamai-context.md'), path.join('.cursor', 'rules', 'teamai-context.mdc')]) { + const content = fs.readFileSync(path.join(member.projectRoot, file), 'utf8'); + expect(content).toContain('DEVELOPMENT-SENTINEL'); + expect(content).not.toContain('RESERVED-RULE-SENTINEL'); + } + }); + + it('does not list a teamai-context.md teamai did not write in OpenCode\'s instructions', async () => { + const sandbox = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'teamai-945-e2e-'))); + sandboxes.push(sandbox); + const member = makeProjectMember(sandbox, makeTeamAndProject(sandbox), 'dev', 'developer', ['.opencode/skills']); + const contextFile = path.join(member.projectRoot, '.opencode', 'teamai-context.md'); + fs.writeFileSync(contextFile, '# My own notes\n'); + const config = path.join(member.projectRoot, '.opencode', 'opencode.json'); + fs.writeFileSync(config, JSON.stringify({ instructions: ['docs/style.md'] }, null, 2)); + + for (const args of [['--dry-run'], []]) { + const result = await pullAs(member, args); + expect(result.code, result.output).toBe(0); + expect(result.output).toContain(`${contextFile} was not written by teamai`); + expect(result.output).not.toContain('.opencode/teamai-context.md" to the instructions'); + } + + expect(fs.readFileSync(contextFile, 'utf8')).toBe('# My own notes\n'); + expect(JSON.parse(fs.readFileSync(config, 'utf8'))).toEqual({ instructions: ['docs/style.md'] }); + }); }); diff --git a/src/doctor-delivery.ts b/src/doctor-delivery.ts index 16af0d683..21f374a79 100644 --- a/src/doctor-delivery.ts +++ b/src/doctor-delivery.ts @@ -1157,7 +1157,7 @@ export async function buildInstructionDeliveryChecks(ctx: DoctorContext): Promis const { localConfig, teamConfig } = ctx; if (!teamConfig) return []; const { - hookLimitProblem, instructionHookChannel, instructionHookTextFor, instructionTargetPath, + holdsInstructionBlocks, hookLimitProblem, instructionHookChannel, instructionHookTextFor, instructionTargetPath, planInstructionFiles, resolveInstructionTargets, } = await import('./instruction-targets.js'); const { resolveInstructionBlocks } = await import('./pull.js'); @@ -1182,8 +1182,8 @@ export async function buildInstructionDeliveryChecks(ctx: DoctorContext): Promis const opencodePaths = scopedToolPaths(teamConfig, localConfig).opencode; const opencodeFile = opencodePaths && instructionTargetPath('opencode', opencodePaths, localConfig); - // Only a file that exists needs listing; pull registers it once it writes one. - if (opencodeFile && targets.some((t) => t.path === opencodeFile) && await pathExists(opencodeFile)) { + // 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)); const instructions = await readOpencodeInstructionList(config); checks.push({ diff --git a/src/instruction-targets.ts b/src/instruction-targets.ts index 431e11f3d..d9edd3069 100644 --- a/src/instruction-targets.ts +++ b/src/instruction-targets.ts @@ -53,9 +53,10 @@ interface TargetEntry { /** teamai owns the whole file: one it did not write is left alone, and it is deleted once its blocks are gone. */ readonly owned?: boolean; /** - * Files an earlier release wrote this tool's blocks to, relative to the same - * base dir. A pull strips teamai blocks from them once no installed tool - * targets them. + * Files an earlier release wrote this tool's blocks to by default, relative + * to the same base dir. A pull strips teamai blocks from them once no + * installed tool targets them. A tool whose target is not its `claudemd` + * retires the team's configured `claudemd` too (`retiredInstructionFiles`). */ readonly retired: readonly string[]; } @@ -208,9 +209,16 @@ export function instructionTargetFile(tool: string, paths: ToolPaths, scope: Sco return (entryFor(tool, scope)?.file ?? configured)(paths); } -/** Files, relative to the tool's base dir, an earlier release wrote `tool`'s blocks to in `scope`. */ -export function retiredInstructionFiles(tool: string, scope: Scope): readonly string[] { - return entryFor(tool, scope)?.retired ?? []; +/** + * Files, relative to the tool's base dir or absolute, an earlier release wrote + * `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[] { + const entry = entryFor(tool, scope); + if (!entry) return []; + const previous = entry.file === configured ? undefined : paths.claudemd; + return previous === undefined || entry.retired.includes(previous) ? entry.retired : [...entry.retired, previous]; } /** Whether `tool` gets this scope's blocks from teamai's session hook or extension rather than a file. */ @@ -333,14 +341,15 @@ export function instructionTargetAt(tool: string, file: string, scope: Scope): I * A tool's current target is not among them: a tool that is not installed * here may still be installed by a teammate who shares the file (#945). */ -function retiredTargets(localConfig: LocalConfig): Map { +function retiredTargets(toolPaths: Record, localConfig: LocalConfig): Map { + const current = new Set(Object.entries(toolPaths).map(([tool, paths]) => instructionTargetPath(tool, paths, localConfig))); const known = new Map(); const table = localConfig.scope === 'user' ? USER_TARGETS : PROJECT_TARGETS; - for (const [tool, entry] of Object.entries(table)) { + for (const tool of Object.keys(table)) { const baseDir = resolveToolBaseDir(tool, localConfig); - for (const retired of entry.retired) { + for (const retired of retiredInstructionFiles(tool, toolPaths[tool] ?? {}, localConfig.scope)) { const file = path.resolve(baseDir, retired); - if (!known.has(file)) known.set(file, { path: file, tools: [], recall: false }); + if (!current.has(file) && !known.has(file)) known.set(file, { path: file, tools: [], recall: false }); } } return known; @@ -371,7 +380,8 @@ export async function resolveInstructionTargets( // left alone, not cleaned. const inUse = new Set(); const hooks: InstructionHook[] = []; - for (const [tool, paths] of Object.entries(scopedToolPaths(teamConfig, localConfig))) { + const toolPaths = scopedToolPaths(teamConfig, localConfig); + for (const [tool, paths] of Object.entries(toolPaths)) { const entry = entryFor(tool, localConfig.scope); if (entry?.hook) { if (!isAgentExcluded(localConfig, tool) && await isInstalled(tool, paths, localConfig)) { @@ -388,7 +398,7 @@ export async function resolveInstructionTargets( if (paths.agents) target.recall = true; targets.set(file, target); } - const stale = [...retiredTargets(localConfig).values()].filter((t) => !inUse.has(t.path)); + const stale = [...retiredTargets(toolPaths, localConfig).values()].filter((t) => !inUse.has(t.path)); // OpenCode reads ~/.claude/CLAUDE.md while its own user AGENTS.md does not // exist; when Claude's blocks are there, a second copy would duplicate them. const opencode = [...targets.values()].find((target) => target.tools.includes('opencode')); @@ -404,8 +414,9 @@ export async function resolveInstructionTargets( /** * List teamai's OpenCode instruction file in OpenCode's `instructions` while - * it exists, and drop the entry once it is gone: OpenCode reads no file it is - * not told about. Returns what it did or, with `dryRun`, would do. + * it holds teamai's blocks, and drop the entry once they are gone: OpenCode + * reads no file it is not told about. Returns what it did or, with `dryRun`, + * would do. */ export async function registerOpencodeContext( teamConfig: TeamaiConfig, @@ -419,7 +430,7 @@ export async function registerOpencodeContext( if (!contextFile) return null; const wanted = resolved.targets.some((target) => target.path === contextFile); if (!wanted && !resolved.stale.some((target) => target.path === contextFile)) return null; - const present = wanted && (await pathExists(contextFile) || (dryRun && planned.includes(contextFile))); + const present = wanted && (await holdsInstructionBlocks(contextFile) || (dryRun && planned.includes(contextFile))); const { config, entry } = opencodeContextReference(contextFile, localConfig.scope, resolveToolBaseDir('opencode', localConfig)); if (dryRun) { const listed = (await readOpencodeInstructionList(config))?.includes(entry) ?? false; @@ -474,6 +485,15 @@ function hasTeamaiBlock(content: string): boolean { return STALE_BLOCKS.some(([start, end]) => content.includes(start) || content.includes(end)); } +/** + * Whether `file` holds teamai's blocks. A same-named file teamai did not write + * is left as it is, so no tool should be told to load it. + */ +export async function holdsInstructionBlocks(file: string): Promise { + const content = await readFileSafe(file); + return content !== null && hasTeamaiBlock(content); +} + function withoutHeader(content: string, header: string | undefined): string { return header && content.startsWith(header) ? content.substring(header.length) : content; } diff --git a/src/resources/rules.ts b/src/resources/rules.ts index 6c75b7eb7..4b00f4f20 100644 --- a/src/resources/rules.ts +++ b/src/resources/rules.ts @@ -4,7 +4,7 @@ import type { ResourceItem, ResourceItemStatus, DeliveryTarget, TeamaiConfig, Lo import { listFilesRecursive, pathExists, copyFile, ensureDir, remove, fileContentEqual, getFileMtime, listDirs, readFileSafe, writeFile, pruneEmptyDirs, fileHash } from '../utils/fs.js'; 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 } from '../builtin-rules.js'; +import { EXCLUDED_RULE_NAMES, isDeployedRecallRule, TEAMAI_CONTEXT_RULE_NAME } from '../builtin-rules.js'; import { teamRuleToCursorMdc, mergeCursorBodyIntoTeamMd, cursorMdcBodyEqualsTeamMd } from './cursor-mdc.js'; import { copilotInstructionsBodyEqualsTeamMd, @@ -201,7 +201,9 @@ export class RulesHandler extends ResourceHandler { const files = await listFilesRecursive(rulesDir); return files - .filter((f) => f.endsWith('.md')) + // teamai-context is the file pull writes the team instructions to; a team + // rule of that name would land on it (#945). pullAllRules names it. + .filter((f) => f.endsWith('.md') && f !== `${TEAMAI_CONTEXT_RULE_NAME}.md`) .map((f) => ({ name: f.replace(/\.md$/, ''), type: 'rules' as const, @@ -447,6 +449,10 @@ export class RulesHandler extends ResourceHandler { ledger?: DeliveryLedger, ): Promise { const rules = filteredRules ?? await this.scanTeamForPull(teamConfig, localConfig); + if (await pathExists(path.join(localConfig.repo.localPath, 'rules', `${TEAMAI_CONTEXT_RULE_NAME}.md`))) { + log.warn(`rules/${TEAMAI_CONTEXT_RULE_NAME}.md is not delivered: ${TEAMAI_CONTEXT_RULE_NAME} is the name of teamai's own instruction file ` + + 'in each rules directory. Rename the rule in the team repo, for example with `git mv`, and push the change.'); + } // Hermes: inline all team rules into a teamai-managed block in SOUL.md // (user-level standing instructions). Only when Hermes is actually diff --git a/src/uninstall.ts b/src/uninstall.ts index ddd1c19b1..61949ab5c 100644 --- a/src/uninstall.ts +++ b/src/uninstall.ts @@ -452,7 +452,7 @@ async function discoverToolResources( res.claudeMdFiles.push(claudeMdPath); } } - for (const retired of retiredInstructionFiles(tool, scope)) { + for (const retired of retiredInstructionFiles(tool, toolPath, scope)) { const file = path.resolve(baseDir, retired); const content = await readFileSafe(file); if (content && CLAUDEMD_MARKER_PAIRS.some(([start]) => content.includes(start))) { @@ -723,11 +723,13 @@ async function buildRemovalPlan( .filter(([start]) => content.includes(start) && !kept?.has(start)); if (blocks.length > 0) plan.claudeMdFiles.push({ path: file, blocks }); } - // No tool reads a retired file any more, so nothing retains its blocks. + // A retired file keeps only the blocks a remaining tool still writes + // there, which a team's toolPaths can make it. for (const file of res.retiredInstructionFiles) { if (plan.claudeMdFiles.some((entry) => entry.path === file)) continue; const content = await readFileSafe(file) ?? ''; - const blocks = CLAUDEMD_MARKER_PAIRS.filter(([start]) => content.includes(start)); + const kept = retainedBlocks.get(file); + const blocks = CLAUDEMD_MARKER_PAIRS.filter(([start]) => content.includes(start) && !kept?.has(start)); if (blocks.length > 0) plan.claudeMdFiles.push({ path: file, blocks }); } plan.skillDirs.push(...res.skillDirs); From 1c76ef7b7e4539a7d080ea651358718d6bfb4857 Mon Sep 17 00:00:00 2001 From: Saul Moro Date: Fri, 2 Oct 2026 08:15:20 +0200 Subject: [PATCH 22/41] fix(pull): address review round 5 of #945 - The Hermes teamai-instructions plugin is written, enabled, disabled and removed only while its directory is absent or its plugin.yaml carries teamai's marker, so a member's same-named plugin survives inject and uninstall; pull and doctor name it. - Uninstalling OpenCode drops teamai's instructions entry even when the member's own text keeps teamai-context.md. - A file several tools share gets the teamai-recall subagent block only when every tool reading it has the subagent, so WorkBuddy without agents beside CodeBuddy gets the direct teamai recall block. - Removing a block that opened a file leaves no blank lines above the member's text. --- CHANGELOG.md | 2 +- docs/usage-guide.md | 6 +-- docs/usage-guide.zh-CN.md | 6 +-- src/__tests__/e2e/instruction-targets.test.ts | 19 ++++++++ src/__tests__/hermes-hooks.test.ts | 23 +++++++++ src/__tests__/instruction-targets.test.ts | 31 ++++++++++++ src/hermes-hooks.ts | 47 +++++++++++++++---- src/instruction-targets.ts | 13 +++-- src/uninstall.ts | 5 +- 9 files changed, 129 insertions(+), 23 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a81734acb..31d6d82d9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ All notable changes to this project will be documented in this file. See [standa ### 💥 Breaking Changes -- **Upgrade every member together: team instructions leave the shared `AGENTS.md`.** `teamai pull` no longer writes the culture, `claudemd/` and recall blocks into the project's `AGENTS.md`, `~/AGENTS.md`, `~/.agents/AGENTS.md` or another tool's file, since members with different roles resolve different `claudemd/` selections and the shared file ended up holding whoever pulled last. Each installed tool gets them in its own file or session hook: Claude Code in `.claude/rules/teamai-context.md` (project) and `~/.claude/CLAUDE.md` (user); Cursor in `teamai-context.mdc` under `.cursor/rules` and `~/.cursor/rules`; CodeBuddy and WorkBuddy in one `.codebuddy/rules/teamai-context.md`, and WorkBuddy in `~/.workbuddy/rules/teamai-context.md`; OpenCode in `.opencode/teamai-context.md` and `~/.config/opencode/teamai-context.md`, listed in its `instructions`; Oh My Pi in `~/.omp/agent/RULES.md` and, in a project, through teamai's OMP extension; Pi through teamai's Pi extension in a project; Hermes in `$HERMES_HOME/SOUL.md` and, in a project, through a Hermes plugin teamai installs and enables. Copilot keeps `.github/copilot-instructions.md`. The first pull after updating removes the blocks earlier releases left in `AGENTS.md`, `~/AGENTS.md`, `.claude/CLAUDE.md`, `.codebuddy/CODEBUDDY.md`, `.omp/AGENTS.md`, `~/.omp/agent/AGENTS.md` and a moved tool's `claudemd` from the team's `toolPaths`, keeps everything else in those files, and names each file it changes; a block with a missing or repeated marker is left with a warning. A member still on an older release writes the blocks back into the shared files, so have everyone update. Pull writes only for installed tools, rewrites nothing that is current, removes the recall block when recall is disabled, and `--dry-run` lists the files it would change. Hermes skips project instructions over 4,000 characters, which pull and `teamai doctor` report. `teamai doctor` checks that each tool can load its instructions. A team rule named `teamai-context` is not delivered, since it would land on teamai's own file. teamai does not change `.gitignore`, `.git/info/exclude` or the index; a team that tracks a generated file such as `.github/copilot-instructions.md` still commits one member's selection (for [#945](https://github.com/Tencent/teamai-cli/issues/945)). +- **Upgrade every member together: team instructions leave the shared `AGENTS.md`.** `teamai pull` no longer writes the culture, `claudemd/` and recall blocks into the project's `AGENTS.md`, `~/AGENTS.md`, `~/.agents/AGENTS.md` or another tool's file, since members with different roles resolve different `claudemd/` selections and the shared file ended up holding whoever pulled last. Each installed tool gets them in its own file or session hook: Claude Code in `.claude/rules/teamai-context.md` (project) and `~/.claude/CLAUDE.md` (user); Cursor in `teamai-context.mdc` under `.cursor/rules` and `~/.cursor/rules`; CodeBuddy and WorkBuddy in one `.codebuddy/rules/teamai-context.md`, and WorkBuddy in `~/.workbuddy/rules/teamai-context.md`; OpenCode in `.opencode/teamai-context.md` and `~/.config/opencode/teamai-context.md`, listed in its `instructions`; Oh My Pi in `~/.omp/agent/RULES.md` and, in a project, through teamai's OMP extension; Pi through teamai's Pi extension in a project; Hermes in `$HERMES_HOME/SOUL.md` and, in a project, through a Hermes plugin teamai installs and enables. Copilot keeps `.github/copilot-instructions.md`. The first pull after updating removes the blocks earlier releases left in `AGENTS.md`, `~/AGENTS.md`, `.claude/CLAUDE.md`, `.codebuddy/CODEBUDDY.md`, `.omp/AGENTS.md`, `~/.omp/agent/AGENTS.md` and a moved tool's `claudemd` from the team's `toolPaths`, keeps everything else in those files, and names each file it changes; a block with a missing or repeated marker is left with a warning. A member still on an older release writes the blocks back into the shared files, so have everyone update. Pull writes only for installed tools, rewrites nothing that is current, removes the recall block when recall is disabled, and `--dry-run` lists the files it would change. Hermes skips project instructions over 4,000 characters, which pull and `teamai doctor` report. `teamai doctor` checks that each tool can load its instructions. A team rule named `teamai-context` is not delivered, since it would land on teamai's own file. A Hermes plugin named `teamai-instructions` that teamai did not write is left alone, and a file CodeBuddy and WorkBuddy share gets the `teamai-recall` subagent block only when both have the subagent. teamai does not change `.gitignore`, `.git/info/exclude` or the index; a team that tracks a generated file such as `.github/copilot-instructions.md` still commits one member's selection (for [#945](https://github.com/Tencent/teamai-cli/issues/945)). - An env variable, hook or MCP server with a key its schema does not know, such as a misspelled `role:` or a hand-added `notes:`, is no longer delivered to anyone: the key used to be dropped silently, so a misspelled restriction shipped the entry to every member. `teamai pull`, the list commands, `teamai status` and `teamai doctor` name the file, the entry and the key. `teamai env add` also warns when it updates a variable carrying the unknown key; it, `teamai env remove` and `teamai remove mcp` keep the key when they rewrite the file. A key that a later version adds to these entries is unknown to this one too, so an entry that uses it is not delivered to a member still on this version: upgrade every member before the team uses a new entry key, as for a new `resources:` key (for [#822](https://github.com/Tencent/teamai-cli/issues/822)). - `manifest/projects.yaml` and `manifest/roles.yaml` now reject a resource namespace that is not a single path segment, as a project id already had to be (the id keeps its own narrower ASCII rule). A namespace becomes a directory component (`skills//`, `agents//`, `learnings//`), so `../evil`, `a/b`, `C:evil`, a bare `..`, any name with a trailing `.` or space — which Win32 strips, making `.. ` arrive as `..` and `frontend.` as `frontend` — and a Windows device name such as `CON` or `COM1` under `resources:` no longer parse; the error names the offending entry. Nothing else is rejected: a namespace that is a plain directory name still parses, non-ASCII names and names with a space included. A manifest that fails to parse now reports the offending entry on one line (`Invalid projects manifest: projects.0.resources.skills.1: ...`) instead of dumping a raw validation object. Two namespaces of one resource type that differ only by case (`frontend`, `Frontend`) are rejected too, within a manifest and between the two, since they name one directory on Windows and macOS. A manifest that ships any of these — a device name, a trailing `.`, a case-only pair — parsed before and fails every pull now; rename the directory and the entry together. - **Upgrade every member before a team declares a new axis.** `resources:` in `manifest/roles.yaml` and `manifest/projects.yaml` accepts `env`, `hooks`, `mcp`, `models` and `docs`, but teamai 0.25.0 and the 0.26.0 betas reject a `resources:` key they do not know, so a team that declares one breaks pull for every member still on those versions. From this version on, an unknown `resources:` key prints one warning naming the role or project and the key, and the scope syncs as if the key were absent; `teamai roles` and `teamai projects` keep the key when they save the manifest. `teamai roles|projects add/update --namespaces` never write the new keys, so nothing declares them until an admin does by hand (for [#707](https://github.com/Tencent/teamai-cli/issues/707)). diff --git a/docs/usage-guide.md b/docs/usage-guide.md index aaf704a73..173f244c1 100644 --- a/docs/usage-guide.md +++ b/docs/usage-guide.md @@ -1662,7 +1662,7 @@ Two members of the same project can have different roles, so their shared instru | Pi | `~/.pi/agent/AGENTS.md` | Added to each run's system prompt by teamai's Pi extension | | Hermes | A block in `$HERMES_HOME/SOUL.md`, beside the team rules block (unverified) | A system prompt section from teamai's Hermes plugin (unverified) | -*Unverified*: built from the tool's documented or source-read loader, not yet checked in a live session. Claude Code, Oh My Pi, OpenCode and Pi (project scope) were checked in live sessions, from the project root and a subdirectory. A tool with the `teamai-recall` subagent gets a recall block that calls it; a tool without one (Pi, Hermes, OpenClaw) gets a recall block that tells the agent to run `teamai recall` directly. +*Unverified*: built from the tool's documented or source-read loader, not yet checked in a live session. Claude Code, Oh My Pi, OpenCode and Pi (project scope) were checked in live sessions, from the project root and a subdirectory. A tool with the `teamai-recall` subagent gets a recall block that calls it; a tool without one (Pi, Hermes, OpenClaw) gets a recall block that tells the agent to run `teamai recall` directly. A file several tools share gets the subagent block only when every one of them has the subagent. Claude Code loads `.claude/rules/teamai-context.md` from the project root and any subdirectory, and still reads the project's `AGENTS.md` or authored `CLAUDE.md` the way it chose to. Copilot CLI 1.0.89 and later also reads a project's `.claude/rules`, so with both tools installed Copilot can get the blocks twice. @@ -1670,7 +1670,7 @@ Both `teamai-context.mdc` files carry `alwaysApply: true`, which Cursor's rule l The CodeBuddy and WorkBuddy rule files carry `alwaysApply: true`, which CodeBuddy's rule parser reads as always applied. Uninstalling one of the two keeps the shared project copy while the other is still installed. -In a project, teamai installs the Hermes plugin `$HERMES_HOME/plugins/teamai-instructions/` and adds it to `plugins.enabled` in `$HERMES_HOME/config.yaml` (a name you list under `plugins.disabled` stays off). According to Hermes' documentation it builds the section once for each new session from the session's directory and keeps it through compression and resume. A section holds at most 4,000 characters, and all plugin sections together at most 8,000. When this member's instructions for the project are longer, Hermes skips them and `teamai pull` says so: teamai does not cut them or write them to `AGENTS.md`. Outside a project the section is empty, and Hermes may log that it skipped an empty section. +In a project, teamai installs the Hermes plugin `$HERMES_HOME/plugins/teamai-instructions/` and adds it to `plugins.enabled` in `$HERMES_HOME/config.yaml` (a name you list under `plugins.disabled` stays off). A plugin of that name teamai did not write is left alone, also on uninstall, and `teamai pull` and `teamai doctor` say so. According to Hermes' documentation it builds the section once for each new session from the session's directory and keeps it through compression and resume. A section holds at most 4,000 characters, and all plugin sections together at most 8,000. When this member's instructions for the project are longer, Hermes skips them and `teamai pull` says so: teamai does not cut them or write them to `AGENTS.md`. Outside a project the section is empty, and Hermes may log that it skipped an empty section. OpenCode loads a file only when its config lists it in `instructions`, so teamai adds that one entry and keeps your other entries and keys; the root `opencode.json` and OpenCode's own `AGENTS.md` files are left alone. While `~/.config/opencode/AGENTS.md` does not exist, OpenCode reads `~/.claude/CLAUDE.md` instead; when Claude Code gets the user blocks there, OpenCode already has them, so teamai writes no second user copy for OpenCode and says so in the pull output. A config file teamai cannot parse as JSON (for example one with comments) is left unchanged with a warning; add the entry by hand. @@ -2843,7 +2843,7 @@ teamai uninstall --agent claude What gets removed: - TeamAI-managed model settings are restored first when ownership is still intact - teamai hooks in AI tool settings -- The teamai blocks (culture, shared instructions, recall, and Codex's team rules) in each tool's instruction file, and the files an earlier release wrote them to (your own content is preserved; a `teamai-context` file teamai wrote is removed whole, and OpenCode's `instructions` entry for it goes too) +- 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, even when your own text keeps the file) - 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 custom agents and CLI built-in agents (your own agents are preserved) diff --git a/docs/usage-guide.zh-CN.md b/docs/usage-guide.zh-CN.md index a04da1191..3a43ca7eb 100644 --- a/docs/usage-guide.zh-CN.md +++ b/docs/usage-guide.zh-CN.md @@ -1540,7 +1540,7 @@ pull 只把团队文化、共享指令和 recall 块写入已安装 AI 工具的 | Pi | `~/.pi/agent/AGENTS.md` | 由 teamai 的 Pi 扩展加入每次运行的系统提示 | | Hermes | `$HERMES_HOME/SOUL.md` 中的一个块,位于团队规则块旁(未验证) | teamai 的 Hermes 插件提供的系统提示段落(未验证) | -*未验证*:依据工具的文档或源码中的加载逻辑实现,尚未在实际会话中检查。Claude Code、Oh My Pi、OpenCode 和 Pi(项目范围)已在实际会话中从项目根目录和子目录检查过。有 `teamai-recall` subagent 的工具获得调用该 subagent 的 recall 块;没有的工具(Pi、Hermes、OpenClaw)获得提示 agent 直接运行 `teamai recall` 的 recall 块。 +*未验证*:依据工具的文档或源码中的加载逻辑实现,尚未在实际会话中检查。Claude Code、Oh My Pi、OpenCode 和 Pi(项目范围)已在实际会话中从项目根目录和子目录检查过。有 `teamai-recall` subagent 的工具获得调用该 subagent 的 recall 块;没有的工具(Pi、Hermes、OpenClaw)获得提示 agent 直接运行 `teamai recall` 的 recall 块。多个工具共用的文件只有在每个工具都有该 subagent 时才获得 subagent 块。 Claude Code 会从项目根目录和任意子目录加载 `.claude/rules/teamai-context.md`,并照常读取项目的 `AGENTS.md` 或项目自己编写的 `CLAUDE.md`。Copilot CLI 1.0.89 及更高版本也会读取项目的 `.claude/rules`,因此同时安装这两个工具时,Copilot 可能会读到两份。 @@ -1548,7 +1548,7 @@ Claude Code 会从项目根目录和任意子目录加载 `.claude/rules/teamai- CodeBuddy 和 WorkBuddy 的规则文件带有 `alwaysApply: true`,CodeBuddy 的规则解析器将其视为始终应用。卸载其中一个工具时,只要另一个仍已安装,项目中共用的那份文件就会保留。 -在项目中,teamai 安装 Hermes 插件 `$HERMES_HOME/plugins/teamai-instructions/`,并把它加入 `$HERMES_HOME/config.yaml` 的 `plugins.enabled`(列在 `plugins.disabled` 中的名称保持关闭)。根据 Hermes 的文档,它在每个新会话开始时根据会话目录生成该段落,并在压缩和恢复后保留。一个段落最多 4,000 个字符,所有插件段落合计最多 8,000 个字符。当该成员在此项目的指令更长时,Hermes 会跳过它们,`teamai pull` 会给出提示:teamai 不会截断它们,也不会写入 `AGENTS.md`。在项目之外段落为空,Hermes 可能记录它跳过了一个空段落。 +在项目中,teamai 安装 Hermes 插件 `$HERMES_HOME/plugins/teamai-instructions/`,并把它加入 `$HERMES_HOME/config.yaml` 的 `plugins.enabled`(列在 `plugins.disabled` 中的名称保持关闭)。同名但并非 teamai 写入的插件保持不变,卸载时也一样,`teamai pull` 和 `teamai doctor` 会指出这一点。根据 Hermes 的文档,它在每个新会话开始时根据会话目录生成该段落,并在压缩和恢复后保留。一个段落最多 4,000 个字符,所有插件段落合计最多 8,000 个字符。当该成员在此项目的指令更长时,Hermes 会跳过它们,`teamai pull` 会给出提示:teamai 不会截断它们,也不会写入 `AGENTS.md`。在项目之外段落为空,Hermes 可能记录它跳过了一个空段落。 OpenCode 只加载配置中 `instructions` 列出的文件,因此 teamai 只添加这一项,并保留你的其他条目和键;根目录的 `opencode.json` 以及 OpenCode 自己的 `AGENTS.md` 文件保持不变。当 `~/.config/opencode/AGENTS.md` 不存在时,OpenCode 会改为读取 `~/.claude/CLAUDE.md`;若 Claude Code 的用户块已在那里,OpenCode 已经获得它们,因此 teamai 不会为 OpenCode 再写一份用户副本,并在 pull 输出中说明。teamai 无法按 JSON 解析的配置文件(例如带注释的文件)保持不变并给出警告,请手动添加该条目。 @@ -2654,7 +2654,7 @@ teamai uninstall --agent claude 移除内容: - 如果 ownership 仍有效,先恢复 TeamAI 管理的模型配置 - AI 工具 settings 中的 teamai hooks -- 各工具指令文件中的 teamai 块(文化、共享指令、recall 以及 Codex 的团队规则),以及早期版本写过这些块的文件(保留用户自写内容;teamai 写入的 `teamai-context` 文件整体删除,OpenCode 中对应的 `instructions` 条目一并移除) +- 各工具指令文件中的 teamai 块(文化、共享指令、recall 以及 Codex 的团队规则),以及早期版本写过这些块的文件(保留用户自写内容;teamai 写入的 `teamai-context` 文件整体删除,OpenCode 中对应的 `instructions` 条目一并移除,即使你自写的内容让该文件保留下来) - 团队同步的 skills,包括 OpenClaw workspace skills(保留用户自建 skills) - 团队同步的 rules,包括旧版本留在 `.codex/rules/` 中的副本,团队此后已删除的 rule 的副本也包括在内。清理使用记录的 `toolRoots` 位置和发布者本地的文件名。其中你改过的副本会保留,并在警告中点名。已删除 rule 的副本只有与记录的投递哈希一致时才会删除;没有该记录时也会保留并点名。Codex 的 `*.rules` 文件保留 - 团队同步的自定义 agents 和 CLI 内置 agents(保留用户自建 agents) diff --git a/src/__tests__/e2e/instruction-targets.test.ts b/src/__tests__/e2e/instruction-targets.test.ts index c423d7568..daed61c98 100644 --- a/src/__tests__/e2e/instruction-targets.test.ts +++ b/src/__tests__/e2e/instruction-targets.test.ts @@ -802,4 +802,23 @@ describe('instruction block targets on real CLI pull (#945)', () => { expect(fs.readFileSync(contextFile, 'utf8')).toBe('# My own notes\n'); expect(JSON.parse(fs.readFileSync(config, 'utf8'))).toEqual({ instructions: ['docs/style.md'] }); }); + + it('drops OpenCode\'s instructions entry on uninstall even when the member\'s text keeps the file', async () => { + const sandbox = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'teamai-945-e2e-'))); + sandboxes.push(sandbox); + const member = makeProjectMember(sandbox, makeTeamAndProject(sandbox), 'dev', 'developer', ['.opencode/skills']); + const contextFile = path.join(member.projectRoot, '.opencode', 'teamai-context.md'); + const config = path.join(member.projectRoot, '.opencode', 'opencode.json'); + + const pull = await pullAs(member); + expect(pull.code, pull.output).toBe(0); + expect(JSON.parse(fs.readFileSync(config, 'utf8')).instructions).toEqual(['.opencode/teamai-context.md']); + fs.appendFileSync(contextFile, '\n# My own notes\n'); + + const uninstall = await runCLI(['uninstall', '--agent', 'opencode', '--force'], { HOME: member.home }, member.projectRoot); + expect(uninstall.code, uninstall.output).toBe(0); + expect(fs.readFileSync(contextFile, 'utf8')).toBe('# My own notes\n'); + expect(JSON.parse(fs.readFileSync(config, 'utf8')).instructions ?? []).toEqual([]); + }); }); + diff --git a/src/__tests__/hermes-hooks.test.ts b/src/__tests__/hermes-hooks.test.ts index 6cd33817f..0cafc71dc 100644 --- a/src/__tests__/hermes-hooks.test.ts +++ b/src/__tests__/hermes-hooks.test.ts @@ -4,6 +4,7 @@ import path from 'node:path'; import os from 'node:os'; import { injectHermesHooks, removeHermesHooks, getReportScriptPath, getInstructionsPluginDir } from '../hermes-hooks.js'; import { log } from '../utils/logger.js'; +import { instructionHookChannel } from '../instruction-targets.js'; let tmpDir: string; let savedHermesHome: string | undefined; @@ -64,4 +65,26 @@ describe('the teamai-instructions plugin (#945)', () => { expect(config()).not.toMatch(/enabled:/); }); + + it('leaves a same-named plugin teamai did not write alone on inject and remove', async () => { + fs.writeFileSync(path.join(tmpDir, 'config.yaml'), 'plugins:\n enabled:\n - teamai-instructions\n'); + const dir = getInstructionsPluginDir(); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, 'plugin.yaml'), 'name: teamai-instructions\n'); + fs.writeFileSync(path.join(dir, '__init__.py'), 'def register(ctx): pass\n'); + fs.writeFileSync(path.join(dir, 'notes.txt'), 'mine\n'); + vi.spyOn(log, 'success').mockImplementation(() => {}); + const warn = vi.spyOn(log, 'warn').mockImplementation(() => {}); + + await injectHermesHooks(); + expect(warn).toHaveBeenCalledWith(expect.stringContaining(`${dir} exists without the TeamAI marker`)); + expect(fs.readFileSync(path.join(dir, '__init__.py'), 'utf8')).toBe('def register(ctx): pass\n'); + expect(fs.existsSync(getReportScriptPath())).toBe(true); + expect(await instructionHookChannel('hermes')).toEqual({ ready: false, fix: expect.stringContaining(`${dir} holds a plugin teamai did not write`) }); + + await removeHermesHooks(); + expect(fs.readdirSync(dir).sort()).toEqual(['__init__.py', 'notes.txt', 'plugin.yaml']); + expect(config()).toContain('- teamai-instructions'); + }); }); + diff --git a/src/__tests__/instruction-targets.test.ts b/src/__tests__/instruction-targets.test.ts index 6eb1fe965..28bee03a9 100644 --- a/src/__tests__/instruction-targets.test.ts +++ b/src/__tests__/instruction-targets.test.ts @@ -8,6 +8,7 @@ import { clearInstructionFile, instructionChannelProblems, planInstructionFiles, + resolveInstructionTargets, type InstructionTarget, } from '../instruction-targets.js'; import { injectPiHooks } from '../pi-hooks.js'; @@ -195,3 +196,33 @@ describe('instruction channel problems (#945)', () => { } }); }); + +describe('instruction targets shared by several tools (#945)', () => { + it('gives a shared file the subagent recall block only when every tool reading it has the subagent', async () => { + const root = fs.realpathSync.native(fs.mkdtempSync(path.join(os.tmpdir(), 'teamai-945-shared-'))); + try { + const projectRoot = path.join(root, 'project'); + fs.mkdirSync(path.join(projectRoot, '.codebuddy', 'skills'), { recursive: true }); + fs.mkdirSync(path.join(projectRoot, '.workbuddy', 'skills'), { recursive: true }); + const localConfig = { + repo: { localPath: path.join(root, 'repo'), remote: 'https://example.invalid/t.git' }, + username: 'u', additionalRoles: [], scope: 'project', projectRoot, + } as unknown as LocalConfig; + const resolve = async (workbuddyAgents: boolean) => (await resolveInstructionTargets(TeamaiConfigSchema.parse({ + team: 't', + repo: 'https://example.invalid/t.git', + toolPaths: { + codebuddy: { skills: '.codebuddy/skills', rules: '.codebuddy/rules', agents: '.codebuddy/agents' }, + workbuddy: { skills: '.workbuddy/skills', rules: '.workbuddy/rules', ...(workbuddyAgents ? { agents: '.workbuddy/agents' } : {}) }, + }, + }), localConfig)).targets; + + const [mixed] = await resolve(false); + expect(mixed).toMatchObject({ tools: ['codebuddy', 'workbuddy'], recall: false }); + const [both] = await resolve(true); + expect(both).toMatchObject({ tools: ['codebuddy', 'workbuddy'], recall: true }); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }); +}); diff --git a/src/hermes-hooks.ts b/src/hermes-hooks.ts index 898c8aa8d..97ff4c29b 100644 --- a/src/hermes-hooks.ts +++ b/src/hermes-hooks.ts @@ -10,7 +10,7 @@ import path from 'node:path'; import { chmod } from 'node:fs/promises'; -import { writeIfChanged, pathExists, remove } from './utils/fs.js'; +import { writeIfChanged, pathExists, readFileSafe, remove } from './utils/fs.js'; import { log } from './utils/logger.js'; import { getHermesHome } from './hermes-home.js'; import { @@ -39,6 +39,25 @@ export function getInstructionsPluginDir(): string { return path.join(getHermesHome(), 'plugins', HERMES_INSTRUCTIONS_PLUGIN); } +const PLUGIN_MARKER = '# [teamai] generated by teamai'; + +/** + * Whether teamai may write or delete the plugin directory: it does not exist + * yet, or its manifest carries teamai's marker. A same-named plugin of the + * member's is never overwritten or removed. + */ +export async function ownsInstructionsPlugin(): Promise { + const dir = getInstructionsPluginDir(); + if (!await pathExists(dir)) return true; + return (await readFileSafe(path.join(dir, 'plugin.yaml')))?.startsWith(PLUGIN_MARKER) ?? false; +} + +/** Why teamai left the plugin directory alone, and what to do. */ +export function foreignInstructionsPlugin(): string { + return `${getInstructionsPluginDir()} holds a plugin teamai did not write, so teamai left it unchanged and Hermes sessions in a project ` + + 'get no team instructions. Rename or move that plugin, then run `teamai hooks inject`.'; +} + /** * The plugin adds the member's team instructions for the session's project * as a cache-safe system prompt section (#945). Hermes builds the section @@ -48,7 +67,7 @@ export function getInstructionsPluginDir(): string { */ export function buildInstructionsPlugin(): { manifest: string; init: string } { const manifest = [ - '# [teamai] generated by teamai, do not edit.', + `${PLUGIN_MARKER}, do not edit.`, `name: ${HERMES_INSTRUCTIONS_PLUGIN}`, 'version: "1"', 'description: Adds the team instructions teamai resolves for this member and project.', @@ -130,12 +149,18 @@ export async function injectHermesHooks(): Promise { } const hookChanged = await upsertHermesHook(REPORT_EVENT, { command: scriptPath, timeout: 60 }); const allowlistChanged = await addHermesAllowlist(REPORT_EVENT, scriptPath); - const plugin = buildInstructionsPlugin(); - const pluginDir = getInstructionsPluginDir(); - const manifestChanged = await writeIfChanged(path.join(pluginDir, 'plugin.yaml'), plugin.manifest); - const initChanged = await writeIfChanged(path.join(pluginDir, '__init__.py'), plugin.init); - const pluginEnabled = await enableHermesPlugin(HERMES_INSTRUCTIONS_PLUGIN); - if (scriptChanged || hookChanged || allowlistChanged || manifestChanged || initChanged || pluginEnabled) { + let pluginChanged = false; + if (await ownsInstructionsPlugin()) { + const plugin = buildInstructionsPlugin(); + const pluginDir = getInstructionsPluginDir(); + const manifestChanged = await writeIfChanged(path.join(pluginDir, 'plugin.yaml'), plugin.manifest); + const initChanged = await writeIfChanged(path.join(pluginDir, '__init__.py'), plugin.init); + const pluginEnabled = await enableHermesPlugin(HERMES_INSTRUCTIONS_PLUGIN); + pluginChanged = manifestChanged || initChanged || pluginEnabled; + } else { + log.warn(`Skipping the Hermes instructions plugin: ${getInstructionsPluginDir()} exists without the TeamAI marker`); + } + if (scriptChanged || hookChanged || allowlistChanged || pluginChanged) { log.success('Injected teamai Hermes hook into ' + scriptPath); } else { log.debug(`teamai Hermes hook already up-to-date in ${scriptPath}`); @@ -157,8 +182,10 @@ export async function removeHermesHooks(): Promise { if (await pathExists(scriptPath)) { await remove(scriptPath); } - await disableHermesPlugin(HERMES_INSTRUCTIONS_PLUGIN); - await remove(getInstructionsPluginDir()); + if (await ownsInstructionsPlugin()) { + await disableHermesPlugin(HERMES_INSTRUCTIONS_PLUGIN); + await remove(getInstructionsPluginDir()); + } log.success('Removed teamai Hermes hook from ' + scriptPath); } diff --git a/src/instruction-targets.ts b/src/instruction-targets.ts index d9edd3069..7c1b6d77f 100644 --- a/src/instruction-targets.ts +++ b/src/instruction-targets.ts @@ -163,7 +163,7 @@ export interface InstructionTarget { /** Absolute path. */ path: string; tools: string[]; - /** Whether a tool reading this file has the `teamai-recall` subagent, which decides the recall block it gets. */ + /** Whether every tool reading this file has the `teamai-recall` subagent, which decides the recall block it gets. */ recall: boolean; header?: string; owned?: boolean; @@ -273,8 +273,11 @@ export async function instructionHookChannel(tool: string): Promise<{ ready: boo }; } if (tool === 'hermes') { - const { buildInstructionsPlugin, getInstructionsPluginDir, HERMES_INSTRUCTIONS_PLUGIN } = await import('./hermes-hooks.js'); + const { + buildInstructionsPlugin, foreignInstructionsPlugin, getInstructionsPluginDir, HERMES_INSTRUCTIONS_PLUGIN, ownsInstructionsPlugin, + } = await import('./hermes-hooks.js'); const { getHermesConfigPath, isHermesPluginEnabled } = await import('./hermes-config.js'); + if (!await ownsInstructionsPlugin()) return { ready: false, fix: foreignInstructionsPlugin() }; const dir = getInstructionsPluginDir(); const plugin = buildInstructionsPlugin(); const installed = await readFileSafe(path.join(dir, '__init__.py')) === plugin.init @@ -394,8 +397,9 @@ export async function resolveInstructionTargets( inUse.add(file); if (isAgentExcluded(localConfig, tool)) continue; const target = targets.get(file) ?? instructionTargetAt(tool, file, localConfig.scope); + // The subagent block only where every tool reading the file has the subagent. + target.recall = Boolean(paths.agents) && (target.tools.length === 0 || target.recall); target.tools.push(tool); - if (paths.agents) target.recall = true; targets.set(file, target); } const stale = [...retiredTargets(toolPaths, localConfig).values()].filter((t) => !inUse.has(t.path)); @@ -477,7 +481,8 @@ function editBlock(content: string, [start, end]: MarkerPair, block: string | nu const after = content.substring(endIdx + end.length); if (block !== null) return { content: content.substring(0, startIdx) + block + after }; const before = content.substring(0, startIdx).replace(/\n+$/, '\n'); - const rest = (before + after.replace(/^\n+/, '\n')).trimEnd(); + // A block that opened the file leaves no blank line above what follows it. + const rest = (before + after.replace(/^\n+/, before ? '\n' : '')).trimEnd(); return { content: rest ? `${rest}\n` : '' }; } diff --git a/src/uninstall.ts b/src/uninstall.ts index 61949ab5c..fbe47e1b3 100644 --- a/src/uninstall.ts +++ b/src/uninstall.ts @@ -1074,8 +1074,9 @@ async function executeRemoval(plan: RemovalPlan): Promise { const { changed, warnings } = await clearInstructionFile(claudeMdPath, blocks.map(([start]) => start)); for (const warning of warnings) log.warn(warning); if (changed) log.success(`Cleaned ${claudeMdPath}`); - // OpenCode loads its file through an `instructions` entry; drop it with the file. - if (OPENCODE_CONTEXT_FILES.some((suffix) => claudeMdPath.endsWith(suffix)) && !await pathExists(claudeMdPath)) { + // OpenCode loads its file through an `instructions` entry teamai added; + // drop it even when the member's own text keeps the file. + if (OPENCODE_CONTEXT_FILES.some((suffix) => claudeMdPath.endsWith(suffix))) { const { opencodeContextReference, reconcileOpencodeInstructions } = await import('./resources/opencode-config.js'); const { config, entry } = opencodeContextReference(claudeMdPath, plan.scope, path.dirname(path.dirname(claudeMdPath))); await reconcileOpencodeInstructions(config, entry, false, 'team instructions'); From fa89c25b90863938912a7fe32aa28821787390af Mon Sep 17 00:00:00 2001 From: Saul Moro Date: Fri, 2 Oct 2026 08:41:20 +0200 Subject: [PATCH 23/41] refactor(hooks): share one ownership check for generated extension files (#945) generatedFileState(file, marker) says whether a file teamai generates into another tool's directory is absent, teamai's, or someone else's. Pi's extension and agent hooks, the Hermes instructions plugin and the Oh My Pi extension use it on inject and remove. Oh My Pi had no check: inject overwrote and uninstall deleted a same-named ~/.omp/agent/extensions/teamai-hooks.ts of the user's. Now inject skips it with a warning and uninstall leaves it. --- CHANGELOG.md | 2 +- docs/usage-guide.md | 2 +- docs/usage-guide.zh-CN.md | 2 +- src/__tests__/omp-hooks.test.ts | 10 ++++++++++ src/hermes-hooks.ts | 4 ++-- src/omp-hooks.ts | 24 +++++++++++++++++++----- src/pi-hooks.ts | 15 +++++++-------- src/uninstall.ts | 4 ++-- src/utils/fs.ts | 14 ++++++++++++++ 9 files changed, 57 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 31d6d82d9..b217a0e96 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ All notable changes to this project will be documented in this file. See [standa ### 💥 Breaking Changes -- **Upgrade every member together: team instructions leave the shared `AGENTS.md`.** `teamai pull` no longer writes the culture, `claudemd/` and recall blocks into the project's `AGENTS.md`, `~/AGENTS.md`, `~/.agents/AGENTS.md` or another tool's file, since members with different roles resolve different `claudemd/` selections and the shared file ended up holding whoever pulled last. Each installed tool gets them in its own file or session hook: Claude Code in `.claude/rules/teamai-context.md` (project) and `~/.claude/CLAUDE.md` (user); Cursor in `teamai-context.mdc` under `.cursor/rules` and `~/.cursor/rules`; CodeBuddy and WorkBuddy in one `.codebuddy/rules/teamai-context.md`, and WorkBuddy in `~/.workbuddy/rules/teamai-context.md`; OpenCode in `.opencode/teamai-context.md` and `~/.config/opencode/teamai-context.md`, listed in its `instructions`; Oh My Pi in `~/.omp/agent/RULES.md` and, in a project, through teamai's OMP extension; Pi through teamai's Pi extension in a project; Hermes in `$HERMES_HOME/SOUL.md` and, in a project, through a Hermes plugin teamai installs and enables. Copilot keeps `.github/copilot-instructions.md`. The first pull after updating removes the blocks earlier releases left in `AGENTS.md`, `~/AGENTS.md`, `.claude/CLAUDE.md`, `.codebuddy/CODEBUDDY.md`, `.omp/AGENTS.md`, `~/.omp/agent/AGENTS.md` and a moved tool's `claudemd` from the team's `toolPaths`, keeps everything else in those files, and names each file it changes; a block with a missing or repeated marker is left with a warning. A member still on an older release writes the blocks back into the shared files, so have everyone update. Pull writes only for installed tools, rewrites nothing that is current, removes the recall block when recall is disabled, and `--dry-run` lists the files it would change. Hermes skips project instructions over 4,000 characters, which pull and `teamai doctor` report. `teamai doctor` checks that each tool can load its instructions. A team rule named `teamai-context` is not delivered, since it would land on teamai's own file. A Hermes plugin named `teamai-instructions` that teamai did not write is left alone, and a file CodeBuddy and WorkBuddy share gets the `teamai-recall` subagent block only when both have the subagent. teamai does not change `.gitignore`, `.git/info/exclude` or the index; a team that tracks a generated file such as `.github/copilot-instructions.md` still commits one member's selection (for [#945](https://github.com/Tencent/teamai-cli/issues/945)). +- **Upgrade every member together: team instructions leave the shared `AGENTS.md`.** `teamai pull` no longer writes the culture, `claudemd/` and recall blocks into the project's `AGENTS.md`, `~/AGENTS.md`, `~/.agents/AGENTS.md` or another tool's file, since members with different roles resolve different `claudemd/` selections and the shared file ended up holding whoever pulled last. Each installed tool gets them in its own file or session hook: Claude Code in `.claude/rules/teamai-context.md` (project) and `~/.claude/CLAUDE.md` (user); Cursor in `teamai-context.mdc` under `.cursor/rules` and `~/.cursor/rules`; CodeBuddy and WorkBuddy in one `.codebuddy/rules/teamai-context.md`, and WorkBuddy in `~/.workbuddy/rules/teamai-context.md`; OpenCode in `.opencode/teamai-context.md` and `~/.config/opencode/teamai-context.md`, listed in its `instructions`; Oh My Pi in `~/.omp/agent/RULES.md` and, in a project, through teamai's OMP extension; Pi through teamai's Pi extension in a project; Hermes in `$HERMES_HOME/SOUL.md` and, in a project, through a Hermes plugin teamai installs and enables. Copilot keeps `.github/copilot-instructions.md`. The first pull after updating removes the blocks earlier releases left in `AGENTS.md`, `~/AGENTS.md`, `.claude/CLAUDE.md`, `.codebuddy/CODEBUDDY.md`, `.omp/AGENTS.md`, `~/.omp/agent/AGENTS.md` and a moved tool's `claudemd` from the team's `toolPaths`, keeps everything else in those files, and names each file it changes; a block with a missing or repeated marker is left with a warning. A member still on an older release writes the blocks back into the shared files, so have everyone update. Pull writes only for installed tools, rewrites nothing that is current, removes the recall block when recall is disabled, and `--dry-run` lists the files it would change. Hermes skips project instructions over 4,000 characters, which pull and `teamai doctor` report. `teamai doctor` checks that each tool can load its instructions. A team rule named `teamai-context` is not delivered, since it would land on teamai's own file. A Hermes plugin named `teamai-instructions` or an Oh My Pi `teamai-hooks.ts` that teamai did not write is left alone, and a file CodeBuddy and WorkBuddy share gets the `teamai-recall` subagent block only when both have the subagent. teamai does not change `.gitignore`, `.git/info/exclude` or the index; a team that tracks a generated file such as `.github/copilot-instructions.md` still commits one member's selection (for [#945](https://github.com/Tencent/teamai-cli/issues/945)). - An env variable, hook or MCP server with a key its schema does not know, such as a misspelled `role:` or a hand-added `notes:`, is no longer delivered to anyone: the key used to be dropped silently, so a misspelled restriction shipped the entry to every member. `teamai pull`, the list commands, `teamai status` and `teamai doctor` name the file, the entry and the key. `teamai env add` also warns when it updates a variable carrying the unknown key; it, `teamai env remove` and `teamai remove mcp` keep the key when they rewrite the file. A key that a later version adds to these entries is unknown to this one too, so an entry that uses it is not delivered to a member still on this version: upgrade every member before the team uses a new entry key, as for a new `resources:` key (for [#822](https://github.com/Tencent/teamai-cli/issues/822)). - `manifest/projects.yaml` and `manifest/roles.yaml` now reject a resource namespace that is not a single path segment, as a project id already had to be (the id keeps its own narrower ASCII rule). A namespace becomes a directory component (`skills//`, `agents//`, `learnings//`), so `../evil`, `a/b`, `C:evil`, a bare `..`, any name with a trailing `.` or space — which Win32 strips, making `.. ` arrive as `..` and `frontend.` as `frontend` — and a Windows device name such as `CON` or `COM1` under `resources:` no longer parse; the error names the offending entry. Nothing else is rejected: a namespace that is a plain directory name still parses, non-ASCII names and names with a space included. A manifest that fails to parse now reports the offending entry on one line (`Invalid projects manifest: projects.0.resources.skills.1: ...`) instead of dumping a raw validation object. Two namespaces of one resource type that differ only by case (`frontend`, `Frontend`) are rejected too, within a manifest and between the two, since they name one directory on Windows and macOS. A manifest that ships any of these — a device name, a trailing `.`, a case-only pair — parsed before and fails every pull now; rename the directory and the entry together. - **Upgrade every member before a team declares a new axis.** `resources:` in `manifest/roles.yaml` and `manifest/projects.yaml` accepts `env`, `hooks`, `mcp`, `models` and `docs`, but teamai 0.25.0 and the 0.26.0 betas reject a `resources:` key they do not know, so a team that declares one breaks pull for every member still on those versions. From this version on, an unknown `resources:` key prints one warning naming the role or project and the key, and the scope syncs as if the key were absent; `teamai roles` and `teamai projects` keep the key when they save the manifest. `teamai roles|projects add/update --namespaces` never write the new keys, so nothing declares them until an admin does by hand (for [#707](https://github.com/Tencent/teamai-cli/issues/707)). diff --git a/docs/usage-guide.md b/docs/usage-guide.md index 173f244c1..279eee422 100644 --- a/docs/usage-guide.md +++ b/docs/usage-guide.md @@ -2232,7 +2232,7 @@ These paths are verified against the ZCode desktop app: profiles created in its ### Oh My Pi -Oh My Pi (OMP) is available as a built-in target. TeamAI deploys skills, rules, and subagents to OMP's native directories — `.omp/skills/`, `.omp/rules/`, and `.omp/agents/` at project scope, and `~/.omp/agent/skills/`, `~/.omp/agent/rules/`, and `~/.omp/agent/agents/` at user scope (user-scope resources live under the agent directory `~/.omp/agent/`, a different prefix from the project one, so TeamAI switches prefixes with the scope). Team instructions go to `~/.omp/agent/RULES.md` in user scope and, in project scope, into each turn's system prompt through the extension below (see [Where the blocks go](#where-the-blocks-go)), and MCP servers merge into `~/.omp/agent/mcp.json` / `/.omp/mcp.json` (Claude `mcpServers` shape — see the MCP section above). Skills are one-level `/SKILL.md` bundles and TeamAI fills in a `description` on sync, which OMP's native skill provider requires to discover a skill. These paths follow OMP's documented discovery layout (verified against OMP 18.2.5). Hooks ride OMP's extension runner: `teamai pull` writes a single generated extension to `~/.omp/agent/extensions/teamai-hooks.ts` (never a project copy — OMP auto-loads both roots and would double-dispatch every event), which forwards OMP's `session_start` / `session_stop` / `before_agent_start` / `tool_result` events to the same `teamai hook-dispatch` entry point every other agent uses, gated on the session `cwd`. In a project session it also asks for the member's team instructions at `session_start` and appends them to the system prompt in `before_agent_start`. Every event carries the OMP session id (`ctx.sessionManager.getSessionId()`; a subagent has its own), and `tool_result` also the tool's text output and a status from `isError`, so upvote **adoption** runs for OMP's main agent: OMP sets no session variable in its shell, so a recall joins the session of the `bash` call that ran it, and a `read` with a line selector (`x.md:50-200`, `x.md:raw`) counts as a read of the file. From OMP 18.3.2 a subagent's events also carry its `ctx.agent` id and name, so the `teamai-recall` subagent's own reads never count. A subagent's session file sits under its parent's, whose header names the parent session, so the extension links the two on the subagent's tool calls, and a doc the main agent opens after a subagent's recall is upvoted (verified against OMP 18.4.8). The `session_stop` handler returns nothing, so a dispatch can never force a session continuation, and there is no matcher-scoped post-tool-use pass because OMP's tool ids are lowercase (`bash`, `read`, …) and it has no `Skill` / `TodoWrite` tool. `teamai uninstall` removes the extension. 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. +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. `teamai uninstall` removes the extension. 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. ### DeepSeek Harness diff --git a/docs/usage-guide.zh-CN.md b/docs/usage-guide.zh-CN.md index 3a43ca7eb..41126976f 100644 --- a/docs/usage-guide.zh-CN.md +++ b/docs/usage-guide.zh-CN.md @@ -2095,7 +2095,7 @@ ZCode 已作为内置目标支持。Skills 下发到 `.zcode/skills/`(ZCode ### Oh My Pi -Oh My Pi(OMP)已作为内置目标支持。TeamAI 将 Skills、Rules 和 Subagents 下发到 OMP 的原生目录——项目级为 `.omp/skills/`、`.omp/rules/` 和 `.omp/agents/`,用户级为 `~/.omp/agent/skills/`、`~/.omp/agent/rules/` 和 `~/.omp/agent/agents/`(用户级资源位于 agent 目录 `~/.omp/agent/` 下,与项目级前缀不同,TeamAI 会随作用域自动切换)。团队指令在用户范围写入 `~/.omp/agent/RULES.md`,在项目范围由下文的 extension 加入每轮的系统提示(见[这些块写到哪里](#这些块写到哪里));MCP Server 合并进 `~/.omp/agent/mcp.json` / `/.omp/mcp.json`(Claude `mcpServers` 结构,见上文 MCP 章节)。Skills 采用一层 `/SKILL.md` 目录结构,TeamAI 在同步时补全 `description`——OMP 原生 skill 发现要求该字段。以上路径遵循 OMP 官方文档的发现布局(对照 OMP 18.2.5 验证)。Hooks 走 OMP 的 extension runner:`teamai pull` 会生成唯一的 extension 写入 `~/.omp/agent/extensions/teamai-hooks.ts`(绝不写项目副本——OMP 会同时加载两个根并导致每个事件双派发),它把 OMP 的 `session_start` / `session_stop` / `before_agent_start` / `tool_result` 事件转发给所有 agent 共用的 `teamai hook-dispatch` 入口,并按会话 `cwd` 做项目门控。在项目会话中,它还会在 `session_start` 时获取成员的团队指令,并在 `before_agent_start` 中追加到系统提示。每个事件都携带 OMP 会话 id(`ctx.sessionManager.getSessionId()`;subagent 有自己的会话),`tool_result` 还带上工具的文本输出和根据 `isError` 得出的状态,因此 upvote **采纳(adoption)**在 OMP 主 agent 上生效:OMP 不在其 shell 中设置会话变量,所以 recall 归入运行它的那次 `bash` 调用所在的会话;带行选择器的 `read`(`x.md:50-200`、`x.md:raw`)计为对该文件的读取。从 OMP 18.3.2 起,subagent 的事件还会携带其 `ctx.agent` 的 id 和名称,因此 `teamai-recall` subagent 自身的读取从不计入。subagent 的会话文件位于父会话文件之下,父会话文件的头部写明父会话 id,因此 extension 会在 subagent 的工具调用中关联这两个会话,主 agent 在 subagent recall 之后打开的文档会被 upvote(对照 OMP 18.4.8 验证)。`session_stop` 处理器不返回任何值,分发绝不会强制会话继续;由于 OMP 的工具名是小写(`bash`、`read` 等)且没有 `Skill` / `TodoWrite` 工具,post-tool-use 不做 matcher 定向分发。`teamai uninstall` 会移除该 extension。OMP 的 profile(`OMP_PROFILE` / `PI_CODING_AGENT_DIR` / `PI_CONFIG_DIR`,会迁移 agent 目录)暂不支持,使用默认的 `~/.omp/agent/` 布局。 +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/` 布局。 ### DeepSeek Harness diff --git a/src/__tests__/omp-hooks.test.ts b/src/__tests__/omp-hooks.test.ts index d6f83baf3..48113ff8f 100644 --- a/src/__tests__/omp-hooks.test.ts +++ b/src/__tests__/omp-hooks.test.ts @@ -130,6 +130,16 @@ describe('injectOmpHooks / removeOmpHooks', () => { await removeOmpHooks(); expect(await fse.pathExists(extFile())).toBe(false); }); + + it('leaves a same-named extension teamai did not write alone on inject and remove', async () => { + await fse.outputFile(extFile(), 'export default function mine() {}\n'); + + await injectOmpHooks(); + expect(log.warn).toHaveBeenCalledWith(expect.stringContaining(`${extFile()} exists without the TeamAI marker`)); + await removeOmpHooks(); + + expect(await fse.readFile(extFile(), 'utf8')).toBe('export default function mine() {}\n'); + }); }); describe('reconcileHooksToAllTools routes omp to the extension adapter', () => { diff --git a/src/hermes-hooks.ts b/src/hermes-hooks.ts index 97ff4c29b..be3a7d5d8 100644 --- a/src/hermes-hooks.ts +++ b/src/hermes-hooks.ts @@ -10,7 +10,7 @@ import path from 'node:path'; import { chmod } from 'node:fs/promises'; -import { writeIfChanged, pathExists, readFileSafe, remove } from './utils/fs.js'; +import { generatedFileState, writeIfChanged, pathExists, remove } from './utils/fs.js'; import { log } from './utils/logger.js'; import { getHermesHome } from './hermes-home.js'; import { @@ -49,7 +49,7 @@ const PLUGIN_MARKER = '# [teamai] generated by teamai'; export async function ownsInstructionsPlugin(): Promise { const dir = getInstructionsPluginDir(); if (!await pathExists(dir)) return true; - return (await readFileSafe(path.join(dir, 'plugin.yaml')))?.startsWith(PLUGIN_MARKER) ?? false; + return await generatedFileState(path.join(dir, 'plugin.yaml'), PLUGIN_MARKER) === 'teamai'; } /** Why teamai left the plugin directory alone, and what to do. */ diff --git a/src/omp-hooks.ts b/src/omp-hooks.ts index e94b664a2..2eed0dac9 100644 --- a/src/omp-hooks.ts +++ b/src/omp-hooks.ts @@ -36,7 +36,7 @@ */ import path from 'node:path'; -import { writeIfChanged, pathExists, remove } from './utils/fs.js'; +import { generatedFileState, writeIfChanged, remove } from './utils/fs.js'; import { getUserHome } from './utils/home.js'; import { log } from './utils/logger.js'; @@ -245,6 +245,10 @@ export default function teamaiHooks(pi) { */ export async function injectOmpHooks(): Promise { const file = path.join(resolveOmpExtensionsDir(), OMP_HOOK_FILE); + if (await hasForeignOmpHooks()) { + log.warn(`Skipping OMP hook injection: ${file} exists without the TeamAI marker`); + return; + } if (await writeIfChanged(file, buildOmpExtensionSource())) { log.success(`Injected teamai OMP hook into ${file}`); } else { @@ -255,8 +259,18 @@ export async function injectOmpHooks(): Promise { /** Remove the teamai OMP extension if present. */ export async function removeOmpHooks(): Promise { const file = path.join(resolveOmpExtensionsDir(), OMP_HOOK_FILE); - if (await pathExists(file)) { - await remove(file); - log.success(`Removed teamai OMP hook from ${file}`); - } + if (!await hasOmpHooks()) return; + await remove(file); + log.success(`Removed teamai OMP hook from ${file}`); +} + +const ompHookState = () => generatedFileState(path.join(resolveOmpExtensionsDir(), OMP_HOOK_FILE), `${TEAMAI_MARKER} hooks extension`); + +/** Whether the OMP extension file is teamai's. */ +export async function hasOmpHooks(): Promise { + return await ompHookState() === 'teamai'; +} + +async function hasForeignOmpHooks(): Promise { + return await ompHookState() === 'foreign'; } diff --git a/src/pi-hooks.ts b/src/pi-hooks.ts index aa371177d..308070888 100644 --- a/src/pi-hooks.ts +++ b/src/pi-hooks.ts @@ -25,7 +25,7 @@ */ import path from 'node:path'; -import { writeFile, writeIfChanged, ensureDir, pathExists, remove, readFileSafe } from './utils/fs.js'; +import { generatedFileState, writeFile, writeIfChanged, ensureDir, remove } from './utils/fs.js'; import { getUserHome } from './utils/home.js'; import { log } from './utils/logger.js'; @@ -198,7 +198,7 @@ export default function teamaiHooks(pi) { export async function injectPiHooks(): Promise { const dir = resolvePiExtensionsDir(); const file = path.join(dir, PI_HOOK_FILE); - if (await pathExists(file) && !await hasPiHooks()) { + if (await generatedFileState(file, `${TEAMAI_MARKER} hooks extension`) === 'foreign') { log.warn(`Skipping Pi hook injection: ${file} exists without the TeamAI marker`); return; } @@ -275,10 +275,11 @@ function piAgentHookFile(slug: string): string { * on this file must never touch a same-named file a user authored by hand. */ export async function hasPiAgentHook(slug: string): Promise { - const content = await readFileSafe(piAgentHookFile(slug)); - return content?.includes(`${TEAMAI_MARKER} agent hook [${slug}]`) ?? false; + return await piAgentHookState(slug) === 'teamai'; } +const piAgentHookState = (slug: string) => generatedFileState(piAgentHookFile(slug), `${TEAMAI_MARKER} agent hook [${slug}]`); + /** * Install one HTTP-source agent hook as a Pi extension. * @@ -301,7 +302,7 @@ export async function applyPiAgentHook(def: { throw new Error(`Pi does not support event "${def.event}" — skipping hook [${def.slug}]`); } const file = piAgentHookFile(def.slug); - if (await pathExists(file) && !await hasPiAgentHook(def.slug)) { + if (await piAgentHookState(def.slug) === 'foreign') { throw new Error(`Skipping Pi agent hook [${def.slug}]: ${file} exists without the TeamAI marker`); } await ensureDir(resolvePiExtensionsDir()); @@ -320,7 +321,5 @@ export async function removePiAgentHook(slug: string): Promise { /** Check whether a global or legacy project extension has TeamAI's marker. */ export async function hasPiHooks(baseDir?: string): Promise { const file = path.join(baseDir ? resolvePiProjectExtensionsDir(baseDir) : resolvePiExtensionsDir(), PI_HOOK_FILE); - if (!await pathExists(file)) return false; - const content = await readFileSafe(file); - return content?.includes(`${TEAMAI_MARKER} hooks extension`) ?? false; + return await generatedFileState(file, `${TEAMAI_MARKER} hooks extension`) === 'teamai'; } diff --git a/src/uninstall.ts b/src/uninstall.ts index fbe47e1b3..62d2aa2ba 100644 --- a/src/uninstall.ts +++ b/src/uninstall.ts @@ -363,9 +363,9 @@ async function discoverToolResources( // OMP hooks are a single teamai-managed TS extension in the user agent dir // (~/.omp/agent/extensions/teamai-hooks.ts) — the adapter never writes a // project copy, so there is just the one place to look. - const { resolveOmpExtensionsDir, OMP_HOOK_FILE } = await import('./omp-hooks.js'); + const { hasOmpHooks, resolveOmpExtensionsDir, OMP_HOOK_FILE } = await import('./omp-hooks.js'); const extFile = path.join(resolveOmpExtensionsDir(), OMP_HOOK_FILE); - if (await pathExists(extFile)) { + if (await hasOmpHooks()) { res.ompHookFile = extFile; } } else if (tool === 'pi') { diff --git a/src/utils/fs.ts b/src/utils/fs.ts index f298c83cc..d6f8d79f7 100644 --- a/src/utils/fs.ts +++ b/src/utils/fs.ts @@ -337,6 +337,20 @@ export async function pathExists(p: string): Promise { return fse.pathExists(expandHome(p)); } +/** + * Who wrote a file teamai generates into another tool's directory: nobody yet, + * teamai (its content carries `marker`), or someone else. teamai writes and + * removes only `absent` and `teamai` files, so a same-named file of the + * user's is never overwritten or deleted. + */ +export type GeneratedFileState = 'absent' | 'teamai' | 'foreign'; + +export async function generatedFileState(file: string, marker: string): Promise { + const content = await readFileSafe(file); + if (content === null) return await pathExists(file) ? 'foreign' : 'absent'; + return content.includes(marker) ? 'teamai' : 'foreign'; +} + /** * Remove a file or directory */ From 0d208ec874f04b349e4aff81edbdde79ebf756eb Mon Sep 17 00:00:00 2001 From: Saul Moro Date: Fri, 2 Oct 2026 08:55:36 +0200 Subject: [PATCH 24/41] fix(pull): keep the configured claudemd for a tool without a rules directory (#945) A team toolPaths entry may omit rules, and an entry does not inherit the defaults. Claude Code (project), Cursor and WorkBuddy (user) then had no teamai-context target while their configured claudemd counted as retired, so pull removed the blocks and delivered them nowhere. contextRule falls back to the configured claudemd; a claudemd is retired only when it is not the tool's current target; and only a teamai-context file takes the entry's header and teamai ownership, so the configured file stays the member's. --- CHANGELOG.md | 2 +- docs/usage-guide.md | 2 ++ docs/usage-guide.zh-CN.md | 2 ++ src/__tests__/instruction-targets.test.ts | 29 +++++++++++++++++++++++ src/instruction-targets.ts | 19 +++++++++++---- 5 files changed, 48 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b217a0e96..14ac997e9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ All notable changes to this project will be documented in this file. See [standa ### 💥 Breaking Changes -- **Upgrade every member together: team instructions leave the shared `AGENTS.md`.** `teamai pull` no longer writes the culture, `claudemd/` and recall blocks into the project's `AGENTS.md`, `~/AGENTS.md`, `~/.agents/AGENTS.md` or another tool's file, since members with different roles resolve different `claudemd/` selections and the shared file ended up holding whoever pulled last. Each installed tool gets them in its own file or session hook: Claude Code in `.claude/rules/teamai-context.md` (project) and `~/.claude/CLAUDE.md` (user); Cursor in `teamai-context.mdc` under `.cursor/rules` and `~/.cursor/rules`; CodeBuddy and WorkBuddy in one `.codebuddy/rules/teamai-context.md`, and WorkBuddy in `~/.workbuddy/rules/teamai-context.md`; OpenCode in `.opencode/teamai-context.md` and `~/.config/opencode/teamai-context.md`, listed in its `instructions`; Oh My Pi in `~/.omp/agent/RULES.md` and, in a project, through teamai's OMP extension; Pi through teamai's Pi extension in a project; Hermes in `$HERMES_HOME/SOUL.md` and, in a project, through a Hermes plugin teamai installs and enables. Copilot keeps `.github/copilot-instructions.md`. The first pull after updating removes the blocks earlier releases left in `AGENTS.md`, `~/AGENTS.md`, `.claude/CLAUDE.md`, `.codebuddy/CODEBUDDY.md`, `.omp/AGENTS.md`, `~/.omp/agent/AGENTS.md` and a moved tool's `claudemd` from the team's `toolPaths`, keeps everything else in those files, and names each file it changes; a block with a missing or repeated marker is left with a warning. A member still on an older release writes the blocks back into the shared files, so have everyone update. Pull writes only for installed tools, rewrites nothing that is current, removes the recall block when recall is disabled, and `--dry-run` lists the files it would change. Hermes skips project instructions over 4,000 characters, which pull and `teamai doctor` report. `teamai doctor` checks that each tool can load its instructions. A team rule named `teamai-context` is not delivered, since it would land on teamai's own file. A Hermes plugin named `teamai-instructions` or an Oh My Pi `teamai-hooks.ts` that teamai did not write is left alone, and a file CodeBuddy and WorkBuddy share gets the `teamai-recall` subagent block only when both have the subagent. teamai does not change `.gitignore`, `.git/info/exclude` or the index; a team that tracks a generated file such as `.github/copilot-instructions.md` still commits one member's selection (for [#945](https://github.com/Tencent/teamai-cli/issues/945)). +- **Upgrade every member together: team instructions leave the shared `AGENTS.md`.** `teamai pull` no longer writes the culture, `claudemd/` and recall blocks into the project's `AGENTS.md`, `~/AGENTS.md`, `~/.agents/AGENTS.md` or another tool's file, since members with different roles resolve different `claudemd/` selections and the shared file ended up holding whoever pulled last. Each installed tool gets them in its own file or session hook: Claude Code in `.claude/rules/teamai-context.md` (project) and `~/.claude/CLAUDE.md` (user); Cursor in `teamai-context.mdc` under `.cursor/rules` and `~/.cursor/rules`; CodeBuddy and WorkBuddy in one `.codebuddy/rules/teamai-context.md`, and WorkBuddy in `~/.workbuddy/rules/teamai-context.md`; OpenCode in `.opencode/teamai-context.md` and `~/.config/opencode/teamai-context.md`, listed in its `instructions`; Oh My Pi in `~/.omp/agent/RULES.md` and, in a project, through teamai's OMP extension; Pi through teamai's Pi extension in a project; Hermes in `$HERMES_HOME/SOUL.md` and, in a project, through a Hermes plugin teamai installs and enables. Copilot keeps `.github/copilot-instructions.md`. The first pull after updating removes the blocks earlier releases left in `AGENTS.md`, `~/AGENTS.md`, `.claude/CLAUDE.md`, `.codebuddy/CODEBUDDY.md`, `.omp/AGENTS.md`, `~/.omp/agent/AGENTS.md` and a moved tool's `claudemd` from the team's `toolPaths`, keeps everything else in those files, and names each file it changes; a block with a missing or repeated marker is left with a warning. A member still on an older release writes the blocks back into the shared files, so have everyone update. Pull writes only for installed tools, rewrites nothing that is current, removes the recall block when recall is disabled, and `--dry-run` lists the files it would change. Hermes skips project instructions over 4,000 characters, which pull and `teamai doctor` report. `teamai doctor` checks that each tool can load its instructions. A team `toolPaths` entry without `rules` keeps its configured `claudemd` for Claude Code, Cursor and WorkBuddy. A team rule named `teamai-context` is not delivered, since it would land on teamai's own file. A Hermes plugin named `teamai-instructions` or an Oh My Pi `teamai-hooks.ts` that teamai did not write is left alone, and a file CodeBuddy and WorkBuddy share gets the `teamai-recall` subagent block only when both have the subagent. teamai does not change `.gitignore`, `.git/info/exclude` or the index; a team that tracks a generated file such as `.github/copilot-instructions.md` still commits one member's selection (for [#945](https://github.com/Tencent/teamai-cli/issues/945)). - An env variable, hook or MCP server with a key its schema does not know, such as a misspelled `role:` or a hand-added `notes:`, is no longer delivered to anyone: the key used to be dropped silently, so a misspelled restriction shipped the entry to every member. `teamai pull`, the list commands, `teamai status` and `teamai doctor` name the file, the entry and the key. `teamai env add` also warns when it updates a variable carrying the unknown key; it, `teamai env remove` and `teamai remove mcp` keep the key when they rewrite the file. A key that a later version adds to these entries is unknown to this one too, so an entry that uses it is not delivered to a member still on this version: upgrade every member before the team uses a new entry key, as for a new `resources:` key (for [#822](https://github.com/Tencent/teamai-cli/issues/822)). - `manifest/projects.yaml` and `manifest/roles.yaml` now reject a resource namespace that is not a single path segment, as a project id already had to be (the id keeps its own narrower ASCII rule). A namespace becomes a directory component (`skills//`, `agents//`, `learnings//`), so `../evil`, `a/b`, `C:evil`, a bare `..`, any name with a trailing `.` or space — which Win32 strips, making `.. ` arrive as `..` and `frontend.` as `frontend` — and a Windows device name such as `CON` or `COM1` under `resources:` no longer parse; the error names the offending entry. Nothing else is rejected: a namespace that is a plain directory name still parses, non-ASCII names and names with a space included. A manifest that fails to parse now reports the offending entry on one line (`Invalid projects manifest: projects.0.resources.skills.1: ...`) instead of dumping a raw validation object. Two namespaces of one resource type that differ only by case (`frontend`, `Frontend`) are rejected too, within a manifest and between the two, since they name one directory on Windows and macOS. A manifest that ships any of these — a device name, a trailing `.`, a case-only pair — parsed before and fails every pull now; rename the directory and the entry together. - **Upgrade every member before a team declares a new axis.** `resources:` in `manifest/roles.yaml` and `manifest/projects.yaml` accepts `env`, `hooks`, `mcp`, `models` and `docs`, but teamai 0.25.0 and the 0.26.0 betas reject a `resources:` key they do not know, so a team that declares one breaks pull for every member still on those versions. From this version on, an unknown `resources:` key prints one warning naming the role or project and the key, and the scope syncs as if the key were absent; `teamai roles` and `teamai projects` keep the key when they save the manifest. `teamai roles|projects add/update --namespaces` never write the new keys, so nothing declares them until an admin does by hand (for [#707](https://github.com/Tencent/teamai-cli/issues/707)). diff --git a/docs/usage-guide.md b/docs/usage-guide.md index 279eee422..3347429a3 100644 --- a/docs/usage-guide.md +++ b/docs/usage-guide.md @@ -1662,6 +1662,8 @@ Two members of the same project can have different roles, so their shared instru | Pi | `~/.pi/agent/AGENTS.md` | Added to each run's system prompt by teamai's Pi extension | | 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 and WorkBuddy, which have no rules directory to take a `teamai-context` file. + *Unverified*: built from the tool's documented or source-read loader, not yet checked in a live session. Claude Code, Oh My Pi, OpenCode and Pi (project scope) were checked in live sessions, from the project root and a subdirectory. A tool with the `teamai-recall` subagent gets a recall block that calls it; a tool without one (Pi, Hermes, OpenClaw) gets a recall block that tells the agent to run `teamai recall` directly. A file several tools share gets the subagent block only when every one of them has the subagent. Claude Code loads `.claude/rules/teamai-context.md` from the project root and any subdirectory, and still reads the project's `AGENTS.md` or authored `CLAUDE.md` the way it chose to. Copilot CLI 1.0.89 and later also reads a project's `.claude/rules`, so with both tools installed Copilot can get the blocks twice. diff --git a/docs/usage-guide.zh-CN.md b/docs/usage-guide.zh-CN.md index 41126976f..b6554ec66 100644 --- a/docs/usage-guide.zh-CN.md +++ b/docs/usage-guide.zh-CN.md @@ -1540,6 +1540,8 @@ pull 只把团队文化、共享指令和 recall 块写入已安装 AI 工具的 | Pi | `~/.pi/agent/AGENTS.md` | 由 teamai 的 Pi 扩展加入每次运行的系统提示 | | Hermes | `$HERMES_HOME/SOUL.md` 中的一个块,位于团队规则块旁(未验证) | teamai 的 Hermes 插件提供的系统提示段落(未验证) | +团队 `toolPaths` 中没有 `rules` 的条目,Claude Code、Cursor 和 WorkBuddy 继续使用其配置的 `claudemd`,因为它们没有可放置 `teamai-context` 文件的 rules 目录。 + *未验证*:依据工具的文档或源码中的加载逻辑实现,尚未在实际会话中检查。Claude Code、Oh My Pi、OpenCode 和 Pi(项目范围)已在实际会话中从项目根目录和子目录检查过。有 `teamai-recall` subagent 的工具获得调用该 subagent 的 recall 块;没有的工具(Pi、Hermes、OpenClaw)获得提示 agent 直接运行 `teamai recall` 的 recall 块。多个工具共用的文件只有在每个工具都有该 subagent 时才获得 subagent 块。 Claude Code 会从项目根目录和任意子目录加载 `.claude/rules/teamai-context.md`,并照常读取项目的 `AGENTS.md` 或项目自己编写的 `CLAUDE.md`。Copilot CLI 1.0.89 及更高版本也会读取项目的 `.claude/rules`,因此同时安装这两个工具时,Copilot 可能会读到两份。 diff --git a/src/__tests__/instruction-targets.test.ts b/src/__tests__/instruction-targets.test.ts index 28bee03a9..12da6079d 100644 --- a/src/__tests__/instruction-targets.test.ts +++ b/src/__tests__/instruction-targets.test.ts @@ -226,3 +226,32 @@ describe('instruction targets shared by several tools (#945)', () => { } }); }); + +describe('a tool configured without a rules directory (#945)', () => { + it('keeps the configured claudemd as its target, as the member\'s own file, instead of retiring it', async () => { + const root = fs.realpathSync.native(fs.mkdtempSync(path.join(os.tmpdir(), 'teamai-945-norules-'))); + try { + const projectRoot = path.join(root, 'project'); + fs.mkdirSync(path.join(projectRoot, '.claude'), { recursive: true }); + fs.writeFileSync(path.join(projectRoot, '.claude', 'settings.json'), '{}\n'); + const localConfig = { + repo: { localPath: path.join(root, 'repo'), remote: 'https://example.invalid/t.git' }, + username: 'u', additionalRoles: [], scope: 'project', projectRoot, + } as unknown as LocalConfig; + const teamConfig = TeamaiConfigSchema.parse({ + team: 't', + repo: 'https://example.invalid/t.git', + toolPaths: { claude: { settings: '.claude/settings.json', claudemd: '.claude/CLAUDE.md' } }, + }); + + const { targets, stale } = await resolveInstructionTargets(teamConfig, localConfig); + + const file = path.join(projectRoot, '.claude', 'CLAUDE.md'); + expect(targets).toEqual([expect.objectContaining({ path: file, tools: ['claude'], header: undefined, owned: undefined })]); + expect(stale.map((t) => t.path)).not.toContain(file); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }); +}); + diff --git a/src/instruction-targets.ts b/src/instruction-targets.ts index 7c1b6d77f..da846c8e8 100644 --- a/src/instruction-targets.ts +++ b/src/instruction-targets.ts @@ -64,9 +64,13 @@ interface TargetEntry { /** The tool's `claudemd` path from the team's `toolPaths` (honors `toolRoots`). */ const configured = (paths: ToolPaths): string | undefined => paths.claudemd; -/** teamai's own always-applied file in the tool's rules directory. */ +/** + * 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 + * the member's file there, not teamai's. + */ const contextRule = (extension: string) => (paths: ToolPaths): string | undefined => - paths.rules === undefined ? undefined : path.posix.join(paths.rules, `${TEAMAI_CONTEXT_RULE_NAME}${extension}`); + paths.rules === undefined ? paths.claudemd : path.posix.join(paths.rules, `${TEAMAI_CONTEXT_RULE_NAME}${extension}`); /** * Cursor applies an `.mdc` rule in every session only with this frontmatter; @@ -217,7 +221,7 @@ export function instructionTargetFile(tool: string, paths: ToolPaths, scope: Sco export function retiredInstructionFiles(tool: string, paths: ToolPaths, scope: Scope): readonly string[] { const entry = entryFor(tool, scope); if (!entry) return []; - const previous = entry.file === configured ? undefined : paths.claudemd; + const previous = paths.claudemd === instructionTargetFile(tool, paths, scope) ? undefined : paths.claudemd; return previous === undefined || entry.retired.includes(previous) ? entry.retired : [...entry.retired, previous]; } @@ -333,10 +337,15 @@ export function instructionTargetPath( return file === undefined ? undefined : path.resolve(resolveToolBaseDir(tool, localConfig), file); } -/** The target `tool` reads from `file` in `scope`, with the header and ownership its entry declares. */ +/** + * The target `tool` reads from `file` in `scope`, with the header and + * ownership its entry declares. Only teamai's `teamai-context` file takes + * them; a configured file is the member's. + */ export function instructionTargetAt(tool: string, file: string, scope: Scope): InstructionTarget { const entry = entryFor(tool, scope); - return { path: file, tools: [], recall: false, header: entry?.header, owned: entry?.owned }; + const own = path.basename(file).startsWith(`${TEAMAI_CONTEXT_RULE_NAME}.`); + return { path: file, tools: [], recall: false, header: own ? entry?.header : undefined, owned: own ? entry?.owned : undefined }; } /** From 5a87d296360bf0e68f92952721fa6c5eef0167cd Mon Sep 17 00:00:00 2001 From: Saul Moro Date: Fri, 2 Oct 2026 09:17:15 +0200 Subject: [PATCH 25/41] fix(pull): address review round 7 of #945 - A copy of a team rule named teamai-context an earlier release delivered to a rules directory is removed when the record shows it unchanged or it matches the team rule's render, so the instructions can take that path; an edited copy stays and the instruction sync names it. - A toolPaths entry with only claudemd is probed through that file's directory, and a bare file such as AGENTS.md counts as installed, as before. - Project CodeBuddy and WorkBuddy keep their configured claudemd when the entry has no rules, like Claude Code and Cursor. - The HTTP local agent's claudemd sync strips the blocks earlier releases left in files no installed tool reads now, as pull does. - Uninstall drops teamai's OpenCode instructions entry whenever the config lists it, also after the context file was deleted or stripped. --- CHANGELOG.md | 2 +- docs/usage-guide.md | 6 +-- docs/usage-guide.zh-CN.md | 6 +-- src/__tests__/instruction-targets.test.ts | 48 ++++++++++++++++++++++ src/__tests__/local-agent.test.ts | 15 +++++++ src/__tests__/rules.test.ts | 21 ++++++++++ src/__tests__/uninstall.test.ts | 24 +++++++++++ src/instruction-targets.ts | 16 +++++--- src/local-agent.ts | 23 +++++++++-- src/resources/rules.ts | 36 +++++++++++++++++ src/uninstall.ts | 49 ++++++++++++++++------- 11 files changed, 216 insertions(+), 30 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 14ac997e9..9283f8e1d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ All notable changes to this project will be documented in this file. See [standa ### 💥 Breaking Changes -- **Upgrade every member together: team instructions leave the shared `AGENTS.md`.** `teamai pull` no longer writes the culture, `claudemd/` and recall blocks into the project's `AGENTS.md`, `~/AGENTS.md`, `~/.agents/AGENTS.md` or another tool's file, since members with different roles resolve different `claudemd/` selections and the shared file ended up holding whoever pulled last. Each installed tool gets them in its own file or session hook: Claude Code in `.claude/rules/teamai-context.md` (project) and `~/.claude/CLAUDE.md` (user); Cursor in `teamai-context.mdc` under `.cursor/rules` and `~/.cursor/rules`; CodeBuddy and WorkBuddy in one `.codebuddy/rules/teamai-context.md`, and WorkBuddy in `~/.workbuddy/rules/teamai-context.md`; OpenCode in `.opencode/teamai-context.md` and `~/.config/opencode/teamai-context.md`, listed in its `instructions`; Oh My Pi in `~/.omp/agent/RULES.md` and, in a project, through teamai's OMP extension; Pi through teamai's Pi extension in a project; Hermes in `$HERMES_HOME/SOUL.md` and, in a project, through a Hermes plugin teamai installs and enables. Copilot keeps `.github/copilot-instructions.md`. The first pull after updating removes the blocks earlier releases left in `AGENTS.md`, `~/AGENTS.md`, `.claude/CLAUDE.md`, `.codebuddy/CODEBUDDY.md`, `.omp/AGENTS.md`, `~/.omp/agent/AGENTS.md` and a moved tool's `claudemd` from the team's `toolPaths`, keeps everything else in those files, and names each file it changes; a block with a missing or repeated marker is left with a warning. A member still on an older release writes the blocks back into the shared files, so have everyone update. Pull writes only for installed tools, rewrites nothing that is current, removes the recall block when recall is disabled, and `--dry-run` lists the files it would change. Hermes skips project instructions over 4,000 characters, which pull and `teamai doctor` report. `teamai doctor` checks that each tool can load its instructions. A team `toolPaths` entry without `rules` keeps its configured `claudemd` for Claude Code, Cursor and WorkBuddy. A team rule named `teamai-context` is not delivered, since it would land on teamai's own file. A Hermes plugin named `teamai-instructions` or an Oh My Pi `teamai-hooks.ts` that teamai did not write is left alone, and a file CodeBuddy and WorkBuddy share gets the `teamai-recall` subagent block only when both have the subagent. teamai does not change `.gitignore`, `.git/info/exclude` or the index; a team that tracks a generated file such as `.github/copilot-instructions.md` still commits one member's selection (for [#945](https://github.com/Tencent/teamai-cli/issues/945)). +- **Upgrade every member together: team instructions leave the shared `AGENTS.md`.** `teamai pull` no longer writes the culture, `claudemd/` and recall blocks into the project's `AGENTS.md`, `~/AGENTS.md`, `~/.agents/AGENTS.md` or another tool's file, since members with different roles resolve different `claudemd/` selections and the shared file ended up holding whoever pulled last. Each installed tool gets them in its own file or session hook: Claude Code in `.claude/rules/teamai-context.md` (project) and `~/.claude/CLAUDE.md` (user); Cursor in `teamai-context.mdc` under `.cursor/rules` and `~/.cursor/rules`; CodeBuddy and WorkBuddy in one `.codebuddy/rules/teamai-context.md`, and WorkBuddy in `~/.workbuddy/rules/teamai-context.md`; OpenCode in `.opencode/teamai-context.md` and `~/.config/opencode/teamai-context.md`, listed in its `instructions`; Oh My Pi in `~/.omp/agent/RULES.md` and, in a project, through teamai's OMP extension; Pi through teamai's Pi extension in a project; Hermes in `$HERMES_HOME/SOUL.md` and, in a project, through a Hermes plugin teamai installs and enables. Copilot keeps `.github/copilot-instructions.md`. The first pull after updating removes the blocks earlier releases left in `AGENTS.md`, `~/AGENTS.md`, `.claude/CLAUDE.md`, `.codebuddy/CODEBUDDY.md`, `.omp/AGENTS.md`, `~/.omp/agent/AGENTS.md` and a moved tool's `claudemd` from the team's `toolPaths`, keeps everything else in those files, and names each file it changes; a block with a missing or repeated marker is left with a warning. A member still on an older release writes the blocks back into the shared files, so have everyone update. Pull writes only for installed tools, rewrites nothing that is current, removes the recall block when recall is disabled, and `--dry-run` lists the files it would change. Hermes skips project instructions over 4,000 characters, which pull and `teamai doctor` report. `teamai doctor` checks that each tool can load its instructions. A team `toolPaths` entry without `rules` keeps its configured `claudemd` for Claude Code, Cursor, CodeBuddy and WorkBuddy, and an entry with only `claudemd` is still delivered to. A team rule named `teamai-context` is not delivered, since it would land on teamai's own file, and a copy an earlier release delivered is removed unless the member changed it. The HTTP local agent strips the old blocks too. A Hermes plugin named `teamai-instructions` or an Oh My Pi `teamai-hooks.ts` that teamai did not write is left alone, and a file CodeBuddy and WorkBuddy share gets the `teamai-recall` subagent block only when both have the subagent. teamai does not change `.gitignore`, `.git/info/exclude` or the index; a team that tracks a generated file such as `.github/copilot-instructions.md` still commits one member's selection (for [#945](https://github.com/Tencent/teamai-cli/issues/945)). - An env variable, hook or MCP server with a key its schema does not know, such as a misspelled `role:` or a hand-added `notes:`, is no longer delivered to anyone: the key used to be dropped silently, so a misspelled restriction shipped the entry to every member. `teamai pull`, the list commands, `teamai status` and `teamai doctor` name the file, the entry and the key. `teamai env add` also warns when it updates a variable carrying the unknown key; it, `teamai env remove` and `teamai remove mcp` keep the key when they rewrite the file. A key that a later version adds to these entries is unknown to this one too, so an entry that uses it is not delivered to a member still on this version: upgrade every member before the team uses a new entry key, as for a new `resources:` key (for [#822](https://github.com/Tencent/teamai-cli/issues/822)). - `manifest/projects.yaml` and `manifest/roles.yaml` now reject a resource namespace that is not a single path segment, as a project id already had to be (the id keeps its own narrower ASCII rule). A namespace becomes a directory component (`skills//`, `agents//`, `learnings//`), so `../evil`, `a/b`, `C:evil`, a bare `..`, any name with a trailing `.` or space — which Win32 strips, making `.. ` arrive as `..` and `frontend.` as `frontend` — and a Windows device name such as `CON` or `COM1` under `resources:` no longer parse; the error names the offending entry. Nothing else is rejected: a namespace that is a plain directory name still parses, non-ASCII names and names with a space included. A manifest that fails to parse now reports the offending entry on one line (`Invalid projects manifest: projects.0.resources.skills.1: ...`) instead of dumping a raw validation object. Two namespaces of one resource type that differ only by case (`frontend`, `Frontend`) are rejected too, within a manifest and between the two, since they name one directory on Windows and macOS. A manifest that ships any of these — a device name, a trailing `.`, a case-only pair — parsed before and fails every pull now; rename the directory and the entry together. - **Upgrade every member before a team declares a new axis.** `resources:` in `manifest/roles.yaml` and `manifest/projects.yaml` accepts `env`, `hooks`, `mcp`, `models` and `docs`, but teamai 0.25.0 and the 0.26.0 betas reject a `resources:` key they do not know, so a team that declares one breaks pull for every member still on those versions. From this version on, an unknown `resources:` key prints one warning naming the role or project and the key, and the scope syncs as if the key were absent; `teamai roles` and `teamai projects` keep the key when they save the manifest. `teamai roles|projects add/update --namespaces` never write the new keys, so nothing declares them until an admin does by hand (for [#707](https://github.com/Tencent/teamai-cli/issues/707)). diff --git a/docs/usage-guide.md b/docs/usage-guide.md index 3347429a3..16727949c 100644 --- a/docs/usage-guide.md +++ b/docs/usage-guide.md @@ -1662,7 +1662,7 @@ Two members of the same project can have different roles, so their shared instru | Pi | `~/.pi/agent/AGENTS.md` | Added to each run's system prompt by teamai's Pi extension | | 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 and WorkBuddy, which have no rules directory to take a `teamai-context` file. +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`. *Unverified*: built from the tool's documented or source-read loader, not yet checked in a live session. Claude Code, Oh My Pi, OpenCode and Pi (project scope) were checked in live sessions, from the project root and a subdirectory. A tool with the `teamai-recall` subagent gets a recall block that calls it; a tool without one (Pi, Hermes, OpenClaw) gets a recall block that tells the agent to run `teamai recall` directly. A file several tools share gets the subagent block only when every one of them has the subagent. @@ -1691,7 +1691,7 @@ A pull from an earlier release may have left these blocks in a file listed below `teamai doctor` checks that each installed tool can load these blocks: that each file holds the current blocks, that OpenCode's config lists its file, that the Pi or Oh My Pi extension and the Hermes plugin are installed and enabled, that the Hermes section fits its limit, and that no file an earlier release wrote still holds blocks. -A file named like a teamai target that teamai did not write is left alone and not listed in OpenCode's `instructions`, and the pull warns about it. A team rule named `teamai-context` is not delivered, since it would land on that file; the pull names it. teamai does not change `.gitignore`, `.git/info/exclude` or the git index. A team that wants to keep these files out of commits excludes them itself. +A file named like a teamai target that teamai did not write is left alone and not listed in OpenCode's `instructions`, and the pull warns about it. A team rule named `teamai-context` is not delivered, since it would land on that file; the pull names it, and removes a copy an earlier release delivered unless you changed it. teamai does not change `.gitignore`, `.git/info/exclude` or the git index. A team that wants to keep these files out of commits excludes them itself. ### Viewing the result @@ -2845,7 +2845,7 @@ teamai uninstall --agent claude What gets removed: - TeamAI-managed model settings are restored first when ownership is still intact - teamai hooks in AI tool settings -- The teamai blocks (culture, shared instructions, recall, and Codex's team rules) in each tool's instruction file, and the files an earlier release wrote them to (your own content is preserved; a `teamai-context` file teamai wrote is removed whole, and OpenCode's `instructions` entry for it goes too, even when your own text keeps the file) +- 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, even when your own text keeps the file or the file is gone) - 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 custom agents and CLI built-in agents (your own agents are preserved) diff --git a/docs/usage-guide.zh-CN.md b/docs/usage-guide.zh-CN.md index b6554ec66..1f5cc5703 100644 --- a/docs/usage-guide.zh-CN.md +++ b/docs/usage-guide.zh-CN.md @@ -1540,7 +1540,7 @@ pull 只把团队文化、共享指令和 recall 块写入已安装 AI 工具的 | Pi | `~/.pi/agent/AGENTS.md` | 由 teamai 的 Pi 扩展加入每次运行的系统提示 | | Hermes | `$HERMES_HOME/SOUL.md` 中的一个块,位于团队规则块旁(未验证) | teamai 的 Hermes 插件提供的系统提示段落(未验证) | -团队 `toolPaths` 中没有 `rules` 的条目,Claude Code、Cursor 和 WorkBuddy 继续使用其配置的 `claudemd`,因为它们没有可放置 `teamai-context` 文件的 rules 目录。 +团队 `toolPaths` 中没有 `rules` 的条目,Claude Code、Cursor、CodeBuddy 和 WorkBuddy 继续使用其配置的 `claudemd`,因为它们没有可放置 `teamai-context` 文件的 rules 目录。只有 `claudemd` 的条目在该文件所在目录存在时视为已安装;对 `AGENTS.md` 这类不在目录中的文件则始终视为已安装。 *未验证*:依据工具的文档或源码中的加载逻辑实现,尚未在实际会话中检查。Claude Code、Oh My Pi、OpenCode 和 Pi(项目范围)已在实际会话中从项目根目录和子目录检查过。有 `teamai-recall` subagent 的工具获得调用该 subagent 的 recall 块;没有的工具(Pi、Hermes、OpenClaw)获得提示 agent 直接运行 `teamai recall` 的 recall 块。多个工具共用的文件只有在每个工具都有该 subagent 时才获得 subagent 块。 @@ -1569,7 +1569,7 @@ Oh My Pi 把 `RULES.md` 作为始终应用的规则读取,与其唯一的用 `teamai doctor` 会检查每个已安装工具能否加载这些块:每个文件是否包含当前的块,OpenCode 配置是否列出其文件,Pi 或 Oh My Pi 扩展和 Hermes 插件是否已安装并启用,Hermes 段落是否在限制内,以及早期版本写过的文件中是否仍残留块。 -与 teamai 目标同名但并非 teamai 写入的文件保持不变,也不会被列入 OpenCode 的 `instructions`,pull 会给出警告。名为 `teamai-context` 的团队 rule 不会被分发,因为它会落在该文件上;pull 会指出它。teamai 不修改 `.gitignore`、`.git/info/exclude` 或 git 索引。若团队希望这些文件不进入提交,需要自行排除。 +与 teamai 目标同名但并非 teamai 写入的文件保持不变,也不会被列入 OpenCode 的 `instructions`,pull 会给出警告。名为 `teamai-context` 的团队 rule 不会被分发,因为它会落在该文件上;pull 会指出它,并删除早期版本分发的副本(除非你改过它)。teamai 不修改 `.gitignore`、`.git/info/exclude` 或 git 索引。若团队希望这些文件不进入提交,需要自行排除。 ### 查看效果 @@ -2656,7 +2656,7 @@ teamai uninstall --agent claude 移除内容: - 如果 ownership 仍有效,先恢复 TeamAI 管理的模型配置 - AI 工具 settings 中的 teamai hooks -- 各工具指令文件中的 teamai 块(文化、共享指令、recall 以及 Codex 的团队规则),以及早期版本写过这些块的文件(保留用户自写内容;teamai 写入的 `teamai-context` 文件整体删除,OpenCode 中对应的 `instructions` 条目一并移除,即使你自写的内容让该文件保留下来) +- 各工具指令文件中的 teamai 块(文化、共享指令、recall 以及 Codex 的团队规则),以及早期版本写过这些块的文件(保留用户自写内容;teamai 写入的 `teamai-context` 文件整体删除,OpenCode 中对应的 `instructions` 条目一并移除,即使你自写的内容让该文件保留下来,或该文件已不存在) - 团队同步的 skills,包括 OpenClaw workspace skills(保留用户自建 skills) - 团队同步的 rules,包括旧版本留在 `.codex/rules/` 中的副本,团队此后已删除的 rule 的副本也包括在内。清理使用记录的 `toolRoots` 位置和发布者本地的文件名。其中你改过的副本会保留,并在警告中点名。已删除 rule 的副本只有与记录的投递哈希一致时才会删除;没有该记录时也会保留并点名。Codex 的 `*.rules` 文件保留 - 团队同步的自定义 agents 和 CLI 内置 agents(保留用户自建 agents) diff --git a/src/__tests__/instruction-targets.test.ts b/src/__tests__/instruction-targets.test.ts index 12da6079d..20468eda7 100644 --- a/src/__tests__/instruction-targets.test.ts +++ b/src/__tests__/instruction-targets.test.ts @@ -253,5 +253,53 @@ describe('a tool configured without a rules directory (#945)', () => { fs.rmSync(root, { recursive: true, force: true }); } }); + + it('keeps WorkBuddy\'s configured project claudemd when its entry has no rules', async () => { + const root = fs.realpathSync.native(fs.mkdtempSync(path.join(os.tmpdir(), 'teamai-945-wb-norules-'))); + try { + const projectRoot = path.join(root, 'project'); + fs.mkdirSync(path.join(projectRoot, '.workbuddy'), { recursive: true }); + fs.writeFileSync(path.join(projectRoot, '.workbuddy', 'settings.json'), '{}\n'); + const localConfig = { + repo: { localPath: path.join(root, 'repo'), remote: 'https://example.invalid/t.git' }, + username: 'u', additionalRoles: [], scope: 'project', projectRoot, + } as unknown as LocalConfig; + const teamConfig = TeamaiConfigSchema.parse({ + team: 't', + repo: 'https://example.invalid/t.git', + toolPaths: { workbuddy: { settings: '.workbuddy/settings.json', claudemd: 'AGENTS.md' } }, + }); + + const { targets, stale } = await resolveInstructionTargets(teamConfig, localConfig); + + const file = path.join(projectRoot, 'AGENTS.md'); + expect(targets).toEqual([expect.objectContaining({ path: file, tools: ['workbuddy'], header: undefined, owned: undefined })]); + expect(stale.map((t) => t.path)).not.toContain(file); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }); + + it('probes a tool whose entry has only claudemd through that file\'s directory', async () => { + const root = fs.realpathSync.native(fs.mkdtempSync(path.join(os.tmpdir(), 'teamai-945-onlymd-'))); + try { + const projectRoot = path.join(root, 'project'); + fs.mkdirSync(projectRoot, { recursive: true }); + const localConfig = { + repo: { localPath: path.join(root, 'repo'), remote: 'https://example.invalid/t.git' }, + username: 'u', additionalRoles: [], scope: 'project', projectRoot, + } as unknown as LocalConfig; + const teamConfig = TeamaiConfigSchema.parse({ + team: 't', repo: 'https://example.invalid/t.git', toolPaths: { claude: { claudemd: '.claude/CLAUDE.md' } }, + }); + + expect((await resolveInstructionTargets(teamConfig, localConfig)).targets).toEqual([]); + fs.mkdirSync(path.join(projectRoot, '.claude')); + expect((await resolveInstructionTargets(teamConfig, localConfig)).targets.map((t) => t.path)) + .toEqual([path.join(projectRoot, '.claude', 'CLAUDE.md')]); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }); }); diff --git a/src/__tests__/local-agent.test.ts b/src/__tests__/local-agent.test.ts index 7b47275cd..2c382b112 100644 --- a/src/__tests__/local-agent.test.ts +++ b/src/__tests__/local-agent.test.ts @@ -1685,6 +1685,21 @@ describe('local-agent: cmds[] migration', () => { expect(manifest.scopes.user.rules?.['doc-a']).toBeUndefined(); }); + it('handle_type=prompt strips the block an earlier release left in ~/AGENTS.md (#945)', async () => { + const legacy = path.join(tmpDir, 'AGENTS.md'); + await fse.writeFile(legacy, `# mine\n\n${TEAMAI_CLAUDEMD_START}\nanother member's selection\n\n`); + + const acks = await runResponse({ + cmds: [{ + id: 6, type: 'install_prompt_rule', handle_type: 'prompt', slug: 'doc-a', + version: '1.0.0', download_url: 'http://127.0.0.1:42100/doc-a.md', scope: 'user', + }], + }); + + expect(acks.find((a) => a.id === 6)?.status).toBe('success'); + expect(await fse.readFile(legacy, 'utf8')).toBe('# mine\n'); + }); + // Codex's default `claudemd` (#938) makes a Codex report a target of this sync. it('handle_type=prompt from Codex writes the prompt into ~/.codex/AGENTS.md when ~/.codex exists', async () => { await fse.ensureDir(path.join(tmpDir, '.codex')); diff --git a/src/__tests__/rules.test.ts b/src/__tests__/rules.test.ts index 6f7991a95..957e39e6e 100644 --- a/src/__tests__/rules.test.ts +++ b/src/__tests__/rules.test.ts @@ -971,6 +971,27 @@ scope: 'user', expect(await fse.pathExists(path.join(localRulesDir, 'teamai-recall.md'))).toBe(true); expect(await fse.pathExists(path.join(localRulesDir, 'old-user-rule.md'))).toBe(false); }); + + it.each([[['team-rule.md']], [[]]])('removes the copy of a team rule named teamai-context an earlier release delivered, and keeps teamai\'s own context file (other team rules: %j) (#945)', async (others) => { + const teamRulesDir = path.join(localConfig.repo.localPath, 'rules'); + for (const other of others) await fse.writeFile(path.join(teamRulesDir, other), 'team content'); + await fse.writeFile(path.join(teamRulesDir, 'teamai-context.md'), 'RESERVED-RULE'); + const localRulesDir = path.join(homeDir, '.claude/rules'); + const context = path.join(localRulesDir, 'teamai-context.md'); + + await fse.writeFile(context, 'RESERVED-RULE'); + await handler.pullAllRules(teamConfig, localConfig); + expect(await fse.pathExists(context)).toBe(false); + + const blocks = '\nShared.\n\n'; + await fse.writeFile(context, blocks); + await handler.pullAllRules(teamConfig, localConfig); + expect(await fse.readFile(context, 'utf8')).toBe(blocks); + + await fse.writeFile(context, 'RESERVED-RULE, edited by the member'); + await handler.pullAllRules(teamConfig, localConfig); + expect(await fse.readFile(context, 'utf8')).toBe('RESERVED-RULE, edited by the member'); + }); }); describe('RulesHandler.pullAllRules — OpenCode instructions activation', () => { diff --git a/src/__tests__/uninstall.test.ts b/src/__tests__/uninstall.test.ts index d43979ebe..0e53bf772 100644 --- a/src/__tests__/uninstall.test.ts +++ b/src/__tests__/uninstall.test.ts @@ -1594,6 +1594,30 @@ describe('uninstall', () => { expect(await fse.pathExists(path.join(projectPlugin, 'my-own-plugin.ts'))).toBe(true); }); + it.each([ + ['deleted', null], + ['stripped of its markers', '# My own notes\n'], + ])('removes OpenCode\'s instructions entry when its context file was %s (#945)', async (_state, content) => { + const projectRoot = path.join(tmpDir, 'oc-entry-project'); + const homeDir = path.join(tmpDir, 'home'); + const repoPath = path.join(projectRoot, '.teamai', 'team-repo'); + await fse.ensureDir(repoPath); + await fse.ensureDir(path.join(projectRoot, '.opencode', 'skills')); + const config = path.join(projectRoot, '.opencode', 'opencode.json'); + await fse.writeJson(config, { instructions: ['docs/style.md', '.opencode/teamai-context.md'] }); + if (content !== null) await fse.writeFile(path.join(projectRoot, '.opencode', 'teamai-context.md'), content); + vi.stubEnv('HOME', homeDir); + vi.stubEnv('SHELL', '/bin/zsh'); + + const teamConfig = makeTeamConfig({ toolPaths: { opencode: { skills: '.opencode/skills', rules: '.opencode/rules' } } }); + const localConfig = makeLocalConfig(projectRoot, repoPath, { scope: 'project', projectRoot }); + mockAutoDetectInit.mockResolvedValue({ localConfig, teamConfig }); + + await uninstall({ force: true, agent: 'opencode' }); + + expect((await fse.readJson(config)).instructions).toEqual(['docs/style.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. diff --git a/src/instruction-targets.ts b/src/instruction-targets.ts index da846c8e8..c4d0dcd09 100644 --- a/src/instruction-targets.ts +++ b/src/instruction-targets.ts @@ -85,8 +85,10 @@ const codexHook: TargetEntry = { file: () => undefined, hook: true, retired: ['A /** * CodeBuddy and WorkBuddy both read the project's .codebuddy/rules, so they * share one copy there; uninstalling one keeps it while the other remains. + * An entry without `rules` keeps its configured `claudemd`, as `contextRule`. */ -const codebuddyProjectRule = (): string => `.codebuddy/rules/${TEAMAI_CONTEXT_RULE_NAME}.md`; +const codebuddyProjectRule = (paths: ToolPaths): string | undefined => + paths.rules === undefined ? paths.claudemd : `.codebuddy/rules/${TEAMAI_CONTEXT_RULE_NAME}.md`; // One line per tool, so a change to one tool's target edits one line. const USER_TARGETS: Readonly> = { @@ -369,8 +371,10 @@ function retiredTargets(toolPaths: Record, localConfig: Local /** * Whether `tool` is installed, probed through a path under its own root. The - * instruction file is never the probe: a bare `AGENTS.md` is shared by several - * tools and says nothing about any one of them. + * instruction file is the probe only when the entry has no other path, and + * then only through its directory: a bare `AGENTS.md` is shared by several + * tools and says nothing about any one of them, so such an entry counts as + * installed, as it did before #945. */ async function isInstalled(tool: string, paths: ToolPaths, localConfig: LocalConfig): Promise { // Hermes lives in $HERMES_HOME, which ~/.hermes need not be. @@ -378,8 +382,10 @@ async function isInstalled(tool: string, paths: ToolPaths, localConfig: LocalCon // teamai installs the OMP extension only where ~/.omp exists; a project's // own .omp/ says nothing about this member using OMP. if (tool === 'omp') return pathExists(path.join(getUserHome(), '.omp')); - const probe = paths.skills ?? paths.rules ?? paths.agents ?? paths.settings; - return probe !== undefined && isToolInstalledForConfig(tool, probe, localConfig); + const nestedClaudemd = paths.claudemd !== undefined && paths.claudemd.includes('/') ? paths.claudemd : undefined; + const probe = paths.skills ?? paths.rules ?? paths.agents ?? paths.settings ?? nestedClaudemd; + if (probe === undefined) return paths.claudemd !== undefined; + return isToolInstalledForConfig(tool, probe, localConfig); } /** Resolve where this scope's instruction blocks go, and which files to clean. */ diff --git a/src/local-agent.ts b/src/local-agent.ts index 8bca2ad1b..a8f862726 100644 --- a/src/local-agent.ts +++ b/src/local-agent.ts @@ -52,7 +52,9 @@ import { } from './mcp-reconcile.js'; import { normalizeAgentType } from './utils/tool-names.js'; import { logHttpRequest, logHttpResponse } from './utils/http-log.js'; -import { applyInstructionPlan, instructionTargetAt, instructionTargetFile, planInstructionFiles, registerOpencodeContext } from './instruction-targets.js'; +import { + applyInstructionPlan, instructionTargetAt, instructionTargetFile, planInstructionFiles, registerOpencodeContext, resolveInstructionTargets, +} from './instruction-targets.js'; import { opencodeClaudeFallback } from './resources/opencode-config.js'; import { reconcilePlugins, teardownAllPlugins, parseGetConfig, substituteVars, unresolvedPlaceholders, type ReconcileDeps, type PluginState } from './plugin-lifecycle.js'; import { @@ -2001,7 +2003,7 @@ async function installDownloadedResource(input: { const dest = path.join(repoPath, 'claudemd', `${input.slug}.md`); await fse.ensureDir(path.dirname(dest)); await fse.copyFile(mdFile, dest); - await syncClaudemd(teamConfig, localConfig, repoPath, input.workspacePath); + await syncClaudemd(teamConfig, localConfig, repoPath, input.workspacePath, fullTeamConfig); } const version = commandVersion(input.command, input.kind); @@ -2051,18 +2053,24 @@ async function uninstallResource(input: { await new RulesHandler().removeItem(input.slug, teamConfig, localConfig); } else { await remove(path.join(repoPath, 'claudemd', `${input.slug}.md`)); - await syncClaudemd(teamConfig, localConfig, repoPath, input.workspacePath); + await syncClaudemd(teamConfig, localConfig, repoPath, input.workspacePath, fullTeamConfig); } delete scopeManifest[manifestKind(input.kind)][input.slug]; await saveManifest(manifest); } +/** + * Deliver the HTTP agent's claudemd block to `teamConfig`'s one tool, and + * strip the blocks earlier releases left in files no installed tool of + * `fullTeamConfig` reads now, as pull does (#945). + */ async function syncClaudemd( teamConfig: TeamaiConfig, localConfig: LocalConfig, repoPath: string, - workspacePath?: string, + workspacePath: string | undefined, + fullTeamConfig: TeamaiConfig, ): Promise { const claudemdDir = path.join(repoPath, 'claudemd'); const files = (await pathExists(claudemdDir)) @@ -2126,6 +2134,13 @@ async function syncClaudemd( syncedAny = true; } + const { stale } = await resolveInstructionTargets(fullTeamConfig, localConfig); + const cleanup = await planInstructionFiles([], {}, stale); + for (const warning of cleanup.warnings) log.warn(warning); + const { report, failures } = await applyInstructionPlan(cleanup, { dryRun: false }); + for (const line of report) log.info(`${line}: no installed tool loads them from this file`); + for (const failure of failures) log.warn(failure); + if (files.length > 0 && !syncedAny) { throw new Error('CLAUDE.md sync landed on no tool: every configured target was skipped'); } diff --git a/src/resources/rules.ts b/src/resources/rules.ts index 4b00f4f20..9228adbbf 100644 --- a/src/resources/rules.ts +++ b/src/resources/rules.ts @@ -431,6 +431,41 @@ export class RulesHandler extends ResourceHandler { return removed; } + /** + * Remove the copy of a team rule named teamai-context an earlier release + * delivered to a rules directory (#945): that path is teamai's own + * instruction file now. A copy goes only without teamai's blocks and when + * the record shows it unchanged or it matches what pull rendered for the + * team's rule; any other is kept, and the instruction sync names it. + */ + private async reclaimReservedRuleCopies( + teamConfig: TeamaiConfig, + localConfig: LocalConfig, + ledger: DeliveryLedger | undefined, + ): Promise { + const { holdsInstructionBlocks } = await import('../instruction-targets.js'); + const relativePath = `rules/${TEAMAI_CONTEXT_RULE_NAME}.md`; + const rule: ResourceItem = { + name: TEAMAI_CONTEXT_RULE_NAME, type: 'rules', relativePath, sourcePath: path.join(localConfig.repo.localPath, relativePath), + }; + let deliveredRevs: readonly string[] | undefined; + for (const [tool, toolPath] of Object.entries(scopedToolPaths(teamConfig, localConfig))) { + if (!toolPath.rules || isAgentExcluded(localConfig, tool)) continue; + const file = path.join(resolveToolBaseDir(tool, localConfig), toolPath.rules, `${TEAMAI_CONTEXT_RULE_NAME}${ruleFileExtensionForTool(tool)}`); + if (!await pathExists(file) || await holdsInstructionBlocks(file)) continue; + const recorded = ledger?.previous?.[file]; + deliveredRevs ??= ( + 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); + if (!delivered) continue; + await remove(file); + if (ledger) forgetDelivered(ledger.hashes, file); + log.info(`Removed ${file}, the copy of the team rule ${TEAMAI_CONTEXT_RULE_NAME} an earlier release delivered: the team instructions go there now`); + } + } + /** * Distribute rule files to each tool's rules/ directory, then update * CLAUDE.md with a lightweight reference list instead of inlining content. @@ -453,6 +488,7 @@ export class RulesHandler extends ResourceHandler { log.warn(`rules/${TEAMAI_CONTEXT_RULE_NAME}.md is not delivered: ${TEAMAI_CONTEXT_RULE_NAME} is the name of teamai's own instruction file ` + 'in each rules directory. Rename the rule in the team repo, for example with `git mv`, and push the change.'); } + await this.reclaimReservedRuleCopies(teamConfig, localConfig, ledger); // Hermes: inline all team rules into a teamai-managed block in SOUL.md // (user-level standing instructions). Only when Hermes is actually diff --git a/src/uninstall.ts b/src/uninstall.ts index 62d2aa2ba..9919fcf39 100644 --- a/src/uninstall.ts +++ b/src/uninstall.ts @@ -100,6 +100,7 @@ interface RemovalPlan { hookManifestPath: string; /** Instruction files (CLAUDE.md, AGENTS.md, …), each with the teamai blocks to strip from it. */ claudeMdFiles: Array<{ path: string; blocks: Array<[string, string]> }>; + opencodeInstructions: OpencodeInstruction[]; /** * Skill directories synced from team repo, each with the base directory its * skills root hangs off: the prune refuses a link anywhere below that base. @@ -143,6 +144,12 @@ interface SkillDirEntry { baseDir: string; } +/** An entry teamai added to an OpenCode config's `instructions`. */ +interface OpencodeInstruction { + config: string; + entry: string; +} + interface ToolResources { hookFiles: Array<{ path: string; tool: string; manifestPath: string }>; openclawHookDirs: Array<{ hooksDir: string; tool: string }>; @@ -153,6 +160,8 @@ interface ToolResources { claudeMdFiles: string[]; /** Files an earlier release wrote this tool's instruction blocks to; no tool reads them now (#945). */ retiredInstructionFiles: string[]; + /** teamai's entries in OpenCode's `instructions`, whether or not their file still holds blocks (#945). */ + opencodeInstructions: OpencodeInstruction[]; skillDirs: SkillDirEntry[]; ruleFiles: string[]; keptRuleFiles: string[]; @@ -169,6 +178,7 @@ function hasToolResources(r: ToolResources): boolean { r.dshHookFile !== null || r.claudeMdFiles.length > 0 || r.retiredInstructionFiles.length > 0 || + r.opencodeInstructions.length > 0 || r.skillDirs.length > 0 || r.ruleFiles.length > 0 || r.agentFiles.length > 0 @@ -177,12 +187,6 @@ function hasToolResources(r: ToolResources): boolean { // ─── Helpers ─────────────────────────────────────────── -/** OpenCode's teamai instruction files in each scope (#945). */ -const OPENCODE_CONTEXT_FILES = [ - path.join('.config', 'opencode', 'teamai-context.md'), - path.join('.opencode', 'teamai-context.md'), -]; - const CLAUDEMD_MARKER_PAIRS: Array<[string, string]> = [ [TEAMAI_RULES_START, TEAMAI_RULES_END], [TEAMAI_CULTURE_START, TEAMAI_CULTURE_END], @@ -323,7 +327,7 @@ async function discoverToolResources( ): Promise { const res: ToolResources = { hookFiles: [], openclawHookDirs: [], opencodeHookScopes: [], ompHookFile: null, piHookFiles: [], dshHookFile: null, - claudeMdFiles: [], retiredInstructionFiles: [], skillDirs: [], ruleFiles: [], keptRuleFiles: [], agentFiles: [], + claudeMdFiles: [], retiredInstructionFiles: [], opencodeInstructions: [], skillDirs: [], ruleFiles: [], keptRuleFiles: [], agentFiles: [], }; // (a) Hooks — settings.json / hooks.json @@ -452,6 +456,11 @@ async function discoverToolResources( res.claudeMdFiles.push(claudeMdPath); } } + if (tool === 'opencode' && instructionFile) { + const { opencodeContextReference, readOpencodeInstructionList } = await import('./resources/opencode-config.js'); + const reference = opencodeContextReference(path.resolve(baseDir, instructionFile), scope, baseDir); + if ((await readOpencodeInstructionList(reference.config))?.includes(reference.entry)) res.opencodeInstructions.push(reference); + } for (const retired of retiredInstructionFiles(tool, toolPath, scope)) { const file = path.resolve(baseDir, retired); const content = await readFileSafe(file); @@ -673,6 +682,7 @@ async function buildRemovalPlan( dshHookFile: null, hookManifestPath: hookTargets[0].manifestPath, claudeMdFiles: [], + opencodeInstructions: [], skillDirs: [], ruleFiles: [], keptRuleFiles: [], @@ -715,6 +725,7 @@ async function buildRemovalPlan( if (res.ompHookFile) plan.ompHookFile = res.ompHookFile; plan.piHookFiles.push(...res.piHookFiles); if (res.dshHookFile) plan.dshHookFile = res.dshHookFile; + plan.opencodeInstructions.push(...res.opencodeInstructions); for (const file of res.claudeMdFiles) { if (plan.claudeMdFiles.some((entry) => entry.path === file)) continue; const content = await readFileSafe(file) ?? ''; @@ -825,6 +836,7 @@ function isPlanEmpty(plan: RemovalPlan): boolean { plan.piHookFiles.length === 0 && plan.dshHookFile === null && plan.claudeMdFiles.length === 0 && + plan.opencodeInstructions.length === 0 && plan.skillDirs.length === 0 && plan.ruleFiles.length === 0 && plan.agentFiles.length === 0 && @@ -873,6 +885,12 @@ function printSummary(plan: RemovalPlan, agentFilter?: string): void { console.log(''); } + if (plan.opencodeInstructions.length > 0) { + console.log(' OpenCode instructions entries:'); + for (const { config, entry } of plan.opencodeInstructions) console.log(` ${entry} in ${config}`); + console.log(''); + } + if (plan.ompHookFile !== null) { console.log(' OMP Hook (extension):'); console.log(` ${plan.ompHookFile}`); @@ -1074,17 +1092,20 @@ async function executeRemoval(plan: RemovalPlan): Promise { const { changed, warnings } = await clearInstructionFile(claudeMdPath, blocks.map(([start]) => start)); for (const warning of warnings) log.warn(warning); if (changed) log.success(`Cleaned ${claudeMdPath}`); - // OpenCode loads its file through an `instructions` entry teamai added; - // drop it even when the member's own text keeps the file. - if (OPENCODE_CONTEXT_FILES.some((suffix) => claudeMdPath.endsWith(suffix))) { - const { opencodeContextReference, reconcileOpencodeInstructions } = await import('./resources/opencode-config.js'); - const { config, entry } = opencodeContextReference(claudeMdPath, plan.scope, path.dirname(path.dirname(claudeMdPath))); - await reconcileOpencodeInstructions(config, entry, false, 'team instructions'); - } } catch (e) { log.warn(`Failed to clean ${claudeMdPath}: ${(e as Error).message}`); } } + // OpenCode loads its file through an `instructions` entry teamai added: it + // goes even when the member's text keeps the file, or the file is gone. + for (const { config, entry } of plan.opencodeInstructions) { + try { + const { reconcileOpencodeInstructions } = await import('./resources/opencode-config.js'); + if (await reconcileOpencodeInstructions(config, entry, false, 'team instructions')) log.success(`Removed "${entry}" from the instructions of ${config}`); + } catch (e) { + log.warn(`Failed to remove "${entry}" from the instructions of ${config}: ${(e as Error).message}`); + } + } // (c) Remove synced skills. // From a6088706bf07d4c5baf6db19022aac053d0ec9d3 Mon Sep 17 00:00:00 2001 From: Saul Moro Date: Fri, 2 Oct 2026 09:35:33 +0200 Subject: [PATCH 26/41] fix(pull): address review round 8 of #945 - The HTTP local agent's project prompts reach Pi, OMP and Hermes: a machine-level `instructions` handler adds the agent's cached claudemd for the session's project, and the sync counts such a tool as reached once its extension or plugin is ready. - The HTTP sync probes each tool through its own paths, as pull does, so a WorkBuddy-only project without .codebuddy/ gets its prompt. - OpenCode treats ~/.claude/CLAUDE.md as its fallback while that file holds teamai blocks, also ones an excluded Claude Code left there, so it no longer loads a second copy; pull warns that nothing keeps them current. - A test resolves targets for every tool, scope and toolPaths shape (full, without rules, only claudemd, only settings): an installed tool with a claudemd keeps getting the blocks, and no target is retired. --- CHANGELOG.md | 2 +- docs/usage-guide.md | 4 +- docs/usage-guide.zh-CN.md | 4 +- src/__tests__/hook-handlers.test.ts | 1 + src/__tests__/instruction-targets.test.ts | 79 +++++++++++++++++++++++ src/__tests__/local-agent.test.ts | 61 +++++++++++++++++ src/hook-handlers.ts | 18 ++++++ src/instruction-targets.ts | 16 +++-- src/local-agent.ts | 60 +++++++++++------ src/pull.ts | 6 +- 10 files changed, 219 insertions(+), 32 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9283f8e1d..509fad53b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ All notable changes to this project will be documented in this file. See [standa ### 💥 Breaking Changes -- **Upgrade every member together: team instructions leave the shared `AGENTS.md`.** `teamai pull` no longer writes the culture, `claudemd/` and recall blocks into the project's `AGENTS.md`, `~/AGENTS.md`, `~/.agents/AGENTS.md` or another tool's file, since members with different roles resolve different `claudemd/` selections and the shared file ended up holding whoever pulled last. Each installed tool gets them in its own file or session hook: Claude Code in `.claude/rules/teamai-context.md` (project) and `~/.claude/CLAUDE.md` (user); Cursor in `teamai-context.mdc` under `.cursor/rules` and `~/.cursor/rules`; CodeBuddy and WorkBuddy in one `.codebuddy/rules/teamai-context.md`, and WorkBuddy in `~/.workbuddy/rules/teamai-context.md`; OpenCode in `.opencode/teamai-context.md` and `~/.config/opencode/teamai-context.md`, listed in its `instructions`; Oh My Pi in `~/.omp/agent/RULES.md` and, in a project, through teamai's OMP extension; Pi through teamai's Pi extension in a project; Hermes in `$HERMES_HOME/SOUL.md` and, in a project, through a Hermes plugin teamai installs and enables. Copilot keeps `.github/copilot-instructions.md`. The first pull after updating removes the blocks earlier releases left in `AGENTS.md`, `~/AGENTS.md`, `.claude/CLAUDE.md`, `.codebuddy/CODEBUDDY.md`, `.omp/AGENTS.md`, `~/.omp/agent/AGENTS.md` and a moved tool's `claudemd` from the team's `toolPaths`, keeps everything else in those files, and names each file it changes; a block with a missing or repeated marker is left with a warning. A member still on an older release writes the blocks back into the shared files, so have everyone update. Pull writes only for installed tools, rewrites nothing that is current, removes the recall block when recall is disabled, and `--dry-run` lists the files it would change. Hermes skips project instructions over 4,000 characters, which pull and `teamai doctor` report. `teamai doctor` checks that each tool can load its instructions. A team `toolPaths` entry without `rules` keeps its configured `claudemd` for Claude Code, Cursor, CodeBuddy and WorkBuddy, and an entry with only `claudemd` is still delivered to. A team rule named `teamai-context` is not delivered, since it would land on teamai's own file, and a copy an earlier release delivered is removed unless the member changed it. The HTTP local agent strips the old blocks too. A Hermes plugin named `teamai-instructions` or an Oh My Pi `teamai-hooks.ts` that teamai did not write is left alone, and a file CodeBuddy and WorkBuddy share gets the `teamai-recall` subagent block only when both have the subagent. teamai does not change `.gitignore`, `.git/info/exclude` or the index; a team that tracks a generated file such as `.github/copilot-instructions.md` still commits one member's selection (for [#945](https://github.com/Tencent/teamai-cli/issues/945)). +- **Upgrade every member together: team instructions leave the shared `AGENTS.md`.** `teamai pull` no longer writes the culture, `claudemd/` and recall blocks into the project's `AGENTS.md`, `~/AGENTS.md`, `~/.agents/AGENTS.md` or another tool's file, since members with different roles resolve different `claudemd/` selections and the shared file ended up holding whoever pulled last. Each installed tool gets them in its own file or session hook: Claude Code in `.claude/rules/teamai-context.md` (project) and `~/.claude/CLAUDE.md` (user); Cursor in `teamai-context.mdc` under `.cursor/rules` and `~/.cursor/rules`; CodeBuddy and WorkBuddy in one `.codebuddy/rules/teamai-context.md`, and WorkBuddy in `~/.workbuddy/rules/teamai-context.md`; OpenCode in `.opencode/teamai-context.md` and `~/.config/opencode/teamai-context.md`, listed in its `instructions`; Oh My Pi in `~/.omp/agent/RULES.md` and, in a project, through teamai's OMP extension; Pi through teamai's Pi extension in a project; Hermes in `$HERMES_HOME/SOUL.md` and, in a project, through a Hermes plugin teamai installs and enables. Copilot keeps `.github/copilot-instructions.md`. The first pull after updating removes the blocks earlier releases left in `AGENTS.md`, `~/AGENTS.md`, `.claude/CLAUDE.md`, `.codebuddy/CODEBUDDY.md`, `.omp/AGENTS.md`, `~/.omp/agent/AGENTS.md` and a moved tool's `claudemd` from the team's `toolPaths`, keeps everything else in those files, and names each file it changes; a block with a missing or repeated marker is left with a warning. A member still on an older release writes the blocks back into the shared files, so have everyone update. Pull writes only for installed tools, rewrites nothing that is current, removes the recall block when recall is disabled, and `--dry-run` lists the files it would change. Hermes skips project instructions over 4,000 characters, which pull and `teamai doctor` report. `teamai doctor` checks that each tool can load its instructions. A team `toolPaths` entry without `rules` keeps its configured `claudemd` for Claude Code, Cursor, CodeBuddy and WorkBuddy, and an entry with only `claudemd` is still delivered to. A team rule named `teamai-context` is not delivered, since it would land on teamai's own file, and a copy an earlier release delivered is removed unless the member changed it. The HTTP local agent strips the old blocks too, probes each tool as pull does, and its project prompts reach Pi, Oh My Pi and Hermes through their extension or plugin. OpenCode counts `~/.claude/CLAUDE.md` as its fallback while that file holds teamai blocks, also from an excluded Claude Code, and pull warns when nothing keeps them current. A Hermes plugin named `teamai-instructions` or an Oh My Pi `teamai-hooks.ts` that teamai did not write is left alone, and a file CodeBuddy and WorkBuddy share gets the `teamai-recall` subagent block only when both have the subagent. teamai does not change `.gitignore`, `.git/info/exclude` or the index; a team that tracks a generated file such as `.github/copilot-instructions.md` still commits one member's selection (for [#945](https://github.com/Tencent/teamai-cli/issues/945)). - An env variable, hook or MCP server with a key its schema does not know, such as a misspelled `role:` or a hand-added `notes:`, is no longer delivered to anyone: the key used to be dropped silently, so a misspelled restriction shipped the entry to every member. `teamai pull`, the list commands, `teamai status` and `teamai doctor` name the file, the entry and the key. `teamai env add` also warns when it updates a variable carrying the unknown key; it, `teamai env remove` and `teamai remove mcp` keep the key when they rewrite the file. A key that a later version adds to these entries is unknown to this one too, so an entry that uses it is not delivered to a member still on this version: upgrade every member before the team uses a new entry key, as for a new `resources:` key (for [#822](https://github.com/Tencent/teamai-cli/issues/822)). - `manifest/projects.yaml` and `manifest/roles.yaml` now reject a resource namespace that is not a single path segment, as a project id already had to be (the id keeps its own narrower ASCII rule). A namespace becomes a directory component (`skills//`, `agents//`, `learnings//`), so `../evil`, `a/b`, `C:evil`, a bare `..`, any name with a trailing `.` or space — which Win32 strips, making `.. ` arrive as `..` and `frontend.` as `frontend` — and a Windows device name such as `CON` or `COM1` under `resources:` no longer parse; the error names the offending entry. Nothing else is rejected: a namespace that is a plain directory name still parses, non-ASCII names and names with a space included. A manifest that fails to parse now reports the offending entry on one line (`Invalid projects manifest: projects.0.resources.skills.1: ...`) instead of dumping a raw validation object. Two namespaces of one resource type that differ only by case (`frontend`, `Frontend`) are rejected too, within a manifest and between the two, since they name one directory on Windows and macOS. A manifest that ships any of these — a device name, a trailing `.`, a case-only pair — parsed before and fails every pull now; rename the directory and the entry together. - **Upgrade every member before a team declares a new axis.** `resources:` in `manifest/roles.yaml` and `manifest/projects.yaml` accepts `env`, `hooks`, `mcp`, `models` and `docs`, but teamai 0.25.0 and the 0.26.0 betas reject a `resources:` key they do not know, so a team that declares one breaks pull for every member still on those versions. From this version on, an unknown `resources:` key prints one warning naming the role or project and the key, and the scope syncs as if the key were absent; `teamai roles` and `teamai projects` keep the key when they save the manifest. `teamai roles|projects add/update --namespaces` never write the new keys, so nothing declares them until an admin does by hand (for [#707](https://github.com/Tencent/teamai-cli/issues/707)). diff --git a/docs/usage-guide.md b/docs/usage-guide.md index 16727949c..79d2b30d4 100644 --- a/docs/usage-guide.md +++ b/docs/usage-guide.md @@ -1674,9 +1674,9 @@ The CodeBuddy and WorkBuddy rule files carry `alwaysApply: true`, which CodeBudd In a project, teamai installs the Hermes plugin `$HERMES_HOME/plugins/teamai-instructions/` and adds it to `plugins.enabled` in `$HERMES_HOME/config.yaml` (a name you list under `plugins.disabled` stays off). A plugin of that name teamai did not write is left alone, also on uninstall, and `teamai pull` and `teamai doctor` say so. According to Hermes' documentation it builds the section once for each new session from the session's directory and keeps it through compression and resume. A section holds at most 4,000 characters, and all plugin sections together at most 8,000. When this member's instructions for the project are longer, Hermes skips them and `teamai pull` says so: teamai does not cut them or write them to `AGENTS.md`. Outside a project the section is empty, and Hermes may log that it skipped an empty section. -OpenCode loads a file only when its config lists it in `instructions`, so teamai adds that one entry and keeps your other entries and keys; the root `opencode.json` and OpenCode's own `AGENTS.md` files are left alone. While `~/.config/opencode/AGENTS.md` does not exist, OpenCode reads `~/.claude/CLAUDE.md` instead; when Claude Code gets the user blocks there, OpenCode already has them, so teamai writes no second user copy for OpenCode and says so in the pull output. A config file teamai cannot parse as JSON (for example one with comments) is left unchanged with a warning; add the entry by hand. +OpenCode loads a file only when its config lists it in `instructions`, so teamai adds that one entry and keeps your other entries and keys; the root `opencode.json` and OpenCode's own `AGENTS.md` files are left alone. While `~/.config/opencode/AGENTS.md` does not exist, OpenCode reads `~/.claude/CLAUDE.md` instead; when Claude Code gets the user blocks there, OpenCode already has them, so teamai writes no second user copy for OpenCode and says so in the pull output. Blocks left there by a Claude Code you excluded count too, since OpenCode reads them all the same; the pull then warns that nothing keeps them current. A config file teamai cannot parse as JSON (for example one with comments) is left unchanged with a warning; add the entry by hand. -Oh My Pi reads `RULES.md` as an always-applied rule beside its single user context file. In project scope teamai's OMP extension asks `teamai` for the blocks when the session starts and adds them to each turn's system prompt, from the project root and any subdirectory. Without that extension (for example with hooks removed), an Oh My Pi project session gets no team blocks. +Oh My Pi reads `RULES.md` as an always-applied rule beside its single user context file. In project scope teamai's OMP extension asks `teamai` for the blocks when the session starts and adds them to each turn's system prompt, from the project root and any subdirectory. Without that extension (for example with hooks removed), an Oh My Pi project session gets no team blocks. Prompts the HTTP local agent delivers for a project reach Pi, Oh My Pi and Hermes the same way, through their extension or plugin. A pull from an earlier release may have left these blocks in a file listed below. The next pull removes them, and names each file it changes: diff --git a/docs/usage-guide.zh-CN.md b/docs/usage-guide.zh-CN.md index 1f5cc5703..dd8f86021 100644 --- a/docs/usage-guide.zh-CN.md +++ b/docs/usage-guide.zh-CN.md @@ -1552,9 +1552,9 @@ CodeBuddy 和 WorkBuddy 的规则文件带有 `alwaysApply: true`,CodeBuddy 在项目中,teamai 安装 Hermes 插件 `$HERMES_HOME/plugins/teamai-instructions/`,并把它加入 `$HERMES_HOME/config.yaml` 的 `plugins.enabled`(列在 `plugins.disabled` 中的名称保持关闭)。同名但并非 teamai 写入的插件保持不变,卸载时也一样,`teamai pull` 和 `teamai doctor` 会指出这一点。根据 Hermes 的文档,它在每个新会话开始时根据会话目录生成该段落,并在压缩和恢复后保留。一个段落最多 4,000 个字符,所有插件段落合计最多 8,000 个字符。当该成员在此项目的指令更长时,Hermes 会跳过它们,`teamai pull` 会给出提示:teamai 不会截断它们,也不会写入 `AGENTS.md`。在项目之外段落为空,Hermes 可能记录它跳过了一个空段落。 -OpenCode 只加载配置中 `instructions` 列出的文件,因此 teamai 只添加这一项,并保留你的其他条目和键;根目录的 `opencode.json` 以及 OpenCode 自己的 `AGENTS.md` 文件保持不变。当 `~/.config/opencode/AGENTS.md` 不存在时,OpenCode 会改为读取 `~/.claude/CLAUDE.md`;若 Claude Code 的用户块已在那里,OpenCode 已经获得它们,因此 teamai 不会为 OpenCode 再写一份用户副本,并在 pull 输出中说明。teamai 无法按 JSON 解析的配置文件(例如带注释的文件)保持不变并给出警告,请手动添加该条目。 +OpenCode 只加载配置中 `instructions` 列出的文件,因此 teamai 只添加这一项,并保留你的其他条目和键;根目录的 `opencode.json` 以及 OpenCode 自己的 `AGENTS.md` 文件保持不变。当 `~/.config/opencode/AGENTS.md` 不存在时,OpenCode 会改为读取 `~/.claude/CLAUDE.md`;若 Claude Code 的用户块已在那里,OpenCode 已经获得它们,因此 teamai 不会为 OpenCode 再写一份用户副本,并在 pull 输出中说明。被排除的 Claude Code 留在那里的块同样算数,因为 OpenCode 照样读取它们;此时 pull 会警告没有任何工具再更新它们。teamai 无法按 JSON 解析的配置文件(例如带注释的文件)保持不变并给出警告,请手动添加该条目。 -Oh My Pi 把 `RULES.md` 作为始终应用的规则读取,与其唯一的用户上下文文件并存。在项目范围内,teamai 的 OMP 扩展在会话开始时向 `teamai` 获取这些块,并加入每轮的系统提示,无论会话从项目根目录还是子目录启动。没有该扩展时(例如移除了 hooks),Oh My Pi 的项目会话不会获得团队块。 +Oh My Pi 把 `RULES.md` 作为始终应用的规则读取,与其唯一的用户上下文文件并存。在项目范围内,teamai 的 OMP 扩展在会话开始时向 `teamai` 获取这些块,并加入每轮的系统提示,无论会话从项目根目录还是子目录启动。没有该扩展时(例如移除了 hooks),Oh My Pi 的项目会话不会获得团队块。HTTP local agent 为项目下发的 prompt 也以同样方式,通过扩展或插件到达 Pi、Oh My Pi 和 Hermes。 早期版本的 pull 可能把这些块留在下列文件中。下一次 pull 会移除它们,并列出所修改的每个文件: diff --git a/src/__tests__/hook-handlers.test.ts b/src/__tests__/hook-handlers.test.ts index aa6fca6f6..5683237e1 100644 --- a/src/__tests__/hook-handlers.test.ts +++ b/src/__tests__/hook-handlers.test.ts @@ -828,6 +828,7 @@ describe('hook-handlers registry', () => { // A new handler must decide: team handlers set requiresConfig, the rest join this list. const names = new Set(filterHandlersForConfig(buildHandlerRegistry(), null).map((r) => r.handler.name)); expect([...names].sort()).toEqual([ + 'local-agent-instructions', 'local-agent-sync', 'package-pending-hint', 'pull', diff --git a/src/__tests__/instruction-targets.test.ts b/src/__tests__/instruction-targets.test.ts index 20468eda7..34bb7d747 100644 --- a/src/__tests__/instruction-targets.test.ts +++ b/src/__tests__/instruction-targets.test.ts @@ -13,8 +13,11 @@ import { } from '../instruction-targets.js'; import { injectPiHooks } from '../pi-hooks.js'; import { + scopedToolPaths, + toolInstallRoot, TeamaiConfigSchema, type LocalConfig, + type TeamaiConfig, TEAMAI_CLAUDEMD_END, TEAMAI_CLAUDEMD_START, TEAMAI_CULTURE_END, @@ -303,3 +306,79 @@ describe('a tool configured without a rules directory (#945)', () => { }); }); +describe('OpenCode\'s Claude fallback (#945)', () => { + it('counts ~/.claude/CLAUDE.md as OpenCode\'s fallback while it holds blocks, even with Claude excluded', async () => { + const root = fs.realpathSync.native(fs.mkdtempSync(path.join(os.tmpdir(), 'teamai-945-ocfallback-'))); + const prevHome = process.env.HOME; + process.env.HOME = path.join(root, 'home'); + try { + const home = process.env.HOME; + fs.mkdirSync(path.join(home, '.config', 'opencode', 'skills'), { recursive: true }); + fs.mkdirSync(path.join(home, '.claude', 'skills'), { recursive: true }); + const claudeFile = path.join(home, '.claude', 'CLAUDE.md'); + fs.writeFileSync(claudeFile, `${claudemd('an earlier selection')}\n`); + const localConfig = { + repo: { localPath: path.join(root, 'repo'), remote: 'https://example.invalid/t.git' }, + username: 'u', additionalRoles: [], scope: 'user', enabledAgents: ['opencode'], + } as unknown as LocalConfig; + + const resolved = await resolveInstructionTargets(TeamaiConfigSchema.parse({ team: 't', repo: 'https://example.invalid/t.git' }), localConfig); + + expect(resolved.opencodeFallback).toBe(claudeFile); + expect(resolved.targets.map((t) => t.path)).not.toContain(path.join(home, '.config', 'opencode', 'teamai-context.md')); + } finally { + process.env.HOME = prevHome; + fs.rmSync(root, { recursive: true, force: true }); + } + }); +}); + +describe('every tool and toolPaths shape keeps its instructions (#945)', () => { + type Paths = TeamaiConfig['toolPaths'][string]; + const DEFAULTS = TeamaiConfigSchema.parse({ team: 't', repo: 'https://example.invalid/t.git' }); + const SHAPES: Record Paths> = { + full: (paths) => paths, + 'without rules': ({ rules: _rules, ...rest }) => rest, + 'only claudemd': (paths) => (paths.claudemd === undefined ? {} : { claudemd: paths.claudemd }), + 'only settings': (paths) => (paths.settings === undefined ? {} : { settings: paths.settings }), + }; + const cases = (['user', 'project'] as const).flatMap((scope) => + Object.keys(DEFAULTS.toolPaths).flatMap((tool) => Object.keys(SHAPES).map((shape) => [scope, tool, shape] as const))); + + it.each(cases)('%s scope, %s, %s: an installed tool with a claudemd still gets the blocks, and no target is retired', async (scope, tool, shape) => { + const root = fs.realpathSync.native(fs.mkdtempSync(path.join(os.tmpdir(), 'teamai-945-shapes-'))); + const saved = { HOME: process.env.HOME, HERMES_HOME: process.env.HERMES_HOME, COPILOT_HOME: process.env.COPILOT_HOME }; + process.env.HOME = path.join(root, 'home'); + process.env.HERMES_HOME = path.join(root, 'hermes'); + process.env.COPILOT_HOME = path.join(root, 'copilot'); + try { + const projectRoot = path.join(root, 'project'); + const localConfig = { + repo: { localPath: path.join(root, 'repo'), remote: 'https://example.invalid/t.git' }, + username: 'u', additionalRoles: [], scope, ...(scope === 'project' ? { projectRoot } : {}), + } as unknown as LocalConfig; + const paths = SHAPES[shape](scopedToolPaths(DEFAULTS, localConfig)[tool]); + const base = scope === 'project' ? projectRoot : process.env.HOME; + for (const dir of [process.env.HERMES_HOME, process.env.COPILOT_HOME, path.join(process.env.HOME, '.omp')]) fs.mkdirSync(dir, { recursive: true }); + for (const value of Object.values(paths)) { + if (typeof value === 'string') fs.mkdirSync(path.join(base, toolInstallRoot(value)), { 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) { + expect([...targets.flatMap((t) => t.tools), ...hooks.map((h) => h.tool)]).toContain(tool); + } + } finally { + for (const [key, value] of Object.entries(saved)) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + fs.rmSync(root, { recursive: true, force: true }); + } + }); +}); + diff --git a/src/__tests__/local-agent.test.ts b/src/__tests__/local-agent.test.ts index 2c382b112..4f59fdbba 100644 --- a/src/__tests__/local-agent.test.ts +++ b/src/__tests__/local-agent.test.ts @@ -2070,6 +2070,67 @@ describe('local-agent: per-worktree claudemd isolation (issue #374 P1-2C)', () = }); }); +describe('local-agent: project prompts reach every installed tool (#945)', () => { + async function installProjectPrompt(tool: string, toolDirs: string[]) { + const { execFileSync } = await import('node:child_process'); + const repo = path.join(tmpDir, 'project'); + await fse.ensureDir(repo); + execFileSync('git', ['init', '-q'], { cwd: repo, stdio: 'pipe' }); + for (const dir of toolDirs) await fse.ensureDir(path.join(repo, dir)); + await fse.ensureDir(path.join(tmpDir, '.teamai', 'local-agent')); + await fse.writeJson(path.join(tmpDir, '.teamai', 'local-agent', 'config.json'), { + endpoint: 'https://test.example.com/api', token: 't', localAgentId: 'id', + createdAt: '2026-01-01T00:00:00.000Z', workspaceBindings: {}, + }); + const acks: Array> = []; + vi.stubGlobal('fetch', vi.fn(async (input: string | URL, init?: { body?: string }) => { + const url = String(input); + if (url.endsWith('doc.md')) return new Response('PROJECT-PROMPT'); + if (url.includes('/local-agent/sync')) { + return new Response(JSON.stringify({ + ok: true, + cmds: [{ + id: 201, type: 'install_prompt_rule', handle_type: 'prompt', slug: 'doc', + version: '1.0.0', download_url: 'http://127.0.0.1:42100/doc.md', scope: 'workspace', workspace_path: repo, + }], + })); + } + if (url.includes('/commands/ack')) acks.push(JSON.parse(init?.body ?? '{}')); + return new Response(JSON.stringify({ ok: true })); + })); + const { reportAndSyncLocalAgent } = await import('../local-agent.js'); + await reportAndSyncLocalAgent({ cwd: repo, tool, status: 'running' }); + return { repo, ack: acks.find((a) => a.id === 201) }; + } + + it('installs a WorkBuddy project prompt where .codebuddy/ does not exist', async () => { + const { repo, ack } = await installProjectPrompt('workbuddy', ['.workbuddy/skills']); + + expect(ack?.status).toBe('success'); + expect(await fse.readFile(path.join(repo, '.codebuddy', 'rules', 'teamai-context.md'), 'utf8')).toContain('PROJECT-PROMPT'); + }); + + it('gives Pi a project prompt through its extension, not the project AGENTS.md', async () => { + const { injectPiHooks } = await import('../pi-hooks.js'); + await injectPiHooks(); + const { repo, ack } = await installProjectPrompt('pi', ['.pi/skills']); + + expect(ack?.status).toBe('success'); + expect(await fse.pathExists(path.join(repo, 'AGENTS.md'))).toBe(false); + const { buildHandlerRegistry, filterHandlersForConfig } = await import('../hook-handlers.js'); + const outputs = await Promise.all(filterHandlersForConfig(buildHandlerRegistry(), null) + .filter((reg) => reg.event === 'instructions') + .map((reg) => reg.handler.execute({ cwd: repo } as never, 'pi', null as never))); + expect(outputs.filter(Boolean).join('\n')).toContain('PROJECT-PROMPT'); + }); + + it('fails a Pi project prompt while its extension is missing', async () => { + const { ack } = await installProjectPrompt('pi', ['.pi/skills']); + + expect(ack?.status).toBe('failed'); + }); +}); + describe('local-agent: loadLocalAgentConfig({ dryRun: true }) writes nothing (#893)', () => { const configPath = () => path.join(tmpDir, '.teamai', 'local-agent', 'config.json'); diff --git a/src/hook-handlers.ts b/src/hook-handlers.ts index 3790f338f..0bcbc23bc 100644 --- a/src/hook-handlers.ts +++ b/src/hook-handlers.ts @@ -770,6 +770,23 @@ const instructionsHandler: HookHandler = { }, }; +/** + * `instructions` from the HTTP local agent's resource cache for the session's + * project (#945): Pi, OMP and Hermes have no project file it could write to. + * Runs without a teamai config, since an HTTP-only machine has none. + */ +const localAgentInstructionsHandler: HookHandler = { + name: 'local-agent-instructions', + async execute(stdin, tool) { + const { deliversInstructionsByHook } = await import('./instruction-targets.js'); + if (!deliversInstructionsByHook(tool, 'project')) return null; + const { localAgentInstructionText } = await import('./local-agent.js'); + const text = await localAgentInstructionText(resolveHookCwd(stdin) ?? process.cwd()); + if (!text) return null; + return JSON.stringify({ hookSpecificOutput: { hookEventName: 'SessionStart', additionalContext: text } }); + }, +}; + /** HTTP local-agent report/sync + workspace binding prompts. */ const localAgentHandler: HookHandler = { name: 'local-agent-sync', @@ -888,6 +905,7 @@ export function buildHandlerRegistry(): HandlerRegistration[] { { event: 'session-start', matcher: '*', handler: localAgentHandler, timeoutMs: FOREGROUND_HOOK_TIMEOUT_MS }, // Asked for by the Pi and OMP extensions and the Hermes plugin, which add the result to the prompt. { event: 'instructions', matcher: '*', handler: instructionsHandler, timeoutMs: FOREGROUND_HOOK_TIMEOUT_MS, requiresConfig: true }, + { event: 'instructions', matcher: '*', handler: localAgentInstructionsHandler, timeoutMs: FOREGROUND_HOOK_TIMEOUT_MS }, { event: 'session-start', matcher: '*', handler: webhookHandler, timeoutMs: FOREGROUND_HOOK_TIMEOUT_MS, background: true, requiresConfig: true }, // Copilot emits SessionEnd after its final turn (not Stop), so the webhook diff --git a/src/instruction-targets.ts b/src/instruction-targets.ts index c4d0dcd09..5f872bbce 100644 --- a/src/instruction-targets.ts +++ b/src/instruction-targets.ts @@ -191,6 +191,8 @@ export interface InstructionTargets { stale: InstructionTarget[]; /** Claude's user file when OpenCode reads the blocks from it, so OpenCode gets no file of its own. */ opencodeFallback?: string | null; + /** The fallback holds blocks no installed, enabled Claude Code keeps current. */ + opencodeFallbackStale?: boolean; } /** @@ -376,7 +378,7 @@ function retiredTargets(toolPaths: Record, localConfig: Local * tools and says nothing about any one of them, so such an entry counts as * installed, as it did before #945. */ -async function isInstalled(tool: string, paths: ToolPaths, localConfig: LocalConfig): Promise { +export async function isInstructionToolInstalled(tool: string, paths: ToolPaths, localConfig: LocalConfig): Promise { // Hermes lives in $HERMES_HOME, which ~/.hermes need not be. if (tool === 'hermes') return pathExists(getHermesHome()); // teamai installs the OMP extension only where ~/.omp exists; a project's @@ -402,13 +404,13 @@ export async function resolveInstructionTargets( for (const [tool, paths] of Object.entries(toolPaths)) { const entry = entryFor(tool, localConfig.scope); if (entry?.hook) { - if (!isAgentExcluded(localConfig, tool) && await isInstalled(tool, paths, localConfig)) { + if (!isAgentExcluded(localConfig, tool) && await isInstructionToolInstalled(tool, paths, localConfig)) { hooks.push({ tool, recall: Boolean(paths.agents), limit: entry.hookLimit }); } continue; } const file = instructionTargetPath(tool, paths, localConfig); - if (!file || !await isInstalled(tool, paths, localConfig)) continue; + if (!file || !await isInstructionToolInstalled(tool, paths, localConfig)) continue; inUse.add(file); if (isAgentExcluded(localConfig, tool)) continue; const target = targets.get(file) ?? instructionTargetAt(tool, file, localConfig.scope); @@ -420,15 +422,19 @@ export async function resolveInstructionTargets( const stale = [...retiredTargets(toolPaths, localConfig).values()].filter((t) => !inUse.has(t.path)); // OpenCode reads ~/.claude/CLAUDE.md while its own user AGENTS.md does not // exist; when Claude's blocks are there, a second copy would duplicate them. + // That holds for blocks an excluded Claude left there too: OpenCode reads + // them all the same. const opencode = [...targets.values()].find((target) => target.tools.includes('opencode')); + const claudeFile = path.join(getUserHome(), '.claude', 'CLAUDE.md'); + const claudeHolds = !targets.has(claudeFile) && await holdsInstructionBlocks(claudeFile); const opencodeFallback = localConfig.scope === 'user' && opencode !== undefined - ? await opencodeClaudeFallback(getUserHome(), [...targets.keys()]) + ? await opencodeClaudeFallback(getUserHome(), [...targets.keys(), ...claudeHolds ? [claudeFile] : []]) : null; if (opencode && opencodeFallback) { targets.delete(opencode.path); stale.push(opencode); } - return { targets: [...targets.values()], hooks, stale, opencodeFallback }; + return { targets: [...targets.values()], hooks, stale, opencodeFallback, opencodeFallbackStale: Boolean(opencodeFallback) && claudeHolds }; } /** diff --git a/src/local-agent.ts b/src/local-agent.ts index a8f862726..df061ffcf 100644 --- a/src/local-agent.ts +++ b/src/local-agent.ts @@ -20,7 +20,6 @@ import { writeJson, writeJsonAtomic, } from './utils/fs.js'; -import { isToolInstalledForConfig, ResourceHandler } from './resources/base.js'; import { RulesHandler, SkillsHandler } from './resources/index.js'; import { injectHooksToAllTools, applyAgentHook, removeAgentHook, isAgentHookSupportedTool, isAgentHookEvent, OPENCLAW_TOOLS } from './hooks.js'; import { parseHookEvent } from './dashboard-collector.js'; @@ -53,7 +52,8 @@ import { import { normalizeAgentType } from './utils/tool-names.js'; import { logHttpRequest, logHttpResponse } from './utils/http-log.js'; import { - applyInstructionPlan, instructionTargetAt, instructionTargetFile, planInstructionFiles, registerOpencodeContext, resolveInstructionTargets, + applyInstructionPlan, deliversInstructionsByHook, instructionHookChannel, instructionHookText, instructionTargetAt, instructionTargetFile, + isInstructionToolInstalled, planInstructionFiles, registerOpencodeContext, resolveInstructionTargets, } from './instruction-targets.js'; import { opencodeClaudeFallback } from './resources/opencode-config.js'; import { reconcilePlugins, teardownAllPlugins, parseGetConfig, substituteVars, unresolvedPlaceholders, type ReconcileDeps, type PluginState } from './plugin-lifecycle.js'; @@ -65,7 +65,6 @@ import { resolveToolRootDir, CLAUDE_TOOL_ID, DEFAULT_CLAUDE_ROOT, - COPILOT_TOOL_ID, getTokenPath, TEAMAI_CLAUDEMD_START, TEAMAI_CLAUDEMD_END, @@ -2060,6 +2059,32 @@ async function uninstallResource(input: { await saveManifest(manifest); } +/** The claudemd fragments in an HTTP resource cache, compiled into one block. */ +async function cachedClaudemdBlock(repoPath: string): Promise<{ files: string[]; block: string | null }> { + const claudemdDir = path.join(repoPath, 'claudemd'); + const files = (await pathExists(claudemdDir)) + ? (await fse.readdir(claudemdDir)).filter((file) => file.endsWith('.md')).sort() + : []; + const contents: string[] = []; + for (const file of files) { + const content = await readFileSafe(path.join(claudemdDir, file)); + if (content) contents.push(content); + } + return { files, block: compileClaudemdBlock(contents) }; +} + +/** + * The HTTP agent's claudemd instructions for the project at `cwd`, as text a + * session hook adds (#945): Pi, OMP and Hermes have no project file of their + * own. Empty outside a project the agent delivered to. + */ +export async function localAgentInstructionText(cwd: string): Promise { + const workspacePath = await resolveWorkspacePath(cwd); + if (!workspacePath) return ''; + const { block } = await cachedClaudemdBlock(await getResourceRepoPath('project', workspacePath)); + return block ? instructionHookText({ claudemd: block }, false) : ''; +} + /** * Deliver the HTTP agent's claudemd block to `teamConfig`'s one tool, and * strip the blocks earlier releases left in files no installed tool of @@ -2072,19 +2097,18 @@ async function syncClaudemd( workspacePath: string | undefined, fullTeamConfig: TeamaiConfig, ): Promise { - const claudemdDir = path.join(repoPath, 'claudemd'); - const files = (await pathExists(claudemdDir)) - ? (await fse.readdir(claudemdDir)).filter((file) => file.endsWith('.md')).sort() - : []; - const contents: string[] = []; - for (const file of files) { - const content = await readFileSafe(path.join(claudemdDir, file)); - if (content) contents.push(content); - } - const block = compileClaudemdBlock(contents); + const { files, block } = await cachedClaudemdBlock(repoPath); let syncedAny = false; for (const [tool, toolPath] of Object.entries(scopedToolPaths(teamConfig, localConfig))) { + // Pi, OMP and Hermes in a project take the cache from their extension or + // plugin through `hook-dispatch instructions` (localAgentInstructionText). + if (deliversInstructionsByHook(tool, localConfig.scope)) { + const ready = await isInstructionToolInstalled(tool, toolPath, localConfig) && (await instructionHookChannel(tool)).ready; + log.debug(`local-agent: ${ready ? `${tool} adds the CLAUDE.md instructions through its extension` : `skipped CLAUDE.md sync for ${tool}: its extension is not installed`}`); + syncedAny ||= ready; + continue; + } const targetFile = instructionTargetFile(tool, toolPath, localConfig.scope); if (!targetFile) continue; @@ -2098,15 +2122,11 @@ async function syncClaudemd( } } + // Probed through the tool's own paths, as pull does: WorkBuddy's project + // target sits under .codebuddy, which says nothing about WorkBuddy. const toolInstalled = resolvedAbsPath ? await pathExists(resolvedAbsPath) - : path.isAbsolute(targetFile) - ? await pathExists(path.dirname(targetFile)) - : tool === COPILOT_TOOL_ID && localConfig.scope === 'user' - ? await isToolInstalledForConfig(tool, targetFile, localConfig) - : targetFile.includes('/') - ? await ResourceHandler.isToolInstalled(targetFile, baseDir) - : await pathExists(path.join(baseDir, `.${tool}`)); + : await isInstructionToolInstalled(tool, toolPath, localConfig); if (!toolInstalled) { log.debug(`Skipped CLAUDE.md sync for ${tool}: target not found`); continue; diff --git a/src/pull.ts b/src/pull.ts index e7fc5f99a..a5af74514 100644 --- a/src/pull.ts +++ b/src/pull.ts @@ -1891,8 +1891,10 @@ async function syncManagedInstructions( ): Promise { const { blocks, claudemdFiles } = await resolveInstructionBlocks(config, localConfig, roleContext); const resolved = await resolveInstructionTargets(config, localConfig); - const { targets, stale, opencodeFallback } = resolved; - if (opencodeFallback) { + const { targets, stale, opencodeFallback, opencodeFallbackStale } = resolved; + if (opencodeFallback && opencodeFallbackStale) { + log.warn(`[${scopeLabel}] OpenCode reads the team instructions from ${opencodeFallback}, its fallback while ~/.config/opencode/AGENTS.md does not exist, but teamai no longer updates them there: Claude Code is excluded or not installed. Create that AGENTS.md to have teamai deliver them to OpenCode's own file, or remove the teamai blocks from ${opencodeFallback}.`); + } else if (opencodeFallback) { log.info(`[${scopeLabel}] OpenCode reads the team instructions from ${opencodeFallback}, its fallback while ~/.config/opencode/AGENTS.md does not exist, so teamai adds no second copy for it. Create that AGENTS.md to have teamai deliver them to OpenCode's own file instead.`); } const plan = await planInstructionFiles(targets, blocks, stale); From e0d724e2881465cfa12ce50d34f7243a7ba23197 Mon Sep 17 00:00:00 2001 From: Saul Moro Date: Fri, 2 Oct 2026 09:49:52 +0200 Subject: [PATCH 27/41] fix(pull): address review round 9 of #945 - OpenCode registration leaves an instructions entry alone while its teamai-context.md is a file of the member's: pull no longer drops an entry the member listed for their own file. - The HTTP prompt sync acks failed, with the reason, when the planner leaves a target unchanged (a file teamai did not write, a malformed block) instead of counting the tool as reached. - Hermes counts as reached only while the project's instructions, team blocks plus HTTP prompts, fit its 4,000-character section. --- CHANGELOG.md | 2 +- docs/usage-guide.md | 2 +- docs/usage-guide.zh-CN.md | 2 +- src/__tests__/instruction-targets.test.ts | 27 +++++++++++ src/__tests__/local-agent.test.ts | 27 ++++++++++- src/instruction-targets.ts | 12 +++-- src/local-agent.ts | 56 ++++++++++++++++++++--- 7 files changed, 112 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 509fad53b..2551a57db 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ All notable changes to this project will be documented in this file. See [standa ### 💥 Breaking Changes -- **Upgrade every member together: team instructions leave the shared `AGENTS.md`.** `teamai pull` no longer writes the culture, `claudemd/` and recall blocks into the project's `AGENTS.md`, `~/AGENTS.md`, `~/.agents/AGENTS.md` or another tool's file, since members with different roles resolve different `claudemd/` selections and the shared file ended up holding whoever pulled last. Each installed tool gets them in its own file or session hook: Claude Code in `.claude/rules/teamai-context.md` (project) and `~/.claude/CLAUDE.md` (user); Cursor in `teamai-context.mdc` under `.cursor/rules` and `~/.cursor/rules`; CodeBuddy and WorkBuddy in one `.codebuddy/rules/teamai-context.md`, and WorkBuddy in `~/.workbuddy/rules/teamai-context.md`; OpenCode in `.opencode/teamai-context.md` and `~/.config/opencode/teamai-context.md`, listed in its `instructions`; Oh My Pi in `~/.omp/agent/RULES.md` and, in a project, through teamai's OMP extension; Pi through teamai's Pi extension in a project; Hermes in `$HERMES_HOME/SOUL.md` and, in a project, through a Hermes plugin teamai installs and enables. Copilot keeps `.github/copilot-instructions.md`. The first pull after updating removes the blocks earlier releases left in `AGENTS.md`, `~/AGENTS.md`, `.claude/CLAUDE.md`, `.codebuddy/CODEBUDDY.md`, `.omp/AGENTS.md`, `~/.omp/agent/AGENTS.md` and a moved tool's `claudemd` from the team's `toolPaths`, keeps everything else in those files, and names each file it changes; a block with a missing or repeated marker is left with a warning. A member still on an older release writes the blocks back into the shared files, so have everyone update. Pull writes only for installed tools, rewrites nothing that is current, removes the recall block when recall is disabled, and `--dry-run` lists the files it would change. Hermes skips project instructions over 4,000 characters, which pull and `teamai doctor` report. `teamai doctor` checks that each tool can load its instructions. A team `toolPaths` entry without `rules` keeps its configured `claudemd` for Claude Code, Cursor, CodeBuddy and WorkBuddy, and an entry with only `claudemd` is still delivered to. A team rule named `teamai-context` is not delivered, since it would land on teamai's own file, and a copy an earlier release delivered is removed unless the member changed it. The HTTP local agent strips the old blocks too, probes each tool as pull does, and its project prompts reach Pi, Oh My Pi and Hermes through their extension or plugin. OpenCode counts `~/.claude/CLAUDE.md` as its fallback while that file holds teamai blocks, also from an excluded Claude Code, and pull warns when nothing keeps them current. A Hermes plugin named `teamai-instructions` or an Oh My Pi `teamai-hooks.ts` that teamai did not write is left alone, and a file CodeBuddy and WorkBuddy share gets the `teamai-recall` subagent block only when both have the subagent. teamai does not change `.gitignore`, `.git/info/exclude` or the index; a team that tracks a generated file such as `.github/copilot-instructions.md` still commits one member's selection (for [#945](https://github.com/Tencent/teamai-cli/issues/945)). +- **Upgrade every member together: team instructions leave the shared `AGENTS.md`.** `teamai pull` no longer writes the culture, `claudemd/` and recall blocks into the project's `AGENTS.md`, `~/AGENTS.md`, `~/.agents/AGENTS.md` or another tool's file, since members with different roles resolve different `claudemd/` selections and the shared file ended up holding whoever pulled last. Each installed tool gets them in its own file or session hook: Claude Code in `.claude/rules/teamai-context.md` (project) and `~/.claude/CLAUDE.md` (user); Cursor in `teamai-context.mdc` under `.cursor/rules` and `~/.cursor/rules`; CodeBuddy and WorkBuddy in one `.codebuddy/rules/teamai-context.md`, and WorkBuddy in `~/.workbuddy/rules/teamai-context.md`; OpenCode in `.opencode/teamai-context.md` and `~/.config/opencode/teamai-context.md`, listed in its `instructions`; Oh My Pi in `~/.omp/agent/RULES.md` and, in a project, through teamai's OMP extension; Pi through teamai's Pi extension in a project; Hermes in `$HERMES_HOME/SOUL.md` and, in a project, through a Hermes plugin teamai installs and enables. Copilot keeps `.github/copilot-instructions.md`. The first pull after updating removes the blocks earlier releases left in `AGENTS.md`, `~/AGENTS.md`, `.claude/CLAUDE.md`, `.codebuddy/CODEBUDDY.md`, `.omp/AGENTS.md`, `~/.omp/agent/AGENTS.md` and a moved tool's `claudemd` from the team's `toolPaths`, keeps everything else in those files, and names each file it changes; a block with a missing or repeated marker is left with a warning. A member still on an older release writes the blocks back into the shared files, so have everyone update. Pull writes only for installed tools, rewrites nothing that is current, removes the recall block when recall is disabled, and `--dry-run` lists the files it would change. Hermes skips project instructions over 4,000 characters, which pull and `teamai doctor` report. `teamai doctor` checks that each tool can load its instructions. A team `toolPaths` entry without `rules` keeps its configured `claudemd` for Claude Code, Cursor, CodeBuddy and WorkBuddy, and an entry with only `claudemd` is still delivered to. A team rule named `teamai-context` is not delivered, since it would land on teamai's own file, and a copy an earlier release delivered is removed unless the member changed it. The HTTP local agent strips the old blocks too, probes each tool as pull does, and its project prompts reach Pi, Oh My Pi and Hermes through their extension or plugin. It acks a prompt as failed, with the reason, when it reaches no tool: a target file teamai did not write, an extension or plugin that is missing, or text over Hermes' 4,000-character section. OpenCode counts `~/.claude/CLAUDE.md` as its fallback while that file holds teamai blocks, also from an excluded Claude Code, and pull warns when nothing keeps them current. A Hermes plugin named `teamai-instructions` or an Oh My Pi `teamai-hooks.ts` that teamai did not write is left alone, and a file CodeBuddy and WorkBuddy share gets the `teamai-recall` subagent block only when both have the subagent. teamai does not change `.gitignore`, `.git/info/exclude` or the index; a team that tracks a generated file such as `.github/copilot-instructions.md` still commits one member's selection (for [#945](https://github.com/Tencent/teamai-cli/issues/945)). - An env variable, hook or MCP server with a key its schema does not know, such as a misspelled `role:` or a hand-added `notes:`, is no longer delivered to anyone: the key used to be dropped silently, so a misspelled restriction shipped the entry to every member. `teamai pull`, the list commands, `teamai status` and `teamai doctor` name the file, the entry and the key. `teamai env add` also warns when it updates a variable carrying the unknown key; it, `teamai env remove` and `teamai remove mcp` keep the key when they rewrite the file. A key that a later version adds to these entries is unknown to this one too, so an entry that uses it is not delivered to a member still on this version: upgrade every member before the team uses a new entry key, as for a new `resources:` key (for [#822](https://github.com/Tencent/teamai-cli/issues/822)). - `manifest/projects.yaml` and `manifest/roles.yaml` now reject a resource namespace that is not a single path segment, as a project id already had to be (the id keeps its own narrower ASCII rule). A namespace becomes a directory component (`skills//`, `agents//`, `learnings//`), so `../evil`, `a/b`, `C:evil`, a bare `..`, any name with a trailing `.` or space — which Win32 strips, making `.. ` arrive as `..` and `frontend.` as `frontend` — and a Windows device name such as `CON` or `COM1` under `resources:` no longer parse; the error names the offending entry. Nothing else is rejected: a namespace that is a plain directory name still parses, non-ASCII names and names with a space included. A manifest that fails to parse now reports the offending entry on one line (`Invalid projects manifest: projects.0.resources.skills.1: ...`) instead of dumping a raw validation object. Two namespaces of one resource type that differ only by case (`frontend`, `Frontend`) are rejected too, within a manifest and between the two, since they name one directory on Windows and macOS. A manifest that ships any of these — a device name, a trailing `.`, a case-only pair — parsed before and fails every pull now; rename the directory and the entry together. - **Upgrade every member before a team declares a new axis.** `resources:` in `manifest/roles.yaml` and `manifest/projects.yaml` accepts `env`, `hooks`, `mcp`, `models` and `docs`, but teamai 0.25.0 and the 0.26.0 betas reject a `resources:` key they do not know, so a team that declares one breaks pull for every member still on those versions. From this version on, an unknown `resources:` key prints one warning naming the role or project and the key, and the scope syncs as if the key were absent; `teamai roles` and `teamai projects` keep the key when they save the manifest. `teamai roles|projects add/update --namespaces` never write the new keys, so nothing declares them until an admin does by hand (for [#707](https://github.com/Tencent/teamai-cli/issues/707)). diff --git a/docs/usage-guide.md b/docs/usage-guide.md index 79d2b30d4..651dcc275 100644 --- a/docs/usage-guide.md +++ b/docs/usage-guide.md @@ -1691,7 +1691,7 @@ A pull from an earlier release may have left these blocks in a file listed below `teamai doctor` checks that each installed tool can load these blocks: that each file holds the current blocks, that OpenCode's config lists its file, that the Pi or Oh My Pi extension and the Hermes plugin are installed and enabled, that the Hermes section fits its limit, and that no file an earlier release wrote still holds blocks. -A file named like a teamai target that teamai did not write is left alone and not listed in OpenCode's `instructions`, and the pull warns about it. A team rule named `teamai-context` is not delivered, since it would land on that file; the pull names it, and removes a copy an earlier release delivered unless you changed it. teamai does not change `.gitignore`, `.git/info/exclude` or the git index. A team that wants to keep these files out of commits excludes them itself. +A file named like a teamai target that teamai did not write is left alone and not listed in OpenCode's `instructions` (an entry you listed for it stays), and the pull warns about it. A team rule named `teamai-context` is not delivered, since it would land on that file; the pull names it, and removes a copy an earlier release delivered unless you changed it. teamai does not change `.gitignore`, `.git/info/exclude` or the git index. A team that wants to keep these files out of commits excludes them itself. ### Viewing the result diff --git a/docs/usage-guide.zh-CN.md b/docs/usage-guide.zh-CN.md index dd8f86021..60a7c19e8 100644 --- a/docs/usage-guide.zh-CN.md +++ b/docs/usage-guide.zh-CN.md @@ -1569,7 +1569,7 @@ Oh My Pi 把 `RULES.md` 作为始终应用的规则读取,与其唯一的用 `teamai doctor` 会检查每个已安装工具能否加载这些块:每个文件是否包含当前的块,OpenCode 配置是否列出其文件,Pi 或 Oh My Pi 扩展和 Hermes 插件是否已安装并启用,Hermes 段落是否在限制内,以及早期版本写过的文件中是否仍残留块。 -与 teamai 目标同名但并非 teamai 写入的文件保持不变,也不会被列入 OpenCode 的 `instructions`,pull 会给出警告。名为 `teamai-context` 的团队 rule 不会被分发,因为它会落在该文件上;pull 会指出它,并删除早期版本分发的副本(除非你改过它)。teamai 不修改 `.gitignore`、`.git/info/exclude` 或 git 索引。若团队希望这些文件不进入提交,需要自行排除。 +与 teamai 目标同名但并非 teamai 写入的文件保持不变,也不会被列入 OpenCode 的 `instructions`(你自己为它列的条目保留不动),pull 会给出警告。名为 `teamai-context` 的团队 rule 不会被分发,因为它会落在该文件上;pull 会指出它,并删除早期版本分发的副本(除非你改过它)。teamai 不修改 `.gitignore`、`.git/info/exclude` 或 git 索引。若团队希望这些文件不进入提交,需要自行排除。 ### 查看效果 diff --git a/src/__tests__/instruction-targets.test.ts b/src/__tests__/instruction-targets.test.ts index 34bb7d747..04bb8d98b 100644 --- a/src/__tests__/instruction-targets.test.ts +++ b/src/__tests__/instruction-targets.test.ts @@ -8,6 +8,7 @@ import { clearInstructionFile, instructionChannelProblems, planInstructionFiles, + registerOpencodeContext, resolveInstructionTargets, type InstructionTarget, } from '../instruction-targets.js'; @@ -333,6 +334,32 @@ describe('OpenCode\'s Claude fallback (#945)', () => { }); }); +describe('OpenCode instructions registration (#945)', () => { + it('leaves the member\'s own listed teamai-context.md entry alone, in a dry run and a real pull', async () => { + const root = fs.realpathSync.native(fs.mkdtempSync(path.join(os.tmpdir(), 'teamai-945-ocreg-'))); + try { + const projectRoot = path.join(root, 'project'); + fs.mkdirSync(path.join(projectRoot, '.opencode', 'skills'), { recursive: true }); + fs.writeFileSync(path.join(projectRoot, '.opencode', 'teamai-context.md'), '# My own notes\n'); + const config = path.join(projectRoot, '.opencode', 'opencode.json'); + const listed = JSON.stringify({ instructions: ['.opencode/teamai-context.md'] }); + fs.writeFileSync(config, listed); + const localConfig = { + repo: { localPath: path.join(root, 'repo'), remote: 'https://example.invalid/t.git' }, + username: 'u', additionalRoles: [], scope: 'project', projectRoot, + } as unknown as LocalConfig; + const teamConfig = TeamaiConfigSchema.parse({ team: 't', repo: 'https://example.invalid/t.git' }); + const resolved = await resolveInstructionTargets(teamConfig, localConfig); + + expect(await registerOpencodeContext(teamConfig, localConfig, resolved, true)).toBeNull(); + expect(await registerOpencodeContext(teamConfig, localConfig, resolved, false)).toBeNull(); + expect(fs.readFileSync(config, 'utf8')).toBe(listed); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }); +}); + describe('every tool and toolPaths shape keeps its instructions (#945)', () => { type Paths = TeamaiConfig['toolPaths'][string]; const DEFAULTS = TeamaiConfigSchema.parse({ team: 't', repo: 'https://example.invalid/t.git' }); diff --git a/src/__tests__/local-agent.test.ts b/src/__tests__/local-agent.test.ts index 4f59fdbba..da9ffc37e 100644 --- a/src/__tests__/local-agent.test.ts +++ b/src/__tests__/local-agent.test.ts @@ -2071,12 +2071,13 @@ describe('local-agent: per-worktree claudemd isolation (issue #374 P1-2C)', () = }); describe('local-agent: project prompts reach every installed tool (#945)', () => { - async function installProjectPrompt(tool: string, toolDirs: string[]) { + async function installProjectPrompt(tool: string, toolDirs: string[], options: { prompt?: string; files?: Record } = {}) { const { execFileSync } = await import('node:child_process'); const repo = path.join(tmpDir, 'project'); await fse.ensureDir(repo); execFileSync('git', ['init', '-q'], { cwd: repo, stdio: 'pipe' }); for (const dir of toolDirs) await fse.ensureDir(path.join(repo, dir)); + for (const [rel, text] of Object.entries(options.files ?? {})) await fse.outputFile(path.join(repo, rel), text); await fse.ensureDir(path.join(tmpDir, '.teamai', 'local-agent')); await fse.writeJson(path.join(tmpDir, '.teamai', 'local-agent', 'config.json'), { endpoint: 'https://test.example.com/api', token: 't', localAgentId: 'id', @@ -2085,7 +2086,7 @@ describe('local-agent: project prompts reach every installed tool (#945)', () => const acks: Array> = []; vi.stubGlobal('fetch', vi.fn(async (input: string | URL, init?: { body?: string }) => { const url = String(input); - if (url.endsWith('doc.md')) return new Response('PROJECT-PROMPT'); + if (url.endsWith('doc.md')) return new Response(options.prompt ?? 'PROJECT-PROMPT'); if (url.includes('/local-agent/sync')) { return new Response(JSON.stringify({ ok: true, @@ -2129,6 +2130,28 @@ describe('local-agent: project prompts reach every installed tool (#945)', () => expect(ack?.status).toBe('failed'); }); + + it('fails a Claude project prompt whose target is a file teamai did not write, and says why', async () => { + const mine = '# My own context rule\n'; + const { repo, ack } = await installProjectPrompt('claude', ['.claude/skills'], { files: { '.claude/rules/teamai-context.md': mine } }); + + expect(ack?.status).toBe('failed'); + expect(String(ack?.error)).toContain('was not written by teamai'); + expect(await fse.readFile(path.join(repo, '.claude', 'rules', 'teamai-context.md'), 'utf8')).toBe(mine); + }); + + it('fails a Hermes project prompt over its 4,000-character section, and acks one that fits', async () => { + const { injectHermesHooks } = await import('../hermes-hooks.js'); + await fse.ensureDir(path.join(tmpDir, '.hermes')); + await injectHermesHooks(); + + const over = await installProjectPrompt('hermes', [], { prompt: 'x'.repeat(4100) }); + expect(over.ack?.status).toBe('failed'); + expect(String(over.ack?.error)).toMatch(/over the 4000-character limit/); + + const fits = await installProjectPrompt('hermes', [], { prompt: 'PROJECT-PROMPT' }); + expect(fits.ack?.status).toBe('success'); + }); }); describe('local-agent: loadLocalAgentConfig({ dryRun: true }) writes nothing (#893)', () => { diff --git a/src/instruction-targets.ts b/src/instruction-targets.ts index 5f872bbce..7471eec12 100644 --- a/src/instruction-targets.ts +++ b/src/instruction-targets.ts @@ -439,9 +439,10 @@ export async function resolveInstructionTargets( /** * List teamai's OpenCode instruction file in OpenCode's `instructions` while - * it holds teamai's blocks, and drop the entry once they are gone: OpenCode - * reads no file it is not told about. Returns what it did or, with `dryRun`, - * would do. + * it holds teamai's blocks, and drop the entry once the file is gone: OpenCode + * reads no file it is not told about. A file without teamai's blocks is the + * member's, so its entry is left as it is. Returns what it did or, with + * `dryRun`, would do. */ export async function registerOpencodeContext( teamConfig: TeamaiConfig, @@ -455,7 +456,10 @@ export async function registerOpencodeContext( if (!contextFile) return null; const wanted = resolved.targets.some((target) => target.path === contextFile); if (!wanted && !resolved.stale.some((target) => target.path === contextFile)) return null; - const present = wanted && (await holdsInstructionBlocks(contextFile) || (dryRun && planned.includes(contextFile))); + const delivered = await holdsInstructionBlocks(contextFile) || (dryRun && planned.includes(contextFile)); + // A same-named file of the member's: its entry, if any, is theirs too. + if (!delivered && await pathExists(contextFile)) return null; + const present = wanted && delivered; const { config, entry } = opencodeContextReference(contextFile, localConfig.scope, resolveToolBaseDir('opencode', localConfig)); if (dryRun) { const listed = (await readOpencodeInstructionList(config))?.includes(entry) ?? false; diff --git a/src/local-agent.ts b/src/local-agent.ts index df061ffcf..bd08acdf9 100644 --- a/src/local-agent.ts +++ b/src/local-agent.ts @@ -52,8 +52,8 @@ import { import { normalizeAgentType } from './utils/tool-names.js'; import { logHttpRequest, logHttpResponse } from './utils/http-log.js'; import { - applyInstructionPlan, deliversInstructionsByHook, instructionHookChannel, instructionHookText, instructionTargetAt, instructionTargetFile, - isInstructionToolInstalled, planInstructionFiles, registerOpencodeContext, resolveInstructionTargets, + applyInstructionPlan, deliversInstructionsByHook, instructionHookChannel, instructionHookText, instructionHookTextFor, instructionTargetAt, + instructionTargetFile, isInstructionToolInstalled, planInstructionFiles, registerOpencodeContext, resolveInstructionTargets, } from './instruction-targets.js'; import { opencodeClaudeFallback } from './resources/opencode-config.js'; import { reconcilePlugins, teardownAllPlugins, parseGetConfig, substituteVars, unresolvedPlaceholders, type ReconcileDeps, type PluginState } from './plugin-lifecycle.js'; @@ -2099,14 +2099,21 @@ async function syncClaudemd( ): Promise { const { files, block } = await cachedClaudemdBlock(repoPath); let syncedAny = false; + // Why each tool got nothing, for the ACK when none did. + const skipped: string[] = []; for (const [tool, toolPath] of Object.entries(scopedToolPaths(teamConfig, localConfig))) { // Pi, OMP and Hermes in a project take the cache from their extension or // plugin through `hook-dispatch instructions` (localAgentInstructionText). if (deliversInstructionsByHook(tool, localConfig.scope)) { - const ready = await isInstructionToolInstalled(tool, toolPath, localConfig) && (await instructionHookChannel(tool)).ready; - log.debug(`local-agent: ${ready ? `${tool} adds the CLAUDE.md instructions through its extension` : `skipped CLAUDE.md sync for ${tool}: its extension is not installed`}`); - syncedAny ||= ready; + const problem = await hookDeliveryProblem(teamConfig, localConfig, tool, block); + if (problem) { + log.debug(`local-agent: skipped CLAUDE.md sync for ${tool}: ${problem}`); + skipped.push(problem); + continue; + } + log.debug(`local-agent: ${tool} adds the CLAUDE.md instructions through its extension`); + syncedAny = true; continue; } const targetFile = instructionTargetFile(tool, toolPath, localConfig.scope); @@ -2143,10 +2150,16 @@ async function syncClaudemd( } const target = instructionTargetAt(tool, claudeMdPath, localConfig.scope); const plan = await planInstructionFiles([target], { claudemd: block }); - for (const warning of plan.warnings) log.warn(warning); + // A warning means the file was left as it was: nothing reached the tool. + if (plan.warnings.length > 0) { + for (const warning of plan.warnings) log.warn(warning); + skipped.push(...plan.warnings); + continue; + } const { failures } = await applyInstructionPlan(plan, { dryRun: false }); if (failures.length > 0) { log.warn(`Failed to sync CLAUDE.md instructions to ${tool}: ${failures.join(' ')}`); + skipped.push(...failures); continue; } if (tool === 'opencode') await registerOpencodeContext(teamConfig, localConfig, { targets: [target], stale: [] }, false); @@ -2162,10 +2175,39 @@ async function syncClaudemd( for (const failure of failures) log.warn(failure); if (files.length > 0 && !syncedAny) { - throw new Error('CLAUDE.md sync landed on no tool: every configured target was skipped'); + throw new Error(['CLAUDE.md sync landed on no tool: every configured target was skipped.', ...skipped].join(' ')); } } +/** + * Why a hook tool cannot add the HTTP agent's instructions in this scope, or + * null when it can: not installed, its extension or plugin not ready, or the + * text over its prompt section's limit. The text counts the team's blocks the + * same hook adds for this project, when a team repo governs it. + */ +async function hookDeliveryProblem( + teamConfig: TeamaiConfig, + localConfig: LocalConfig, + tool: string, + block: string | null, +): Promise { + const hook = (await resolveInstructionTargets(teamConfig, localConfig)).hooks.find((entry) => entry.tool === tool); + if (!hook) return `${tool} is not installed here.`; + const channel = await instructionHookChannel(tool); + if (!channel.ready) return channel.fix; + if (hook.limit === undefined) return null; + const parts = [block ? instructionHookText({ claudemd: block }, false) : '']; + const { loadTeamConfig, resolveConfigForDir } = await import('./config.js'); + const memberConfig = localConfig.projectRoot ? await resolveConfigForDir(localConfig.projectRoot) : null; + const memberTeam = memberConfig ? await loadTeamConfig(memberConfig.repo.localPath) : null; + if (memberConfig && memberTeam) parts.unshift(await instructionHookTextFor(memberTeam, memberConfig, tool)); + const length = parts.filter(Boolean).join('\n\n').length; + return length > hook.limit + ? `${tool} cannot load this project's instructions: with the HTTP prompts they are ${length} characters, over the ` + + `${hook.limit}-character limit of its prompt section, so ${tool} skips them. Shorten the prompts for this project.` + : null; +} + async function ackCommand( config: LocalAgentConfig, tag: string, From 6c79660ea9c464a0186e3f67a1cdaacbdfa4420d Mon Sep 17 00:00:00 2001 From: Saul Moro Date: Fri, 2 Oct 2026 10:08:10 +0200 Subject: [PATCH 28/41] fix(pull): address the adversarial review of #945 against main - The HTTP prompt sync strips an old instruction file only when every installed tool that wrote it received this sync's instructions, so a CodeBuddy prompt no longer removes Claude's blocks from .claude/CLAUDE.md. - A rule tombstone named teamai-context no longer deletes the instruction file the unchanged-revision pull just refreshed. - A placed namespaced rule named teamai-context keeps its namespaced path instead of landing on teamai's instruction file. - Codex project HTTP prompts reach Codex through its SessionStart and SubagentStart hooks; the cache handler is named http-prompt-instructions so the HTTP reporter wiring stays one handler per event. --- CHANGELOG.md | 2 +- docs/usage-guide.md | 2 +- docs/usage-guide.zh-CN.md | 2 +- src/__tests__/hook-handlers.test.ts | 2 +- src/__tests__/local-agent.test.ts | 25 +++++++++++++++++++++++ src/__tests__/pull-tombstone.test.ts | 20 +++++++++++++++++++ src/__tests__/rules.test.ts | 16 +++++++++++++++ src/hook-handlers.ts | 19 +++++++++++++----- src/local-agent.ts | 30 ++++++++++++++++++++++++++-- src/pull.ts | 5 +++++ src/resources/rules.ts | 4 +++- 11 files changed, 115 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2551a57db..21a5c33f1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ All notable changes to this project will be documented in this file. See [standa ### 💥 Breaking Changes -- **Upgrade every member together: team instructions leave the shared `AGENTS.md`.** `teamai pull` no longer writes the culture, `claudemd/` and recall blocks into the project's `AGENTS.md`, `~/AGENTS.md`, `~/.agents/AGENTS.md` or another tool's file, since members with different roles resolve different `claudemd/` selections and the shared file ended up holding whoever pulled last. Each installed tool gets them in its own file or session hook: Claude Code in `.claude/rules/teamai-context.md` (project) and `~/.claude/CLAUDE.md` (user); Cursor in `teamai-context.mdc` under `.cursor/rules` and `~/.cursor/rules`; CodeBuddy and WorkBuddy in one `.codebuddy/rules/teamai-context.md`, and WorkBuddy in `~/.workbuddy/rules/teamai-context.md`; OpenCode in `.opencode/teamai-context.md` and `~/.config/opencode/teamai-context.md`, listed in its `instructions`; Oh My Pi in `~/.omp/agent/RULES.md` and, in a project, through teamai's OMP extension; Pi through teamai's Pi extension in a project; Hermes in `$HERMES_HOME/SOUL.md` and, in a project, through a Hermes plugin teamai installs and enables. Copilot keeps `.github/copilot-instructions.md`. The first pull after updating removes the blocks earlier releases left in `AGENTS.md`, `~/AGENTS.md`, `.claude/CLAUDE.md`, `.codebuddy/CODEBUDDY.md`, `.omp/AGENTS.md`, `~/.omp/agent/AGENTS.md` and a moved tool's `claudemd` from the team's `toolPaths`, keeps everything else in those files, and names each file it changes; a block with a missing or repeated marker is left with a warning. A member still on an older release writes the blocks back into the shared files, so have everyone update. Pull writes only for installed tools, rewrites nothing that is current, removes the recall block when recall is disabled, and `--dry-run` lists the files it would change. Hermes skips project instructions over 4,000 characters, which pull and `teamai doctor` report. `teamai doctor` checks that each tool can load its instructions. A team `toolPaths` entry without `rules` keeps its configured `claudemd` for Claude Code, Cursor, CodeBuddy and WorkBuddy, and an entry with only `claudemd` is still delivered to. A team rule named `teamai-context` is not delivered, since it would land on teamai's own file, and a copy an earlier release delivered is removed unless the member changed it. The HTTP local agent strips the old blocks too, probes each tool as pull does, and its project prompts reach Pi, Oh My Pi and Hermes through their extension or plugin. It acks a prompt as failed, with the reason, when it reaches no tool: a target file teamai did not write, an extension or plugin that is missing, or text over Hermes' 4,000-character section. OpenCode counts `~/.claude/CLAUDE.md` as its fallback while that file holds teamai blocks, also from an excluded Claude Code, and pull warns when nothing keeps them current. A Hermes plugin named `teamai-instructions` or an Oh My Pi `teamai-hooks.ts` that teamai did not write is left alone, and a file CodeBuddy and WorkBuddy share gets the `teamai-recall` subagent block only when both have the subagent. teamai does not change `.gitignore`, `.git/info/exclude` or the index; a team that tracks a generated file such as `.github/copilot-instructions.md` still commits one member's selection (for [#945](https://github.com/Tencent/teamai-cli/issues/945)). +- **Upgrade every member together: team instructions leave the shared `AGENTS.md`.** `teamai pull` no longer writes the culture, `claudemd/` and recall blocks into the project's `AGENTS.md`, `~/AGENTS.md`, `~/.agents/AGENTS.md` or another tool's file, since members with different roles resolve different `claudemd/` selections and the shared file ended up holding whoever pulled last. Each installed tool gets them in its own file or session hook: Claude Code in `.claude/rules/teamai-context.md` (project) and `~/.claude/CLAUDE.md` (user); Cursor in `teamai-context.mdc` under `.cursor/rules` and `~/.cursor/rules`; CodeBuddy and WorkBuddy in one `.codebuddy/rules/teamai-context.md`, and WorkBuddy in `~/.workbuddy/rules/teamai-context.md`; OpenCode in `.opencode/teamai-context.md` and `~/.config/opencode/teamai-context.md`, listed in its `instructions`; Oh My Pi in `~/.omp/agent/RULES.md` and, in a project, through teamai's OMP extension; Pi through teamai's Pi extension in a project; Hermes in `$HERMES_HOME/SOUL.md` and, in a project, through a Hermes plugin teamai installs and enables. Copilot keeps `.github/copilot-instructions.md`. The first pull after updating removes the blocks earlier releases left in `AGENTS.md`, `~/AGENTS.md`, `.claude/CLAUDE.md`, `.codebuddy/CODEBUDDY.md`, `.omp/AGENTS.md`, `~/.omp/agent/AGENTS.md` and a moved tool's `claudemd` from the team's `toolPaths`, keeps everything else in those files, and names each file it changes; a block with a missing or repeated marker is left with a warning. A member still on an older release writes the blocks back into the shared files, so have everyone update. Pull writes only for installed tools, rewrites nothing that is current, removes the recall block when recall is disabled, and `--dry-run` lists the files it would change. Hermes skips project instructions over 4,000 characters, which pull and `teamai doctor` report. `teamai doctor` checks that each tool can load its instructions. A team `toolPaths` entry without `rules` keeps its configured `claudemd` for Claude Code, Cursor, CodeBuddy and WorkBuddy, and an entry with only `claudemd` is still delivered to. A team rule named `teamai-context` is not delivered, since it would land on teamai's own file, and a copy an earlier release delivered is removed unless the member changed it; neither a tombstone of that rule nor a namespaced placement of it reaches teamai's file. The HTTP local agent strips the old blocks of the tools a prompt reached, probes each tool as pull does, and its project prompts reach Pi, Oh My Pi and Hermes through their extension or plugin and the Codex family through its session hooks. It acks a prompt as failed, with the reason, when it reaches no tool: a target file teamai did not write, an extension or plugin that is missing, or text over Hermes' 4,000-character section. OpenCode counts `~/.claude/CLAUDE.md` as its fallback while that file holds teamai blocks, also from an excluded Claude Code, and pull warns when nothing keeps them current. A Hermes plugin named `teamai-instructions` or an Oh My Pi `teamai-hooks.ts` that teamai did not write is left alone, and a file CodeBuddy and WorkBuddy share gets the `teamai-recall` subagent block only when both have the subagent. teamai does not change `.gitignore`, `.git/info/exclude` or the index; a team that tracks a generated file such as `.github/copilot-instructions.md` still commits one member's selection (for [#945](https://github.com/Tencent/teamai-cli/issues/945)). - An env variable, hook or MCP server with a key its schema does not know, such as a misspelled `role:` or a hand-added `notes:`, is no longer delivered to anyone: the key used to be dropped silently, so a misspelled restriction shipped the entry to every member. `teamai pull`, the list commands, `teamai status` and `teamai doctor` name the file, the entry and the key. `teamai env add` also warns when it updates a variable carrying the unknown key; it, `teamai env remove` and `teamai remove mcp` keep the key when they rewrite the file. A key that a later version adds to these entries is unknown to this one too, so an entry that uses it is not delivered to a member still on this version: upgrade every member before the team uses a new entry key, as for a new `resources:` key (for [#822](https://github.com/Tencent/teamai-cli/issues/822)). - `manifest/projects.yaml` and `manifest/roles.yaml` now reject a resource namespace that is not a single path segment, as a project id already had to be (the id keeps its own narrower ASCII rule). A namespace becomes a directory component (`skills//`, `agents//`, `learnings//`), so `../evil`, `a/b`, `C:evil`, a bare `..`, any name with a trailing `.` or space — which Win32 strips, making `.. ` arrive as `..` and `frontend.` as `frontend` — and a Windows device name such as `CON` or `COM1` under `resources:` no longer parse; the error names the offending entry. Nothing else is rejected: a namespace that is a plain directory name still parses, non-ASCII names and names with a space included. A manifest that fails to parse now reports the offending entry on one line (`Invalid projects manifest: projects.0.resources.skills.1: ...`) instead of dumping a raw validation object. Two namespaces of one resource type that differ only by case (`frontend`, `Frontend`) are rejected too, within a manifest and between the two, since they name one directory on Windows and macOS. A manifest that ships any of these — a device name, a trailing `.`, a case-only pair — parsed before and fails every pull now; rename the directory and the entry together. - **Upgrade every member before a team declares a new axis.** `resources:` in `manifest/roles.yaml` and `manifest/projects.yaml` accepts `env`, `hooks`, `mcp`, `models` and `docs`, but teamai 0.25.0 and the 0.26.0 betas reject a `resources:` key they do not know, so a team that declares one breaks pull for every member still on those versions. From this version on, an unknown `resources:` key prints one warning naming the role or project and the key, and the scope syncs as if the key were absent; `teamai roles` and `teamai projects` keep the key when they save the manifest. `teamai roles|projects add/update --namespaces` never write the new keys, so nothing declares them until an admin does by hand (for [#707](https://github.com/Tencent/teamai-cli/issues/707)). diff --git a/docs/usage-guide.md b/docs/usage-guide.md index 651dcc275..a683b981d 100644 --- a/docs/usage-guide.md +++ b/docs/usage-guide.md @@ -1676,7 +1676,7 @@ In a project, teamai installs the Hermes plugin `$HERMES_HOME/plugins/teamai-ins OpenCode loads a file only when its config lists it in `instructions`, so teamai adds that one entry and keeps your other entries and keys; the root `opencode.json` and OpenCode's own `AGENTS.md` files are left alone. While `~/.config/opencode/AGENTS.md` does not exist, OpenCode reads `~/.claude/CLAUDE.md` instead; when Claude Code gets the user blocks there, OpenCode already has them, so teamai writes no second user copy for OpenCode and says so in the pull output. Blocks left there by a Claude Code you excluded count too, since OpenCode reads them all the same; the pull then warns that nothing keeps them current. A config file teamai cannot parse as JSON (for example one with comments) is left unchanged with a warning; add the entry by hand. -Oh My Pi reads `RULES.md` as an always-applied rule beside its single user context file. In project scope teamai's OMP extension asks `teamai` for the blocks when the session starts and adds them to each turn's system prompt, from the project root and any subdirectory. Without that extension (for example with hooks removed), an Oh My Pi project session gets no team blocks. Prompts the HTTP local agent delivers for a project reach Pi, Oh My Pi and Hermes the same way, through their extension or plugin. +Oh My Pi reads `RULES.md` as an always-applied rule beside its single user context file. In project scope teamai's OMP extension asks `teamai` for the blocks when the session starts and adds them to each turn's system prompt, from the project root and any subdirectory. Without that extension (for example with hooks removed), an Oh My Pi project session gets no team blocks. Prompts the HTTP local agent delivers for a project reach Pi, Oh My Pi and Hermes the same way, through their extension or plugin, and the Codex family through its session-start and subagent-start hooks. A pull from an earlier release may have left these blocks in a file listed below. The next pull removes them, and names each file it changes: diff --git a/docs/usage-guide.zh-CN.md b/docs/usage-guide.zh-CN.md index 60a7c19e8..6753ca438 100644 --- a/docs/usage-guide.zh-CN.md +++ b/docs/usage-guide.zh-CN.md @@ -1554,7 +1554,7 @@ CodeBuddy 和 WorkBuddy 的规则文件带有 `alwaysApply: true`,CodeBuddy OpenCode 只加载配置中 `instructions` 列出的文件,因此 teamai 只添加这一项,并保留你的其他条目和键;根目录的 `opencode.json` 以及 OpenCode 自己的 `AGENTS.md` 文件保持不变。当 `~/.config/opencode/AGENTS.md` 不存在时,OpenCode 会改为读取 `~/.claude/CLAUDE.md`;若 Claude Code 的用户块已在那里,OpenCode 已经获得它们,因此 teamai 不会为 OpenCode 再写一份用户副本,并在 pull 输出中说明。被排除的 Claude Code 留在那里的块同样算数,因为 OpenCode 照样读取它们;此时 pull 会警告没有任何工具再更新它们。teamai 无法按 JSON 解析的配置文件(例如带注释的文件)保持不变并给出警告,请手动添加该条目。 -Oh My Pi 把 `RULES.md` 作为始终应用的规则读取,与其唯一的用户上下文文件并存。在项目范围内,teamai 的 OMP 扩展在会话开始时向 `teamai` 获取这些块,并加入每轮的系统提示,无论会话从项目根目录还是子目录启动。没有该扩展时(例如移除了 hooks),Oh My Pi 的项目会话不会获得团队块。HTTP local agent 为项目下发的 prompt 也以同样方式,通过扩展或插件到达 Pi、Oh My Pi 和 Hermes。 +Oh My Pi 把 `RULES.md` 作为始终应用的规则读取,与其唯一的用户上下文文件并存。在项目范围内,teamai 的 OMP 扩展在会话开始时向 `teamai` 获取这些块,并加入每轮的系统提示,无论会话从项目根目录还是子目录启动。没有该扩展时(例如移除了 hooks),Oh My Pi 的项目会话不会获得团队块。HTTP local agent 为项目下发的 prompt 也以同样方式,通过扩展或插件到达 Pi、Oh My Pi 和 Hermes,并通过 session-start 和 subagent-start hook 到达 Codex 系列。 早期版本的 pull 可能把这些块留在下列文件中。下一次 pull 会移除它们,并列出所修改的每个文件: diff --git a/src/__tests__/hook-handlers.test.ts b/src/__tests__/hook-handlers.test.ts index 5683237e1..81d2879da 100644 --- a/src/__tests__/hook-handlers.test.ts +++ b/src/__tests__/hook-handlers.test.ts @@ -828,7 +828,7 @@ describe('hook-handlers registry', () => { // A new handler must decide: team handlers set requiresConfig, the rest join this list. const names = new Set(filterHandlersForConfig(buildHandlerRegistry(), null).map((r) => r.handler.name)); expect([...names].sort()).toEqual([ - 'local-agent-instructions', + 'http-prompt-instructions', 'local-agent-sync', 'package-pending-hint', 'pull', diff --git a/src/__tests__/local-agent.test.ts b/src/__tests__/local-agent.test.ts index da9ffc37e..c505506a7 100644 --- a/src/__tests__/local-agent.test.ts +++ b/src/__tests__/local-agent.test.ts @@ -2125,6 +2125,31 @@ describe('local-agent: project prompts reach every installed tool (#945)', () => expect(outputs.filter(Boolean).join('\n')).toContain('PROJECT-PROMPT'); }); + it('leaves the blocks an earlier release left for another installed tool that this prompt did not reach', async () => { + const legacy = '# Team notes\n\n\nClaude\'s earlier selection\n\n'; + const { repo, ack } = await installProjectPrompt('codebuddy', ['.codebuddy/skills', '.claude/skills'], { + files: { '.claude/CLAUDE.md': legacy }, + }); + + expect(ack?.status).toBe('success'); + expect(await fse.readFile(path.join(repo, '.codebuddy', 'rules', 'teamai-context.md'), 'utf8')).toContain('PROJECT-PROMPT'); + expect(await fse.readFile(path.join(repo, '.claude', 'CLAUDE.md'), 'utf8')).toBe(legacy); + }); + + it('gives Codex a project prompt through its SessionStart and SubagentStart hooks', async () => { + const { repo, ack } = await installProjectPrompt('codex', ['.codex/skills']); + + expect(ack?.status).toBe('success'); + const { buildHandlerRegistry, filterHandlersForConfig } = await import('../hook-handlers.js'); + const registry = filterHandlersForConfig(buildHandlerRegistry(), null); + for (const [event, hookEventName] of [['session-start', 'SessionStart'], ['subagent-start', 'SubagentStart']] as const) { + const outputs = (await Promise.all(registry.filter((reg) => reg.event === event) + .map((reg) => reg.handler.execute({ cwd: repo, hook_event_name: hookEventName, source: 'startup' } as never, 'codex', null as never)))) + .filter((output): output is string => typeof output === 'string' && output.includes('PROJECT-PROMPT')); + expect(outputs.map((output) => JSON.parse(output).hookSpecificOutput.hookEventName)).toEqual([hookEventName]); + } + }); + it('fails a Pi project prompt while its extension is missing', async () => { const { ack } = await installProjectPrompt('pi', ['.pi/skills']); diff --git a/src/__tests__/pull-tombstone.test.ts b/src/__tests__/pull-tombstone.test.ts index 1dad43a9d..3b7cc0fca 100644 --- a/src/__tests__/pull-tombstone.test.ts +++ b/src/__tests__/pull-tombstone.test.ts @@ -278,6 +278,26 @@ describe('pull role-aware sync and cleanup', () => { await expectAgentRendersGone(); }); + it('keeps teamai\'s instruction file when the team tombstoned a rule named teamai-context (#945)', async () => { + const base = (await loadTeamConfig(repoPath))!; + vi.mocked(loadTeamConfig).mockResolvedValue({ ...base, toolPaths: { cursor: { skills: '.cursor/skills', rules: '.cursor/rules' } } }); + await fse.ensureDir(path.join(homeDir, '.cursor', 'skills')); + await fse.writeFile(path.join(repoPath, 'culture.md'), 'Be kind.\n'); + await fse.writeFile(path.join(repoPath, 'rules', '.removed'), 'teamai-context\n'); + const context = path.join(homeDir, '.cursor', 'rules', 'teamai-context.mdc'); + + await pull({}); + expect(await fse.readFile(context, 'utf8')).toContain('Be kind.'); + vi.mocked(loadStateForScope).mockImplementation(async () => ({ + lastPull: null, + lastPullRev: HEAD_REV, + lastPullTargets: ['cursor'], + }) as Awaited>); + await pull({}); + + expect(await fse.readFile(context, 'utf8')).toContain('Be kind.'); + }); + it('should not delete files that are NOT tombstoned', async () => { // No tombstone files await fse.writeFile(path.join(homeDir, '.claude/rules', 'keep-rule.md'), '# Keep'); diff --git a/src/__tests__/rules.test.ts b/src/__tests__/rules.test.ts index 957e39e6e..3c752ba7c 100644 --- a/src/__tests__/rules.test.ts +++ b/src/__tests__/rules.test.ts @@ -774,6 +774,22 @@ scope: 'user', expect(await fse.pathExists(path.join(localRulesDir, 'fe-know/my-rule.md'))).toBe(false); }); + it('keeps a placed rule named teamai-context at its namespaced path, off teamai\'s instruction file (#945)', async () => { + const teamRulesDir = path.join(localConfig.repo.localPath, 'rules'); + await fse.outputFile(path.join(teamRulesDir, 'fe-know/teamai-context.md'), 'the author\'s namespaced rule'); + const localRulesDir = path.join(homeDir, '.claude/rules'); + vi.mocked(loadStateForScope).mockResolvedValue({ + lastPush: null, lastPull: null, lastPullRev: null, pushedRules: [], pushedSkills: [], + pushedEnvVars: [], pendingPushes: [], lastUpdateCheck: null, availableUpdate: null, + placedRules: { 'teamai-context': 'rules/fe-know/teamai-context.md' }, + } as State); + + await handler.pullAllRules(teamConfig, localConfig); + + expect(await fse.pathExists(path.join(localRulesDir, 'teamai-context.md'))).toBe(false); + expect(await fse.readFile(path.join(localRulesDir, 'fe-know/teamai-context.md'), 'utf-8')).toBe('the author\'s namespaced rule'); + }); + it('does not redirect a placed rule onto a root path a shared-root rule of the same name owns', async () => { const teamRulesDir = path.join(localConfig.repo.localPath, 'rules'); await fse.outputFile(path.join(teamRulesDir, 'fe-know/my-rule.md'), 'the author\'s namespaced rule'); diff --git a/src/hook-handlers.ts b/src/hook-handlers.ts index 0bcbc23bc..0dd9cf912 100644 --- a/src/hook-handlers.ts +++ b/src/hook-handlers.ts @@ -771,19 +771,26 @@ const instructionsHandler: HookHandler = { }; /** - * `instructions` from the HTTP local agent's resource cache for the session's - * project (#945): Pi, OMP and Hermes have no project file it could write to. - * Runs without a teamai config, since an HTTP-only machine has none. + * The HTTP local agent's prompts from its resource cache for the session's + * project (#945), for a tool with no project file the agent could write to. + * Pi, OMP and Hermes ask through `instructions`; the Codex family gets them + * at SessionStart and SubagentStart, beside the team rules. Runs without a + * teamai config, since an HTTP-only machine has none. */ const localAgentInstructionsHandler: HookHandler = { - name: 'local-agent-instructions', + name: 'http-prompt-instructions', async execute(stdin, tool) { const { deliversInstructionsByHook } = await import('./instruction-targets.js'); + const { getsRulesFromSessionHook } = await import('./resources/rule-format.js'); if (!deliversInstructionsByHook(tool, 'project')) return null; + const sessionEvent = stdin.hook_event_name === 'SessionStart' || stdin.hook_event_name === 'SubagentStart'; + // Each tool through its own channel only; a resumed Codex session holds them already. + if (getsRulesFromSessionHook(tool) !== sessionEvent || stdin.source === 'resume') return null; const { localAgentInstructionText } = await import('./local-agent.js'); const text = await localAgentInstructionText(resolveHookCwd(stdin) ?? process.cwd()); if (!text) return null; - return JSON.stringify({ hookSpecificOutput: { hookEventName: 'SessionStart', additionalContext: text } }); + const hookEventName = stdin.hook_event_name === 'SubagentStart' ? 'SubagentStart' : 'SessionStart'; + return JSON.stringify({ hookSpecificOutput: { hookEventName, additionalContext: text } }); }, }; @@ -899,6 +906,7 @@ export function buildHandlerRegistry(): HandlerRegistration[] { { event: 'session-start', matcher: '*', handler: pullHandler, timeoutMs: PULL_TIMEOUT_MS, background: true }, { event: 'session-start', matcher: '*', handler: dashboardReportHandler, timeoutMs: FOREGROUND_HOOK_TIMEOUT_MS, requiresConfig: true }, { event: 'session-start', matcher: '*', handler: teamRulesHandler, timeoutMs: FOREGROUND_HOOK_TIMEOUT_MS, requiresConfig: true }, + { event: 'session-start', matcher: '*', handler: localAgentInstructionsHandler, timeoutMs: FOREGROUND_HOOK_TIMEOUT_MS }, { event: 'session-start', matcher: '*', handler: mrHintHandler, timeoutMs: FOREGROUND_HOOK_TIMEOUT_MS, gitOnly: true, requiresConfig: true }, { event: 'session-start', matcher: '*', handler: packageHintHandler, timeoutMs: FOREGROUND_HOOK_TIMEOUT_MS, requiresConfig: true }, { event: 'session-start', matcher: '*', handler: secretsHintHandler, timeoutMs: FOREGROUND_HOOK_TIMEOUT_MS, requiresConfig: true }, @@ -938,6 +946,7 @@ export function buildHandlerRegistry(): HandlerRegistration[] { // ─── SubagentStart ──────────────────────────────── // Codex only (SUBAGENT_START_SPEC): a fresh subagent fires no SessionStart (#938). { event: 'subagent-start', matcher: '*', handler: teamRulesHandler, timeoutMs: FOREGROUND_HOOK_TIMEOUT_MS, requiresConfig: true }, + { event: 'subagent-start', matcher: '*', handler: localAgentInstructionsHandler, timeoutMs: FOREGROUND_HOOK_TIMEOUT_MS }, // ─── SubagentStop ───────────────────────────────── // A subagent can finish after the session's last Stop (a background diff --git a/src/local-agent.ts b/src/local-agent.ts index bd08acdf9..4b7837450 100644 --- a/src/local-agent.ts +++ b/src/local-agent.ts @@ -54,6 +54,7 @@ import { logHttpRequest, logHttpResponse } from './utils/http-log.js'; import { applyInstructionPlan, deliversInstructionsByHook, instructionHookChannel, instructionHookText, instructionHookTextFor, instructionTargetAt, instructionTargetFile, isInstructionToolInstalled, planInstructionFiles, registerOpencodeContext, resolveInstructionTargets, + retiredInstructionFiles, type InstructionTarget, } from './instruction-targets.js'; import { opencodeClaudeFallback } from './resources/opencode-config.js'; import { reconcilePlugins, teardownAllPlugins, parseGetConfig, substituteVars, unresolvedPlaceholders, type ReconcileDeps, type PluginState } from './plugin-lifecycle.js'; @@ -2101,6 +2102,7 @@ async function syncClaudemd( let syncedAny = false; // Why each tool got nothing, for the ACK when none did. const skipped: string[] = []; + const reached: string[] = []; for (const [tool, toolPath] of Object.entries(scopedToolPaths(teamConfig, localConfig))) { // Pi, OMP and Hermes in a project take the cache from their extension or @@ -2114,6 +2116,7 @@ async function syncClaudemd( } log.debug(`local-agent: ${tool} adds the CLAUDE.md instructions through its extension`); syncedAny = true; + reached.push(tool); continue; } const targetFile = instructionTargetFile(tool, toolPath, localConfig.scope); @@ -2165,10 +2168,10 @@ async function syncClaudemd( if (tool === 'opencode') await registerOpencodeContext(teamConfig, localConfig, { targets: [target], stale: [] }, false); log.debug(`local-agent: ${block ? 'synced' : 'removed'} CLAUDE.md instructions for ${tool}`); syncedAny = true; + reached.push(tool); } - const { stale } = await resolveInstructionTargets(fullTeamConfig, localConfig); - const cleanup = await planInstructionFiles([], {}, stale); + const cleanup = await planInstructionFiles([], {}, await retiredFilesOfReached(fullTeamConfig, localConfig, reached)); for (const warning of cleanup.warnings) log.warn(warning); const { report, failures } = await applyInstructionPlan(cleanup, { dryRun: false }); for (const line of report) log.info(`${line}: no installed tool loads them from this file`); @@ -2179,6 +2182,29 @@ async function syncClaudemd( } } +/** + * The files earlier releases wrote blocks to that this sync may strip: no + * installed tool reads them now, and every installed tool that wrote them, if + * any, got this sync's instructions in their place. Another tool's old blocks stay + * until a sync reaches it, since the HTTP agent delivers to one tool at a time. + */ +async function retiredFilesOfReached( + fullTeamConfig: TeamaiConfig, + localConfig: LocalConfig, + reached: readonly string[], +): Promise { + const { stale } = await resolveInstructionTargets(fullTeamConfig, localConfig); + const writers = new Map(); + for (const [tool, paths] of Object.entries(scopedToolPaths(fullTeamConfig, localConfig))) { + if (!await isInstructionToolInstalled(tool, paths, localConfig)) continue; + for (const file of retiredInstructionFiles(tool, paths, localConfig.scope)) { + const absolute = path.resolve(resolveToolBaseDir(tool, localConfig), file); + writers.set(absolute, [...writers.get(absolute) ?? [], tool]); + } + } + return stale.filter((target) => (writers.get(target.path) ?? []).every((tool) => reached.includes(tool))); +} + /** * Why a hook tool cannot add the HTTP agent's instructions in this scope, or * null when it can: not installed, its extension or plugin not ready, or the diff --git a/src/pull.ts b/src/pull.ts index a5af74514..f8e34e0dd 100644 --- a/src/pull.ts +++ b/src/pull.ts @@ -443,6 +443,7 @@ async function cleanupTombstonedResources( { type: 'agents', toolPathField: 'agents' }, ]; + const { TEAMAI_CONTEXT_RULE_NAME } = await import('./builtin-rules.js'); for (const { type, toolPathField } of tombstoneTypes) { const handler = getHandler(type); // Agents deploy flattened, so a namespaced agent tombstone has to be read @@ -463,6 +464,10 @@ async function cleanupTombstonedResources( for (const extension of tombstoneExtensions(type, tool)) { const localPath = path.join(baseDir, dir, `${name}${extension}`); if (!await pathExists(localPath)) continue; + // teamai-context in a rules directory is teamai's instruction file + // now (#945): a tombstone of a team rule by that name, from before, + // does not reach it. A copy without the blocks is reclaimed by rules sync. + if (type === 'rules' && name === TEAMAI_CONTEXT_RULE_NAME) continue; // Even an upstream (tombstone) removal must not blow away a local // repo's stash/unpushed history inside a skill directory. Keep // + warn; the user can delete it manually once backed up. diff --git a/src/resources/rules.ts b/src/resources/rules.ts index 9228adbbf..632890e94 100644 --- a/src/resources/rules.ts +++ b/src/resources/rules.ts @@ -299,7 +299,9 @@ export class RulesHandler extends ResourceHandler { */ private async localNameFor(teamName: string, localConfig: LocalConfig): Promise { const bareName = path.basename(teamName); - if (bareName === teamName) return teamName; + // teamai-context at the root is teamai's instruction file (#945), so a + // placed rule of that name keeps its namespaced path. + if (bareName === teamName || bareName === TEAMAI_CONTEXT_RULE_NAME) return teamName; const placed = placedResourcePath( (await loadStateForScope(localConfig)).placedRules, 'rules', bareName, ); From 9bd6904d9aa7000ce2d2f6b6ff4b7279887b9c4d Mon Sep 17 00:00:00 2001 From: Saul Moro Date: Fri, 2 Oct 2026 10:41:57 +0200 Subject: [PATCH 29/41] fix(pull): address review round 10 of #945 - A Codex project HTTP prompt is delivered when Codex is installed for the member: its hooks are user-level, so a project without .codex/ still reaches it. - Uninstall leaves OpenCode's instructions entry for a teamai-context.md that holds none of teamai's blocks: that file is the member's. --- docs/usage-guide.md | 2 +- docs/usage-guide.zh-CN.md | 2 +- src/__tests__/local-agent.test.ts | 5 ++++- src/__tests__/uninstall.test.ts | 9 +++++---- src/instruction-targets.ts | 5 ++++- src/uninstall.ts | 8 ++++++-- 6 files changed, 21 insertions(+), 10 deletions(-) diff --git a/docs/usage-guide.md b/docs/usage-guide.md index a683b981d..aa3546cce 100644 --- a/docs/usage-guide.md +++ b/docs/usage-guide.md @@ -2845,7 +2845,7 @@ teamai uninstall --agent claude What gets removed: - TeamAI-managed model settings are restored first when ownership is still intact - teamai hooks in AI tool settings -- The teamai blocks (culture, shared instructions, recall, and Codex's team rules) in each tool's instruction file, and the files an earlier release wrote them to (your own content is preserved; a `teamai-context` file teamai wrote is removed whole, and OpenCode's `instructions` entry for it goes too, even when your own text keeps the file or the file is gone) +- 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, even when your own text keeps the file or the file is gone; a `teamai-context` file without teamai's blocks is yours and keeps its entry) - 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 custom agents and CLI built-in agents (your own agents are preserved) diff --git a/docs/usage-guide.zh-CN.md b/docs/usage-guide.zh-CN.md index 6753ca438..a29631b1a 100644 --- a/docs/usage-guide.zh-CN.md +++ b/docs/usage-guide.zh-CN.md @@ -2656,7 +2656,7 @@ teamai uninstall --agent claude 移除内容: - 如果 ownership 仍有效,先恢复 TeamAI 管理的模型配置 - AI 工具 settings 中的 teamai hooks -- 各工具指令文件中的 teamai 块(文化、共享指令、recall 以及 Codex 的团队规则),以及早期版本写过这些块的文件(保留用户自写内容;teamai 写入的 `teamai-context` 文件整体删除,OpenCode 中对应的 `instructions` 条目一并移除,即使你自写的内容让该文件保留下来,或该文件已不存在) +- 各工具指令文件中的 teamai 块(文化、共享指令、recall 以及 Codex 的团队规则),以及早期版本写过这些块的文件(保留用户自写内容;teamai 写入的 `teamai-context` 文件整体删除,OpenCode 中对应的 `instructions` 条目一并移除,即使你自写的内容让该文件保留下来,或该文件已不存在;不含 teamai 块的 `teamai-context` 文件属于你,其条目保留) - 团队同步的 skills,包括 OpenClaw workspace skills(保留用户自建 skills) - 团队同步的 rules,包括旧版本留在 `.codex/rules/` 中的副本,团队此后已删除的 rule 的副本也包括在内。清理使用记录的 `toolRoots` 位置和发布者本地的文件名。其中你改过的副本会保留,并在警告中点名。已删除 rule 的副本只有与记录的投递哈希一致时才会删除;没有该记录时也会保留并点名。Codex 的 `*.rules` 文件保留 - 团队同步的自定义 agents 和 CLI 内置 agents(保留用户自建 agents) diff --git a/src/__tests__/local-agent.test.ts b/src/__tests__/local-agent.test.ts index c505506a7..d66d371e4 100644 --- a/src/__tests__/local-agent.test.ts +++ b/src/__tests__/local-agent.test.ts @@ -2137,7 +2137,10 @@ describe('local-agent: project prompts reach every installed tool (#945)', () => }); it('gives Codex a project prompt through its SessionStart and SubagentStart hooks', async () => { - const { repo, ack } = await installProjectPrompt('codex', ['.codex/skills']); + // Codex is installed for the member (~/.codex), not in the project. + await fse.ensureDir(path.join(tmpDir, '.codex', 'skills')); + const { repo, ack } = await installProjectPrompt('codex', []); + expect(await fse.pathExists(path.join(repo, '.codex'))).toBe(false); expect(ack?.status).toBe('success'); const { buildHandlerRegistry, filterHandlersForConfig } = await import('../hook-handlers.js'); diff --git a/src/__tests__/uninstall.test.ts b/src/__tests__/uninstall.test.ts index 0e53bf772..e90688f6e 100644 --- a/src/__tests__/uninstall.test.ts +++ b/src/__tests__/uninstall.test.ts @@ -1595,9 +1595,10 @@ describe('uninstall', () => { }); it.each([ - ['deleted', null], - ['stripped of its markers', '# My own notes\n'], - ])('removes OpenCode\'s instructions entry when its context file was %s (#945)', async (_state, content) => { + ['deleted', null, ['docs/style.md']], + ['teamai\'s', '\nx\n\n', ['docs/style.md']], + ['the member\'s own', '# My own notes\n', ['docs/style.md', '.opencode/teamai-context.md']], + ])('handles OpenCode\'s instructions entry when its context file is %s (#945)', async (_state, content, expected) => { const projectRoot = path.join(tmpDir, 'oc-entry-project'); const homeDir = path.join(tmpDir, 'home'); const repoPath = path.join(projectRoot, '.teamai', 'team-repo'); @@ -1615,7 +1616,7 @@ describe('uninstall', () => { await uninstall({ force: true, agent: 'opencode' }); - expect((await fse.readJson(config)).instructions).toEqual(['docs/style.md']); + expect((await fse.readJson(config)).instructions).toEqual(expected); }); // A relocated Claude Code root (toolRoots) moves the HOME hook file, but the diff --git a/src/instruction-targets.ts b/src/instruction-targets.ts index 7471eec12..be76865d1 100644 --- a/src/instruction-targets.ts +++ b/src/instruction-targets.ts @@ -404,7 +404,10 @@ export async function resolveInstructionTargets( for (const [tool, paths] of Object.entries(toolPaths)) { const entry = entryFor(tool, localConfig.scope); if (entry?.hook) { - if (!isAgentExcluded(localConfig, tool) && await isInstructionToolInstalled(tool, paths, localConfig)) { + // Codex's hooks are user-level, so a project without its own `.codex/` + // still reaches an installed Codex. + const probeConfig: LocalConfig = entry === codexHook ? { ...localConfig, scope: 'user' } : localConfig; + if (!isAgentExcluded(localConfig, tool) && await isInstructionToolInstalled(tool, paths, probeConfig)) { hooks.push({ tool, recall: Boolean(paths.agents), limit: entry.hookLimit }); } continue; diff --git a/src/uninstall.ts b/src/uninstall.ts index 9919fcf39..b6ed1f62f 100644 --- a/src/uninstall.ts +++ b/src/uninstall.ts @@ -456,9 +456,13 @@ async function discoverToolResources( res.claudeMdFiles.push(claudeMdPath); } } - if (tool === 'opencode' && instructionFile) { + // A teamai-context.md without teamai's blocks is the member's and keeps its + // entry, as on pull; an entry to a file that is gone is dropped. + const contextFile = instructionFile ? path.resolve(baseDir, instructionFile) : undefined; + if (tool === 'opencode' && contextFile + && (res.claudeMdFiles.includes(contextFile) || !await pathExists(contextFile))) { const { opencodeContextReference, readOpencodeInstructionList } = await import('./resources/opencode-config.js'); - const reference = opencodeContextReference(path.resolve(baseDir, instructionFile), scope, baseDir); + const reference = opencodeContextReference(contextFile, scope, baseDir); if ((await readOpencodeInstructionList(reference.config))?.includes(reference.entry)) res.opencodeInstructions.push(reference); } for (const retired of retiredInstructionFiles(tool, toolPath, scope)) { From ffa36c80f5fb882f458025123153602ab5a76de9 Mon Sep 17 00:00:00 2001 From: Saul Moro Date: Fri, 2 Oct 2026 11:14:53 +0200 Subject: [PATCH 30/41] fix(pull): address review round 11 of #945 - Pi counts as installed for project instructions when ~/.pi exists: teamai installs its extension there, so a project needs no .pi/. - teamai records the OpenCode instructions entry it adds, and uninstall removes a recorded entry even after the member stripped the markers from the context file. --- docs/usage-guide.md | 2 +- docs/usage-guide.zh-CN.md | 2 +- src/__tests__/instruction-targets.test.ts | 36 ++++++++++++++++++++++- src/__tests__/uninstall.test.ts | 8 ++++- src/instruction-targets.ts | 13 ++++++++ src/types.ts | 6 ++++ src/uninstall.ts | 12 ++++++++ 7 files changed, 75 insertions(+), 4 deletions(-) diff --git a/docs/usage-guide.md b/docs/usage-guide.md index aa3546cce..e0e27db64 100644 --- a/docs/usage-guide.md +++ b/docs/usage-guide.md @@ -2845,7 +2845,7 @@ teamai uninstall --agent claude What gets removed: - TeamAI-managed model settings are restored first when ownership is still intact - teamai hooks in AI tool settings -- The teamai blocks (culture, shared instructions, recall, and Codex's team rules) in each tool's instruction file, and the files an earlier release wrote them to (your own content is preserved; a `teamai-context` file teamai wrote is removed whole, and OpenCode's `instructions` entry for it goes too, even when your own text keeps the file or the file is gone; a `teamai-context` file without teamai's blocks is yours and keeps its entry) +- 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, even when your own text keeps the file or the file is gone; an entry teamai did not add, for a `teamai-context` file without teamai's blocks, is yours and 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 custom agents and CLI built-in agents (your own agents are preserved) diff --git a/docs/usage-guide.zh-CN.md b/docs/usage-guide.zh-CN.md index a29631b1a..0a0bb8758 100644 --- a/docs/usage-guide.zh-CN.md +++ b/docs/usage-guide.zh-CN.md @@ -2656,7 +2656,7 @@ teamai uninstall --agent claude 移除内容: - 如果 ownership 仍有效,先恢复 TeamAI 管理的模型配置 - AI 工具 settings 中的 teamai hooks -- 各工具指令文件中的 teamai 块(文化、共享指令、recall 以及 Codex 的团队规则),以及早期版本写过这些块的文件(保留用户自写内容;teamai 写入的 `teamai-context` 文件整体删除,OpenCode 中对应的 `instructions` 条目一并移除,即使你自写的内容让该文件保留下来,或该文件已不存在;不含 teamai 块的 `teamai-context` 文件属于你,其条目保留) +- 各工具指令文件中的 teamai 块(文化、共享指令、recall 以及 Codex 的团队规则),以及早期版本写过这些块的文件(保留用户自写内容;teamai 写入的 `teamai-context` 文件整体删除,OpenCode 中对应的 `instructions` 条目一并移除,即使你自写的内容让该文件保留下来,或该文件已不存在;并非 teamai 添加、且对应文件不含 teamai 块的条目属于你,予以保留) - 团队同步的 skills,包括 OpenClaw workspace skills(保留用户自建 skills) - 团队同步的 rules,包括旧版本留在 `.codex/rules/` 中的副本,团队此后已删除的 rule 的副本也包括在内。清理使用记录的 `toolRoots` 位置和发布者本地的文件名。其中你改过的副本会保留,并在警告中点名。已删除 rule 的副本只有与记录的投递哈希一致时才会删除;没有该记录时也会保留并点名。Codex 的 `*.rules` 文件保留 - 团队同步的自定义 agents 和 CLI 内置 agents(保留用户自建 agents) diff --git a/src/__tests__/instruction-targets.test.ts b/src/__tests__/instruction-targets.test.ts index 04bb8d98b..ec204612b 100644 --- a/src/__tests__/instruction-targets.test.ts +++ b/src/__tests__/instruction-targets.test.ts @@ -181,7 +181,9 @@ describe('instruction channel problems (#945)', () => { try { const projectRoot = path.join(root, 'project'); const repo = path.join(root, 'repo'); - fs.mkdirSync(path.join(projectRoot, '.pi', 'skills'), { recursive: true }); + // Pi is installed for the member (~/.pi); the project has no .pi/. + fs.mkdirSync(path.join(root, 'home', '.pi'), { recursive: true }); + fs.mkdirSync(projectRoot, { recursive: true }); fs.mkdirSync(path.join(repo, 'claudemd'), { recursive: true }); fs.writeFileSync(path.join(repo, 'claudemd', 'shared.md'), 'Shared.\n'); const teamConfig = TeamaiConfigSchema.parse({ team: 't', repo: 'https://example.invalid/t.git' }); @@ -360,6 +362,38 @@ describe('OpenCode instructions registration (#945)', () => { }); }); +describe('OpenCode instructions ownership (#945)', () => { + it('records the entry teamai adds, for uninstall, and forgets it once removed', async () => { + const root = fs.realpathSync.native(fs.mkdtempSync(path.join(os.tmpdir(), 'teamai-945-ocown-'))); + const prevHome = process.env.HOME; + process.env.HOME = path.join(root, 'home'); + try { + const projectRoot = path.join(root, 'project'); + const context = path.join(projectRoot, '.opencode', 'teamai-context.md'); + fs.mkdirSync(path.join(projectRoot, '.opencode', 'skills'), { recursive: true }); + fs.writeFileSync(context, `${claudemd('team')}\n`); + const config = path.join(projectRoot, '.opencode', 'opencode.json'); + const localConfig = { + repo: { localPath: path.join(root, 'repo'), remote: 'https://example.invalid/t.git' }, + username: 'u', additionalRoles: [], scope: 'project', projectRoot, dataHome: path.join(root, 'data'), + } as unknown as LocalConfig; + const teamConfig = TeamaiConfigSchema.parse({ team: 't', repo: 'https://example.invalid/t.git' }); + const { loadStateForScope } = await import('../config.js'); + + await registerOpencodeContext(teamConfig, localConfig, await resolveInstructionTargets(teamConfig, localConfig), false); + expect((await loadStateForScope(localConfig)).opencodeContextEntries).toEqual([{ config, entry: '.opencode/teamai-context.md' }]); + + fs.rmSync(context); + const resolved = await resolveInstructionTargets(teamConfig, localConfig); + await registerOpencodeContext(teamConfig, localConfig, { targets: [], stale: resolved.targets }, false); + expect((await loadStateForScope(localConfig)).opencodeContextEntries).toEqual([]); + } finally { + process.env.HOME = prevHome; + fs.rmSync(root, { recursive: true, force: true }); + } + }); +}); + describe('every tool and toolPaths shape keeps its instructions (#945)', () => { type Paths = TeamaiConfig['toolPaths'][string]; const DEFAULTS = TeamaiConfigSchema.parse({ team: 't', repo: 'https://example.invalid/t.git' }); diff --git a/src/__tests__/uninstall.test.ts b/src/__tests__/uninstall.test.ts index e90688f6e..35938243b 100644 --- a/src/__tests__/uninstall.test.ts +++ b/src/__tests__/uninstall.test.ts @@ -1598,7 +1598,8 @@ describe('uninstall', () => { ['deleted', null, ['docs/style.md']], ['teamai\'s', '\nx\n\n', ['docs/style.md']], ['the member\'s own', '# My own notes\n', ['docs/style.md', '.opencode/teamai-context.md']], - ])('handles OpenCode\'s instructions entry when its context file is %s (#945)', async (_state, content, expected) => { + ['teamai\'s with its markers stripped', 'Generated text.\n', ['docs/style.md'], true], + ])('handles OpenCode\'s instructions entry when its context file is %s (#945)', async (_state, content, expected, recorded = false) => { const projectRoot = path.join(tmpDir, 'oc-entry-project'); const homeDir = path.join(tmpDir, 'home'); const repoPath = path.join(projectRoot, '.teamai', 'team-repo'); @@ -1613,6 +1614,11 @@ describe('uninstall', () => { const teamConfig = makeTeamConfig({ toolPaths: { opencode: { skills: '.opencode/skills', rules: '.opencode/rules' } } }); const localConfig = makeLocalConfig(projectRoot, repoPath, { scope: 'project', projectRoot }); mockAutoDetectInit.mockResolvedValue({ localConfig, teamConfig }); + if (recorded) { + const { loadStateForScope, saveStateForScope } = await import('../config.js'); + const state = await loadStateForScope(localConfig); + await saveStateForScope({ ...state, opencodeContextEntries: [{ config, entry: '.opencode/teamai-context.md' }] }, localConfig); + } await uninstall({ force: true, agent: 'opencode' }); diff --git a/src/instruction-targets.ts b/src/instruction-targets.ts index be76865d1..9d457c168 100644 --- a/src/instruction-targets.ts +++ b/src/instruction-targets.ts @@ -384,6 +384,9 @@ export async function isInstructionToolInstalled(tool: string, paths: ToolPaths, // teamai installs the OMP extension only where ~/.omp exists; a project's // own .omp/ says nothing about this member using OMP. if (tool === 'omp') return pathExists(path.join(getUserHome(), '.omp')); + // teamai installs the Pi extension in ~/.pi/agent/extensions when ~/.pi + // exists, so a project needs no .pi/ of its own. + if (tool === 'pi' && await pathExists(path.join(getUserHome(), '.pi'))) return true; const nestedClaudemd = paths.claudemd !== undefined && paths.claudemd.includes('/') ? paths.claudemd : undefined; const probe = paths.skills ?? paths.rules ?? paths.agents ?? paths.settings ?? nestedClaudemd; if (probe === undefined) return paths.claudemd !== undefined; @@ -470,9 +473,19 @@ export async function registerOpencodeContext( return `Would ${present ? 'add' : 'remove'} "${entry}" ${present ? 'to' : 'from'} the instructions of ${config}`; } const changed = await reconcileOpencodeInstructions(config, entry, present, 'team instructions'); + if (changed) await recordOpencodeContextEntry(localConfig, { config, entry }, present); return changed ? `${present ? 'Added' : 'Removed'} "${entry}" ${present ? 'to' : 'from'} the instructions of ${config}` : null; } +/** Remember (`added`) or forget the OpenCode entry teamai wrote, for uninstall. */ +async function recordOpencodeContextEntry(localConfig: LocalConfig, ref: { config: string; entry: string }, added: boolean): Promise { + const { loadStateForScope, saveStateForScope } = await import('./config.js'); + const state = await loadStateForScope(localConfig); + const others = (state.opencodeContextEntries ?? []).filter((e) => e.config !== ref.config || e.entry !== ref.entry); + state.opencodeContextEntries = added ? [...others, ref] : others; + await saveStateForScope(state, localConfig); +} + // ─── Planning file contents ──────────────────────────── /** A file whose content a plan changes; `content: null` deletes it. */ diff --git a/src/types.ts b/src/types.ts index deab01b95..9dcc42884 100644 --- a/src/types.ts +++ b/src/types.ts @@ -825,6 +825,12 @@ export const StateSchema = z.object({ * literals stay valid; the reconciler treats absent as an empty map. */ coAuthorManaged: z.record(z.string(), z.boolean()).optional(), + /** + * OpenCode configs whose `instructions` entry for teamai's context file + * teamai added, as `{ config, entry }`, so uninstall removes the entry even + * after the member stripped the markers from that file (#945). + */ + opencodeContextEntries: z.array(z.object({ config: z.string(), entry: z.string() })).optional(), lastUpdateCheck: z.string().nullable().default(null), availableUpdate: z.string().nullable().default(null), }); diff --git a/src/uninstall.ts b/src/uninstall.ts index b6ed1f62f..1cb9d8f9f 100644 --- a/src/uninstall.ts +++ b/src/uninstall.ts @@ -629,6 +629,18 @@ async function buildRemovalPlan( ); } + // The OpenCode entries teamai added stay teamai's even after the member + // stripped the markers from the context file. + const opencodeRes = perTool.get('opencode'); + if (opencodeRes) { + const { loadStateForScope } = await import('./config.js'); + const { readOpencodeInstructionList } = await import('./resources/opencode-config.js'); + for (const ref of (await loadStateForScope(localConfig)).opencodeContextEntries ?? []) { + if (opencodeRes.opencodeInstructions.some((e) => e.config === ref.config && e.entry === ref.entry)) continue; + if ((await readOpencodeInstructionList(ref.config))?.includes(ref.entry)) opencodeRes.opencodeInstructions.push(ref); + } + } + // (d) continued: the copies a release made in a tool's legacy rules // directory, before its rules moved into its instructions file, which a pull // may not have reclaimed yet. A name is no proof there: only the copies a From d0418d6dad9d7fddb5f5ee778e85ebfc1cb2bccf Mon Sep 17 00:00:00 2001 From: Saul Moro Date: Fri, 2 Oct 2026 11:38:34 +0200 Subject: [PATCH 31/41] fix(pull): address the second adversarial review of #945 against main - The unchanged-revision pull reclaims an earlier release's copy of a team rule named teamai-context before syncing the instructions. - Uninstall removes only this checkout's recorded OpenCode entry, and forgets the entries it removed. - Rule removal skips the teamai-context file that holds teamai's blocks, so a targeted uninstall keeps a shared instruction file. - Codex is probed at the member's recorded tool root. - The HTTP ack fails when OpenCode's config cannot list the prompt file. - A prompt whose HTTP delivery fails leaves the cache as it was, so session hooks do not read it. - The HTTP agent records state in the project's data home. --- src/__tests__/instruction-targets.test.ts | 24 ++++++++ src/__tests__/local-agent.test.ts | 28 ++++++++++ src/__tests__/pull-tombstone.test.ts | 18 ++++++ src/__tests__/uninstall.test.ts | 67 +++++++++++++++++++++++ src/instruction-targets.ts | 14 ++++- src/local-agent.ts | 32 ++++++++++- src/pull.ts | 11 ++++ src/resources/rules.ts | 2 +- src/uninstall.ts | 34 ++++++++++-- 9 files changed, 221 insertions(+), 9 deletions(-) diff --git a/src/__tests__/instruction-targets.test.ts b/src/__tests__/instruction-targets.test.ts index ec204612b..dec27323a 100644 --- a/src/__tests__/instruction-targets.test.ts +++ b/src/__tests__/instruction-targets.test.ts @@ -362,6 +362,30 @@ describe('OpenCode instructions registration (#945)', () => { }); }); +describe('Codex project instructions (#945)', () => { + it('reaches a Codex the member relocated with toolRoots, with no project .codex/', async () => { + const root = fs.realpathSync.native(fs.mkdtempSync(path.join(os.tmpdir(), 'teamai-945-codexroot-'))); + const prevHome = process.env.HOME; + process.env.HOME = path.join(root, 'home'); + try { + const projectRoot = path.join(root, 'project'); + fs.mkdirSync(projectRoot, { recursive: true }); + fs.mkdirSync(path.join(root, 'home', '.codex-work', 'skills'), { recursive: true }); + const localConfig = { + repo: { localPath: path.join(root, 'repo'), remote: 'https://example.invalid/t.git' }, + username: 'u', additionalRoles: [], scope: 'project', projectRoot, toolRoots: { codex: '~/.codex-work' }, + } as unknown as LocalConfig; + + const resolved = await resolveInstructionTargets(TeamaiConfigSchema.parse({ team: 't', repo: 'https://example.invalid/t.git' }), localConfig); + + expect(resolved.hooks.map((hook) => hook.tool)).toContain('codex'); + } finally { + process.env.HOME = prevHome; + fs.rmSync(root, { recursive: true, force: true }); + } + }); +}); + describe('OpenCode instructions ownership (#945)', () => { it('records the entry teamai adds, for uninstall, and forgets it once removed', async () => { const root = fs.realpathSync.native(fs.mkdtempSync(path.join(os.tmpdir(), 'teamai-945-ocown-'))); diff --git a/src/__tests__/local-agent.test.ts b/src/__tests__/local-agent.test.ts index d66d371e4..33bc35f95 100644 --- a/src/__tests__/local-agent.test.ts +++ b/src/__tests__/local-agent.test.ts @@ -2153,6 +2153,31 @@ describe('local-agent: project prompts reach every installed tool (#945)', () => } }); + it('records the OpenCode entry it adds in the project\'s data home, where uninstall reads it', async () => { + // A partitioned project: its data home is under ~/.teamai/projects. + const { projectSlug } = await import('../utils/partition.js'); + const project = path.join(tmpDir, 'project'); + const partition = path.join(tmpDir, '.teamai', 'projects', projectSlug(fs.realpathSync(tmpDir) + '/project')); + await fse.outputFile(path.join(partition, 'config.yaml'), + `repo:\n localPath: ${partition}/team-repo\n remote: https://example.com/t.git\nusername: u\nscope: project\nprojectRoot: ${project}\n`); + const { repo, ack } = await installProjectPrompt('opencode', ['.opencode/skills']); + + expect(ack?.status).toBe('success'); + const { loadStateForScope, resolveDataHomeForScope } = await import('../config.js'); + const dataHome = await resolveDataHomeForScope('project', repo); + expect(dataHome).toBe(partition); + const state = await loadStateForScope({ scope: 'project', projectRoot: repo, dataHome } as never); + expect(state.opencodeContextEntries?.map((e) => e.entry)).toEqual(['.opencode/teamai-context.md']); + }); + + it('fails an OpenCode project prompt whose config teamai cannot list it in', async () => { + const { ack } = await installProjectPrompt('opencode', ['.opencode/skills'], { + files: { '.opencode/opencode.json': '{\n // my settings\n "instructions": []\n}\n' }, + }); + + expect(ack?.status).toBe('failed'); + }); + it('fails a Pi project prompt while its extension is missing', async () => { const { ack } = await installProjectPrompt('pi', ['.pi/skills']); @@ -2176,6 +2201,9 @@ describe('local-agent: project prompts reach every installed tool (#945)', () => const over = await installProjectPrompt('hermes', [], { prompt: 'x'.repeat(4100) }); expect(over.ack?.status).toBe('failed'); expect(String(over.ack?.error)).toMatch(/over the 4000-character limit/); + // The rejected prompt does not reach the session hook either. + const { localAgentInstructionText } = await import('../local-agent.js'); + expect(await localAgentInstructionText(over.repo)).not.toContain('xxxx'); const fits = await installProjectPrompt('hermes', [], { prompt: 'PROJECT-PROMPT' }); expect(fits.ack?.status).toBe('success'); diff --git a/src/__tests__/pull-tombstone.test.ts b/src/__tests__/pull-tombstone.test.ts index 3b7cc0fca..3faaaed8a 100644 --- a/src/__tests__/pull-tombstone.test.ts +++ b/src/__tests__/pull-tombstone.test.ts @@ -298,6 +298,24 @@ describe('pull role-aware sync and cleanup', () => { expect(await fse.readFile(context, 'utf8')).toContain('Be kind.'); }); + it('reclaims an earlier release\'s teamai-context rule copy when the revision is unchanged (#945)', async () => { + const base = (await loadTeamConfig(repoPath))!; + vi.mocked(loadTeamConfig).mockResolvedValue({ ...base, toolPaths: { claude: { skills: '.claude/skills', rules: '.claude/rules' } } }); + await fse.writeFile(path.join(repoPath, 'culture.md'), 'Be kind.\n'); + await fse.writeFile(path.join(repoPath, 'rules', 'teamai-context.md'), '# Old team rule\n'); + const context = path.join(homeDir, '.claude', 'rules', 'teamai-context.md'); + await fse.copy(path.join(repoPath, 'rules', 'teamai-context.md'), context); + vi.mocked(loadStateForScope).mockImplementation(async () => ({ + lastPull: null, + lastPullRev: HEAD_REV, + lastPullTargets: ['claude'], + }) as Awaited>); + + await pull({}); + + expect(await fse.pathExists(context)).toBe(false); + }); + it('should not delete files that are NOT tombstoned', async () => { // No tombstone files await fse.writeFile(path.join(homeDir, '.claude/rules', 'keep-rule.md'), '# Keep'); diff --git a/src/__tests__/uninstall.test.ts b/src/__tests__/uninstall.test.ts index 35938243b..1acb1b8e4 100644 --- a/src/__tests__/uninstall.test.ts +++ b/src/__tests__/uninstall.test.ts @@ -616,6 +616,35 @@ describe('uninstall', () => { expect(await fse.readFile(sharedInstructions, 'utf8')).toContain(TEAMAI_CULTURE_START); }); + it('targeted CodeBuddy uninstall keeps the shared rule when the team still has a rule named teamai-context (#945)', async () => { + const { homeDir, repoPath } = await setupFixture(tmpDir); + const projectRoot = path.join(tmpDir, 'business-repo'); + vi.stubEnv('HOME', homeDir); + vi.stubEnv('SHELL', '/bin/zsh'); + await fse.writeFile(path.join(repoPath, 'rules', 'teamai-context.md'), '# Old team rule\n'); + const sharedInstructions = path.join(projectRoot, '.codebuddy', 'rules', 'teamai-context.md'); + await fse.ensureDir(path.join(projectRoot, '.codebuddy', 'skills')); + await fse.ensureDir(path.join(projectRoot, '.workbuddy', 'skills')); + await fse.outputFile(sharedInstructions, `---\nalwaysApply: true\n---\n\n${TEAMAI_CULTURE_START}\nculture\n${TEAMAI_CULTURE_END}\n`); + const teamConfig = makeTeamConfig({ + toolPaths: { + codebuddy: { skills: '.codebuddy/skills', rules: '.codebuddy/rules' }, + workbuddy: { skills: '.workbuddy/skills', rules: '.workbuddy/rules' }, + }, + }); + const localConfig = makeLocalConfig(homeDir, repoPath, { + scope: 'project', + projectRoot, + enabledAgents: ['codebuddy', 'workbuddy'], + repo: { localPath: repoPath, remote: '', kind: 'self', businessRepoRoot: projectRoot }, + }); + mockAutoDetectInit.mockResolvedValue({ localConfig, teamConfig }); + + await uninstall({ force: true, agent: 'codebuddy' }); + + expect(await fse.readFile(sharedInstructions, 'utf8')).toContain(TEAMAI_CULTURE_START); + }); + it('targeted CodeBuddy uninstall keeps the shared .codebuddy rule for a WorkBuddy entry without claudemd (#945)', async () => { const { homeDir, repoPath } = await setupFixture(tmpDir); const projectRoot = path.join(tmpDir, 'business-repo'); @@ -1625,6 +1654,44 @@ describe('uninstall', () => { expect((await fse.readJson(config)).instructions).toEqual(expected); }); + it('removes only this checkout\'s recorded OpenCode entry, and forgets it (#945)', async () => { + const projectRoot = path.join(tmpDir, 'oc-own-project'); + const sibling = path.join(tmpDir, 'oc-own-sibling'); + const homeDir = path.join(tmpDir, 'home'); + const repoPath = path.join(projectRoot, '.teamai', 'team-repo'); + await fse.ensureDir(repoPath); + await fse.ensureDir(path.join(projectRoot, '.opencode', 'skills')); + // Claude keeps a team skill, so OpenCode is not the last tool. + await fse.outputFile(path.join(repoPath, 'skills', 'team-skill', 'SKILL.md'), '# Team Skill'); + await fse.outputFile(path.join(projectRoot, '.claude', 'skills', 'team-skill', 'SKILL.md'), '# Team Skill'); + const entry = '.opencode/teamai-context.md'; + const config = path.join(projectRoot, '.opencode', 'opencode.json'); + const siblingConfig = path.join(sibling, '.opencode', 'opencode.json'); + await fse.outputJson(config, { instructions: [entry] }); + await fse.outputJson(siblingConfig, { instructions: [entry] }); + await fse.writeFile(path.join(projectRoot, '.opencode', 'teamai-context.md'), 'Generated text.\n'); + vi.stubEnv('HOME', homeDir); + vi.stubEnv('SHELL', '/bin/zsh'); + + const teamConfig = makeTeamConfig({ toolPaths: { + opencode: { skills: '.opencode/skills', rules: '.opencode/rules' }, + claude: { skills: '.claude/skills', rules: '.claude/rules' }, + } }); + const localConfig = makeLocalConfig(projectRoot, repoPath, { scope: 'project', projectRoot }); + mockAutoDetectInit.mockResolvedValue({ localConfig, teamConfig }); + const { loadStateForScope, saveStateForScope } = await import('../config.js'); + await saveStateForScope({ + ...await loadStateForScope(localConfig), + opencodeContextEntries: [{ config, entry }, { config: siblingConfig, entry }], + }, localConfig); + + await uninstall({ force: true, agent: 'opencode' }); + + expect((await fse.readJson(config)).instructions).toBeUndefined(); + expect((await fse.readJson(siblingConfig)).instructions).toEqual([entry]); + expect((await loadStateForScope(localConfig)).opencodeContextEntries).toEqual([{ config: siblingConfig, entry }]); + }); + // 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/instruction-targets.ts b/src/instruction-targets.ts index 9d457c168..6513c9358 100644 --- a/src/instruction-targets.ts +++ b/src/instruction-targets.ts @@ -409,8 +409,18 @@ export async function resolveInstructionTargets( if (entry?.hook) { // Codex's hooks are user-level, so a project without its own `.codex/` // still reaches an installed Codex. - const probeConfig: LocalConfig = entry === codexHook ? { ...localConfig, scope: 'user' } : localConfig; - if (!isAgentExcluded(localConfig, tool) && await isInstructionToolInstalled(tool, paths, probeConfig)) { + // Probe at the member's recorded root (toolRoots), as hook install does. + let probeConfig = localConfig; + let probePaths = paths; + if (entry === codexHook) { + // A project config records no tool roots; the member's are in the + // user config, which a user-scope init may have written later. + const { loadLocalConfig } = await import('./config.js'); + const toolRoots = localConfig.toolRoots ?? (await loadLocalConfig())?.toolRoots; + probeConfig = { ...localConfig, scope: 'user', toolRoots }; + probePaths = scopedToolPaths(teamConfig, probeConfig)[tool] ?? paths; + } + if (!isAgentExcluded(localConfig, tool) && await isInstructionToolInstalled(tool, probePaths, probeConfig)) { hooks.push({ tool, recall: Boolean(paths.agents), limit: entry.hookLimit }); } continue; diff --git a/src/local-agent.ts b/src/local-agent.ts index 4b7837450..db4b2949b 100644 --- a/src/local-agent.ts +++ b/src/local-agent.ts @@ -704,6 +704,11 @@ async function createResourceLocalConfig( // User-scope paths resolve under $HOME here, so a tool the member relocated // must be addressed at its recorded root — the same one `teamai pull` uses. ...(projectScope ? {} : { toolRoots: await memberToolRoots(workspacePath) }), + // State a sync records (OpenCode's instructions entry) goes to the + // project's data home, where uninstall reads it. + ...(projectScope && workspacePath + ? { dataHome: await (await import('./config.js')).resolveDataHomeForScope('project', workspacePath) } + : {}), }; } @@ -2002,8 +2007,17 @@ async function installDownloadedResource(input: { const mdFile = await resolveMarkdownFromDownload(downloadedPath, input.slug); const dest = path.join(repoPath, 'claudemd', `${input.slug}.md`); await fse.ensureDir(path.dirname(dest)); + const previous = await readFileSafe(dest); await fse.copyFile(mdFile, dest); - await syncClaudemd(teamConfig, localConfig, repoPath, input.workspacePath, fullTeamConfig); + try { + await syncClaudemd(teamConfig, localConfig, repoPath, input.workspacePath, fullTeamConfig); + } catch (error) { + // Session hooks read the cache directly: a prompt that was not + // delivered must not reach them, nor push out the ones that were. + if (previous === null) await remove(dest); + else await fse.writeFile(dest, previous); + throw error; + } } const version = commandVersion(input.command, input.kind); @@ -2165,7 +2179,21 @@ async function syncClaudemd( skipped.push(...failures); continue; } - if (tool === 'opencode') await registerOpencodeContext(teamConfig, localConfig, { targets: [target], stale: [] }, false); + if (tool === 'opencode') { + await registerOpencodeContext(teamConfig, localConfig, { targets: [target], stale: [] }, false); + // OpenCode reads the file only through its `instructions` entry. + if (block) { + const { opencodeContextReference, readOpencodeInstructionList } = await import('./resources/opencode-config.js'); + const { config, entry } = opencodeContextReference(claudeMdPath, localConfig.scope, baseDir); + if (!(await readOpencodeInstructionList(config))?.includes(entry)) { + const problem = `OpenCode does not load ${claudeMdPath}: teamai could not add "${entry}" to the instructions of ${config} ` + + '(the file is missing, unreadable or not plain JSON). Add the entry by hand, or run `teamai doctor`.'; + log.warn(problem); + skipped.push(problem); + continue; + } + } + } log.debug(`local-agent: ${block ? 'synced' : 'removed'} CLAUDE.md instructions for ${tool}`); syncedAny = true; reached.push(tool); diff --git a/src/pull.ts b/src/pull.ts index f8e34e0dd..f2147f64b 100644 --- a/src/pull.ts +++ b/src/pull.ts @@ -1221,6 +1221,17 @@ async function pullForScope( // Refresh the managed instruction blocks as well. A CLI upgrade may // move a target or ship a new recall block while the team repo SHA // and tool target set remain unchanged. + // An earlier release's copy of a team rule named teamai-context + // holds the path the instructions go to; reclaim it first (#945). + if (resourceTypes.includes('rules')) { + try { + await (getHandler('rules') as RulesHandler).reclaimReservedRuleCopies( + freshConfig, localConfig, openLedger(await deliveredHashes(localConfig, state)), + ); + } catch (error) { + log.warn(`[${scopeLabel}] The earlier copy of the team rule teamai-context was not reclaimed: ${(error as Error).message}. Run \`teamai pull --force\` to retry.`); + } + } await syncManagedInstructions(freshConfig, localConfig, roleContext, scopeLabel); // Same reason: a machine that already pulled a tombstone with an older // CLI keeps the copies that CLI failed to delete, and its stored rev diff --git a/src/resources/rules.ts b/src/resources/rules.ts index 632890e94..7ef6973c5 100644 --- a/src/resources/rules.ts +++ b/src/resources/rules.ts @@ -440,7 +440,7 @@ export class RulesHandler extends ResourceHandler { * the record shows it unchanged or it matches what pull rendered for the * team's rule; any other is kept, and the instruction sync names it. */ - private async reclaimReservedRuleCopies( + async reclaimReservedRuleCopies( teamConfig: TeamaiConfig, localConfig: LocalConfig, ledger: DeliveryLedger | undefined, diff --git a/src/uninstall.ts b/src/uninstall.ts index 1cb9d8f9f..8c521b8ef 100644 --- a/src/uninstall.ts +++ b/src/uninstall.ts @@ -33,7 +33,7 @@ import { type Scope, type ManagedMcpManifest, } from './types.js'; -import { BUILTIN_RULE_NAMES } from './builtin-rules.js'; +import { BUILTIN_RULE_NAMES, TEAMAI_CONTEXT_RULE_NAME } from './builtin-rules.js'; import { ruleStemFromFilename, writesInstructionBlock, type InstructionBlock } from './resources/rule-format.js'; import { agentStemFromFilename } from './resources/agent-format.js'; import { resolveDocsDestination } from './resources/docs.js'; @@ -524,6 +524,12 @@ async function discoverToolResources( const ruleName = ruleStemFromFilename(file); if (ruleName === null) continue; if (teamRuleNames.has(ruleName)) { + // teamai's instruction file shares the reserved name; its blocks are + // stripped above, keeping what another tool or the member still uses. + if (ruleName === TEAMAI_CONTEXT_RULE_NAME) { + const text = await readFileSafe(path.join(rulesDir, file)); + if (text && CLAUDEMD_MARKER_PAIRS.some(([start]) => text.includes(start))) continue; + } res.ruleFiles.push(path.join(rulesDir, file)); } } @@ -635,9 +641,17 @@ async function buildRemovalPlan( if (opencodeRes) { const { loadStateForScope } = await import('./config.js'); const { readOpencodeInstructionList } = await import('./resources/opencode-config.js'); - for (const ref of (await loadStateForScope(localConfig)).opencodeContextEntries ?? []) { - if (opencodeRes.opencodeInstructions.some((e) => e.config === ref.config && e.entry === ref.entry)) continue; - if ((await readOpencodeInstructionList(ref.config))?.includes(ref.entry)) opencodeRes.opencodeInstructions.push(ref); + const { opencodeContextReference } = await import('./resources/opencode-config.js'); + const contextFile = toolPaths.opencode && instructionTargetFile('opencode', toolPaths.opencode, localConfig.scope); + // Worktrees share state.json: only this checkout's own record counts. + const own = contextFile + ? opencodeContextReference(path.resolve(resolveToolBaseDir('opencode', localConfig), contextFile), localConfig.scope, resolveToolBaseDir('opencode', localConfig)) + : undefined; + const recorded = (await loadStateForScope(localConfig)).opencodeContextEntries ?? []; + if (own && recorded.some((ref) => ref.config === own.config && ref.entry === own.entry) + && !opencodeRes.opencodeInstructions.some((e) => e.config === own.config && e.entry === own.entry) + && (await readOpencodeInstructionList(own.config))?.includes(own.entry)) { + opencodeRes.opencodeInstructions.push(own); } } @@ -1399,6 +1413,18 @@ export async function uninstall(opts: UninstallOptions): Promise { await executeRemoval(plan); + // The OpenCode entries uninstall removed are no longer teamai's to track. + if (plan.opencodeInstructions.length > 0 && !plan.includeShared) { + const { loadStateForScope, saveStateForScope } = await import('./config.js'); + const state = await loadStateForScope(localConfig!); + if (state.opencodeContextEntries) { + state.opencodeContextEntries = state.opencodeContextEntries.filter( + (ref) => !plan.opencodeInstructions.some((e) => e.config === ref.config && e.entry === ref.entry), + ); + await saveStateForScope(state, localConfig!); + } + } + // Persist the exclusion so the next pull (or another tool's session-start // hook) does not resurrect this tool's resources. Only meaningful when the // shared ~/.teamai home survives (non-last-tool uninstall); on a last-tool From 005d2c931f9a5ed1c3a7aef77efa802223776c37 Mon Sep 17 00:00:00 2001 From: Saul Moro Date: Fri, 2 Oct 2026 11:58:58 +0200 Subject: [PATCH 32/41] fix(pull): address review round 12 of #945 - Pull restores the alwaysApply header of teamai's own rule file when it was lost or changed, so doctor no longer reports it current. - OpenCode's instructions entry is removed, by pull or uninstall, only when teamai recorded adding it. - A configured claudemd named teamai-context (no rules) is the member's file, in pull and uninstall. - Pull reports synced culture and instructions only when a target was reached. --- docs/usage-guide.md | 2 +- docs/usage-guide.zh-CN.md | 2 +- src/__tests__/instruction-targets.test.ts | 45 +++++++++++++++++++++++ src/__tests__/pull-tombstone.test.ts | 15 ++++++++ src/__tests__/uninstall.test.ts | 12 +++--- src/instruction-targets.ts | 31 ++++++++++++---- src/local-agent.ts | 2 +- src/pull.ts | 5 ++- src/uninstall.ts | 34 ++++++++--------- 9 files changed, 113 insertions(+), 35 deletions(-) diff --git a/docs/usage-guide.md b/docs/usage-guide.md index e0e27db64..446566150 100644 --- a/docs/usage-guide.md +++ b/docs/usage-guide.md @@ -2845,7 +2845,7 @@ teamai uninstall --agent claude What gets removed: - TeamAI-managed model settings are restored first when ownership is still intact - teamai hooks in AI tool settings -- The teamai blocks (culture, shared instructions, recall, and Codex's team rules) in each tool's instruction file, and the files an earlier release wrote them to (your own content is preserved; a `teamai-context` file teamai wrote is removed whole, and OpenCode's `instructions` entry for it goes too, even when your own text keeps the file or the file is gone; an entry teamai did not add, for a `teamai-context` file without teamai's blocks, is yours and stays) +- 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 custom agents and CLI built-in agents (your own agents are preserved) diff --git a/docs/usage-guide.zh-CN.md b/docs/usage-guide.zh-CN.md index 0a0bb8758..bbdd24f29 100644 --- a/docs/usage-guide.zh-CN.md +++ b/docs/usage-guide.zh-CN.md @@ -2656,7 +2656,7 @@ teamai uninstall --agent claude 移除内容: - 如果 ownership 仍有效,先恢复 TeamAI 管理的模型配置 - AI 工具 settings 中的 teamai hooks -- 各工具指令文件中的 teamai 块(文化、共享指令、recall 以及 Codex 的团队规则),以及早期版本写过这些块的文件(保留用户自写内容;teamai 写入的 `teamai-context` 文件整体删除,OpenCode 中对应的 `instructions` 条目一并移除,即使你自写的内容让该文件保留下来,或该文件已不存在;并非 teamai 添加、且对应文件不含 teamai 块的条目属于你,予以保留) +- 各工具指令文件中的 teamai 块(文化、共享指令、recall 以及 Codex 的团队规则),以及早期版本写过这些块的文件(保留用户自写内容;teamai 写入的 `teamai-context` 文件整体删除,若 OpenCode 中对应的 `instructions` 条目由 teamai 添加,则一并移除,即使你自写的内容让该文件保留下来,或该文件已不存在;你自己列入的条目予以保留) - 团队同步的 skills,包括 OpenClaw workspace skills(保留用户自建 skills) - 团队同步的 rules,包括旧版本留在 `.codex/rules/` 中的副本,团队此后已删除的 rule 的副本也包括在内。清理使用记录的 `toolRoots` 位置和发布者本地的文件名。其中你改过的副本会保留,并在警告中点名。已删除 rule 的副本只有与记录的投递哈希一致时才会删除;没有该记录时也会保留并点名。Codex 的 `*.rules` 文件保留 - 团队同步的自定义 agents 和 CLI 内置 agents(保留用户自建 agents) diff --git a/src/__tests__/instruction-targets.test.ts b/src/__tests__/instruction-targets.test.ts index dec27323a..c6d204089 100644 --- a/src/__tests__/instruction-targets.test.ts +++ b/src/__tests__/instruction-targets.test.ts @@ -57,6 +57,18 @@ describe('instruction file planning (#945)', () => { expect(plan.changes.map((c) => c.content)).toEqual([`${recall}\n`, `${direct}\n`]); }); + it('restores the header of teamai\'s own rule file when it was lost or changed', async () => { + const header = '---\nalwaysApply: true\n---\n'; + const own = target('teamai-context.mdc', { header, owned: true }); + const fresh = await planInstructionFiles([own], { culture: culture('c') }); + const expected = fresh.changes[0].content; + for (const lost of [`${culture('c')}\n`, `---\nalwaysApply: false\n---\n\n${culture('c')}\n`]) { + fs.writeFileSync(own.path, lost); + const plan = await planInstructionFiles([own], { culture: culture('c') }); + expect(plan.changes.map((c) => c.content)).toEqual([expected]); + } + }); + it('creates a missing target with the blocks only', async () => { const plan = await planInstructionFiles([target('CLAUDE.local.md')], { culture: culture('c'), claudemd: claudemd('s') }); await applyInstructionPlan(plan, { dryRun: false }); @@ -386,6 +398,34 @@ describe('Codex project instructions (#945)', () => { }); }); +describe('a configured claudemd named like teamai\'s file (#945)', () => { + it('is the member\'s file: an existing one gets the blocks beside its text', async () => { + const root = fs.realpathSync.native(fs.mkdtempSync(path.join(os.tmpdir(), 'teamai-945-fallback-'))); + try { + const projectRoot = path.join(root, 'project'); + const notes = path.join(projectRoot, '.claude', 'teamai-context.md'); + fs.mkdirSync(path.join(projectRoot, '.claude', 'skills'), { recursive: true }); + fs.writeFileSync(notes, '# My notes\n'); + const teamConfig = TeamaiConfigSchema.parse({ + team: 't', repo: 'https://example.invalid/t.git', + toolPaths: { claude: { skills: '.claude/skills', claudemd: '.claude/teamai-context.md' } }, + }); + const localConfig = { + repo: { localPath: path.join(root, 'repo'), remote: 'https://example.invalid/t.git' }, + username: 'u', additionalRoles: [], scope: 'project', projectRoot, enabledAgents: ['claude'], + } as unknown as LocalConfig; + + const { targets } = await resolveInstructionTargets(teamConfig, localConfig); + const plan = await planInstructionFiles(targets.filter((t) => t.path === notes), { culture: culture('c') }); + + expect(plan.warnings).toEqual([]); + expect(plan.changes.map((c) => c.content)).toEqual([`# My notes\n\n${culture('c')}\n`]); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }); +}); + describe('OpenCode instructions ownership (#945)', () => { it('records the entry teamai adds, for uninstall, and forgets it once removed', async () => { const root = fs.realpathSync.native(fs.mkdtempSync(path.join(os.tmpdir(), 'teamai-945-ocown-'))); @@ -411,6 +451,11 @@ describe('OpenCode instructions ownership (#945)', () => { const resolved = await resolveInstructionTargets(teamConfig, localConfig); await registerOpencodeContext(teamConfig, localConfig, { targets: [], stale: resolved.targets }, false); expect((await loadStateForScope(localConfig)).opencodeContextEntries).toEqual([]); + + // An entry the member listed, which teamai did not record, stays. + fs.writeFileSync(config, JSON.stringify({ instructions: ['.opencode/teamai-context.md'] })); + await registerOpencodeContext(teamConfig, localConfig, { targets: [], stale: resolved.targets }, false); + expect(JSON.parse(fs.readFileSync(config, 'utf8')).instructions).toEqual(['.opencode/teamai-context.md']); } finally { process.env.HOME = prevHome; fs.rmSync(root, { recursive: true, force: true }); diff --git a/src/__tests__/pull-tombstone.test.ts b/src/__tests__/pull-tombstone.test.ts index 3faaaed8a..9c8da00c8 100644 --- a/src/__tests__/pull-tombstone.test.ts +++ b/src/__tests__/pull-tombstone.test.ts @@ -316,6 +316,21 @@ describe('pull role-aware sync and cleanup', () => { expect(await fse.pathExists(context)).toBe(false); }); + it('reports no synced culture when every instruction target was left alone (#945)', async () => { + const { log } = await import('../utils/logger.js'); + const base = (await loadTeamConfig(repoPath))!; + vi.mocked(loadTeamConfig).mockResolvedValue({ ...base, toolPaths: { cursor: { skills: '.cursor/skills', rules: '.cursor/rules' } } }); + await fse.ensureDir(path.join(homeDir, '.cursor', 'skills')); + await fse.writeFile(path.join(repoPath, 'culture.md'), 'Be kind.\n'); + // A member's own file holds teamai's target path. + await fse.outputFile(path.join(homeDir, '.cursor', 'rules', 'teamai-context.mdc'), '# Mine\n'); + vi.mocked(log.success).mockClear(); + + await pull({}); + + expect(vi.mocked(log.success).mock.calls.flat()).not.toContain('Synced team culture'); + }); + it('should not delete files that are NOT tombstoned', async () => { // No tombstone files await fse.writeFile(path.join(homeDir, '.claude/rules', 'keep-rule.md'), '# Keep'); diff --git a/src/__tests__/uninstall.test.ts b/src/__tests__/uninstall.test.ts index 1acb1b8e4..21c7a0bec 100644 --- a/src/__tests__/uninstall.test.ts +++ b/src/__tests__/uninstall.test.ts @@ -1624,11 +1624,13 @@ describe('uninstall', () => { }); it.each([ - ['deleted', null, ['docs/style.md']], - ['teamai\'s', '\nx\n\n', ['docs/style.md']], - ['the member\'s own', '# My own notes\n', ['docs/style.md', '.opencode/teamai-context.md']], - ['teamai\'s with its markers stripped', 'Generated text.\n', ['docs/style.md'], true], - ])('handles OpenCode\'s instructions entry when its context file is %s (#945)', async (_state, content, expected, recorded = false) => { + ['deleted, entry recorded', null, ['docs/style.md'], true], + ['teamai\'s, entry recorded', '\nx\n\n', ['docs/style.md'], true], + ['teamai\'s with its markers stripped, entry recorded', 'Generated text.\n', ['docs/style.md'], true], + // The member listed the entry before pull filled the file: theirs. + ['teamai\'s, entry not recorded', '\nx\n\n', ['docs/style.md', '.opencode/teamai-context.md'], false], + ['the member\'s own', '# My own notes\n', ['docs/style.md', '.opencode/teamai-context.md'], false], + ])('handles OpenCode\'s instructions entry when its context file is %s (#945)', async (_state, content, expected, recorded) => { const projectRoot = path.join(tmpDir, 'oc-entry-project'); const homeDir = path.join(tmpDir, 'home'); const repoPath = path.join(projectRoot, '.teamai', 'team-repo'); diff --git a/src/instruction-targets.ts b/src/instruction-targets.ts index 6513c9358..c5d455e68 100644 --- a/src/instruction-targets.ts +++ b/src/instruction-targets.ts @@ -346,9 +346,12 @@ export function instructionTargetPath( * ownership its entry declares. Only teamai's `teamai-context` file takes * them; a configured file is the member's. */ -export function instructionTargetAt(tool: string, file: string, scope: Scope): InstructionTarget { +export function instructionTargetAt(tool: string, file: string, scope: Scope, paths: ToolPaths): InstructionTarget { const entry = entryFor(tool, scope); - const own = path.basename(file).startsWith(`${TEAMAI_CONTEXT_RULE_NAME}.`); + // teamai's file is the one its entry generates; the team's configured + // `claudemd` (the fallback without `rules`) is the member's, whatever its name. + const own = path.basename(file).startsWith(`${TEAMAI_CONTEXT_RULE_NAME}.`) + && instructionTargetFile(tool, paths, scope) !== paths.claudemd; return { path: file, tools: [], recall: false, header: own ? entry?.header : undefined, owned: own ? entry?.owned : undefined }; } @@ -429,7 +432,7 @@ export async function resolveInstructionTargets( if (!file || !await isInstructionToolInstalled(tool, paths, localConfig)) continue; inUse.add(file); if (isAgentExcluded(localConfig, tool)) continue; - const target = targets.get(file) ?? instructionTargetAt(tool, file, localConfig.scope); + const target = targets.get(file) ?? instructionTargetAt(tool, file, localConfig.scope, paths); // The subagent block only where every tool reading the file has the subagent. target.recall = Boolean(paths.agents) && (target.tools.length === 0 || target.recall); target.tools.push(tool); @@ -455,9 +458,9 @@ export async function resolveInstructionTargets( /** * List teamai's OpenCode instruction file in OpenCode's `instructions` while - * it holds teamai's blocks, and drop the entry once the file is gone: OpenCode - * reads no file it is not told about. A file without teamai's blocks is the - * member's, so its entry is left as it is. Returns what it did or, with + * it holds teamai's blocks, and drop the entry teamai recorded adding once the + * file is gone: OpenCode reads no file it is not told about. A file without + * teamai's blocks is the member's, and an entry teamai did not add stays. Returns what it did or, with * `dryRun`, would do. */ export async function registerOpencodeContext( @@ -477,6 +480,13 @@ export async function registerOpencodeContext( if (!delivered && await pathExists(contextFile)) return null; const present = wanted && delivered; const { config, entry } = opencodeContextReference(contextFile, localConfig.scope, resolveToolBaseDir('opencode', localConfig)); + // An entry goes only if teamai recorded adding it: one the member listed + // before teamai wrote the file is theirs. + if (!present) { + const { loadStateForScope } = await import('./config.js'); + const recorded = (await loadStateForScope(localConfig)).opencodeContextEntries ?? []; + if (!recorded.some((ref) => ref.config === config && ref.entry === entry)) return null; + } if (dryRun) { const listed = (await readOpencodeInstructionList(config))?.includes(entry) ?? false; if (listed === present) return null; @@ -594,6 +604,11 @@ async function planFile( } content = edited.content; } + // teamai's own rule file needs its header to be loaded at all: put it back + // if it was lost or edited, replacing any other frontmatter. + if (target.owned && target.header && hasTeamaiBlock(content) && !content.startsWith(target.header)) { + content = target.header + content.replace(/^---\n[\s\S]*?\n---\n/, '').replace(/^\n*/, '\n'); + } if (content === (existing ?? target.header ?? '')) return null; const remainder = withoutHeader(content, target.header).trim(); @@ -618,6 +633,8 @@ const KNOWN_HEADERS = [ALWAYS_APPLY]; export async function clearInstructionFile( file: string, starts?: readonly string[], + /** Whether the file is teamai's generated one; by default, judged by its name. */ + owned = path.basename(file).startsWith(`${TEAMAI_CONTEXT_RULE_NAME}.`), ): Promise<{ changed: boolean; warnings: string[] }> { const existing = await readFileSafe(file); if (existing === null) return { changed: false, warnings: [] }; @@ -626,7 +643,7 @@ export async function clearInstructionFile( tools: [], recall: false, header: KNOWN_HEADERS.find((header) => existing.startsWith(header)), - owned: path.basename(file).startsWith(`${TEAMAI_CONTEXT_RULE_NAME}.`), + owned, }; const blocks = starts === undefined ? STALE_BLOCKS : STALE_BLOCKS.filter(([start]) => starts.includes(start)); const warnings: string[] = []; diff --git a/src/local-agent.ts b/src/local-agent.ts index db4b2949b..4ed7ed13f 100644 --- a/src/local-agent.ts +++ b/src/local-agent.ts @@ -2165,7 +2165,7 @@ async function syncClaudemd( log.debug(`local-agent: OpenCode reads the team instructions from ${claudeUserFile}; skipped`); continue; } - const target = instructionTargetAt(tool, claudeMdPath, localConfig.scope); + const target = instructionTargetAt(tool, claudeMdPath, localConfig.scope, toolPath); const plan = await planInstructionFiles([target], { claudemd: block }); // A warning means the file was left as it was: nothing reached the tool. if (plan.warnings.length > 0) { diff --git a/src/pull.ts b/src/pull.ts index f2147f64b..d02b034c3 100644 --- a/src/pull.ts +++ b/src/pull.ts @@ -1932,7 +1932,10 @@ async function syncManagedInstructions( } // Hook targets (Pi, OMP, Hermes, Codex) are reported after the hooks are // reconciled, since that is what installs their extensions and plugins. - if (dryRun || targets.length === 0) return; + // A target named in a warning or failure was left as it was. + const problems = [...plan.warnings, ...failures]; + const reached = targets.filter((target) => !problems.some((problem) => problem.includes(target.path))); + if (dryRun || reached.length === 0) return; if (blocks.culture) log.success('Synced team culture'); if (blocks.claudemd) log.success(`[${scopeLabel}] Synced shared instructions (${claudemdFiles} file(s))`); } diff --git a/src/uninstall.ts b/src/uninstall.ts index 8c521b8ef..30629d00b 100644 --- a/src/uninstall.ts +++ b/src/uninstall.ts @@ -99,7 +99,8 @@ interface RemovalPlan { /** Manifest used by the primary hook injection scope. */ hookManifestPath: string; /** Instruction files (CLAUDE.md, AGENTS.md, …), each with the teamai blocks to strip from it. */ - claudeMdFiles: Array<{ path: string; blocks: Array<[string, string]> }>; + /** `owned`: teamai's generated file, which goes with its last block; else a member's file. */ + claudeMdFiles: Array<{ path: string; blocks: Array<[string, string]>; owned: boolean }>; opencodeInstructions: OpencodeInstruction[]; /** * Skill directories synced from team repo, each with the base directory its @@ -456,15 +457,8 @@ async function discoverToolResources( res.claudeMdFiles.push(claudeMdPath); } } - // A teamai-context.md without teamai's blocks is the member's and keeps its - // entry, as on pull; an entry to a file that is gone is dropped. - const contextFile = instructionFile ? path.resolve(baseDir, instructionFile) : undefined; - if (tool === 'opencode' && contextFile - && (res.claudeMdFiles.includes(contextFile) || !await pathExists(contextFile))) { - const { opencodeContextReference, readOpencodeInstructionList } = await import('./resources/opencode-config.js'); - const reference = opencodeContextReference(contextFile, scope, baseDir); - if ((await readOpencodeInstructionList(reference.config))?.includes(reference.entry)) res.opencodeInstructions.push(reference); - } + // OpenCode's instructions entry goes only when teamai recorded adding it + // (buildRemovalPlan): an entry the member listed is theirs, whatever the file holds. for (const retired of retiredInstructionFiles(tool, toolPath, scope)) { const file = path.resolve(baseDir, retired); const content = await readFileSafe(file); @@ -635,13 +629,13 @@ async function buildRemovalPlan( ); } - // The OpenCode entries teamai added stay teamai's even after the member - // stripped the markers from the context file. + // OpenCode's instructions entry is teamai's only when pull recorded adding + // it, whatever the context file holds now. No release before #945 added + // this entry, so there are no unrecorded teamai entries to migrate. const opencodeRes = perTool.get('opencode'); if (opencodeRes) { const { loadStateForScope } = await import('./config.js'); - const { readOpencodeInstructionList } = await import('./resources/opencode-config.js'); - const { opencodeContextReference } = await import('./resources/opencode-config.js'); + const { opencodeContextReference, readOpencodeInstructionList } = await import('./resources/opencode-config.js'); const contextFile = toolPaths.opencode && instructionTargetFile('opencode', toolPaths.opencode, localConfig.scope); // Worktrees share state.json: only this checkout's own record counts. const own = contextFile @@ -649,7 +643,6 @@ async function buildRemovalPlan( : undefined; const recorded = (await loadStateForScope(localConfig)).opencodeContextEntries ?? []; if (own && recorded.some((ref) => ref.config === own.config && ref.entry === own.entry) - && !opencodeRes.opencodeInstructions.some((e) => e.config === own.config && e.entry === own.entry) && (await readOpencodeInstructionList(own.config))?.includes(own.entry)) { opencodeRes.opencodeInstructions.push(own); } @@ -762,7 +755,9 @@ async function buildRemovalPlan( const kept = retainedBlocks.get(file); const blocks = CLAUDEMD_MARKER_PAIRS .filter(([start]) => content.includes(start) && !kept?.has(start)); - if (blocks.length > 0) plan.claudeMdFiles.push({ path: file, blocks }); + // The configured `claudemd` (no `rules`) is the member's, whatever its name. + const owned = instructionTargetFile(tool, toolPaths[tool], localConfig.scope) !== toolPaths[tool].claudemd; + if (blocks.length > 0) plan.claudeMdFiles.push({ path: file, blocks, owned }); } // A retired file keeps only the blocks a remaining tool still writes // there, which a team's toolPaths can make it. @@ -771,7 +766,8 @@ async function buildRemovalPlan( const content = await readFileSafe(file) ?? ''; const kept = retainedBlocks.get(file); const blocks = CLAUDEMD_MARKER_PAIRS.filter(([start]) => content.includes(start) && !kept?.has(start)); - if (blocks.length > 0) plan.claudeMdFiles.push({ path: file, blocks }); + const owned = path.basename(file).startsWith(`${TEAMAI_CONTEXT_RULE_NAME}.`); + if (blocks.length > 0) plan.claudeMdFiles.push({ path: file, blocks, owned }); } plan.skillDirs.push(...res.skillDirs); plan.ruleFiles.push(...res.ruleFiles); @@ -1115,11 +1111,11 @@ async function executeRemoval(plan: RemovalPlan): Promise { } // (b) Clean CLAUDE.md teamai section blocks - for (const { path: claudeMdPath, blocks } of plan.claudeMdFiles) { + for (const { path: claudeMdPath, blocks, owned } of plan.claudeMdFiles) { try { // A file teamai created goes with its last block; a member's file, // even an empty one, stays. - const { changed, warnings } = await clearInstructionFile(claudeMdPath, blocks.map(([start]) => start)); + const { changed, warnings } = await clearInstructionFile(claudeMdPath, blocks.map(([start]) => start), owned); for (const warning of warnings) log.warn(warning); if (changed) log.success(`Cleaned ${claudeMdPath}`); } catch (e) { From c590d14e237771f5eb2eff1376023cfba5b1b9ff Mon Sep 17 00:00:00 2001 From: Saul Moro Date: Fri, 2 Oct 2026 12:26:55 +0200 Subject: [PATCH 33/41] fix(instructions): keep failed deliveries and removals retryable (#945) --- docs/usage-guide.md | 6 ++- docs/usage-guide.zh-CN.md | 6 ++- skill-data/setup/references/uninstall.md | 3 ++ src/__tests__/e2e/instruction-targets.test.ts | 26 +++++++++++- src/__tests__/instruction-targets.test.ts | 36 +++++++++++++++- src/__tests__/local-agent.test.ts | 32 ++++++++++++++ src/__tests__/uninstall.test.ts | 42 +++++++++++++++++++ src/instruction-targets.ts | 5 ++- src/local-agent.ts | 14 +++++-- src/pull.ts | 2 +- src/recall-toggle.ts | 2 +- src/uninstall.ts | 11 ++++- 12 files changed, 172 insertions(+), 13 deletions(-) diff --git a/docs/usage-guide.md b/docs/usage-guide.md index 446566150..4bd80a2b1 100644 --- a/docs/usage-guide.md +++ b/docs/usage-guide.md @@ -1674,7 +1674,7 @@ The CodeBuddy and WorkBuddy rule files carry `alwaysApply: true`, which CodeBudd In a project, teamai installs the Hermes plugin `$HERMES_HOME/plugins/teamai-instructions/` and adds it to `plugins.enabled` in `$HERMES_HOME/config.yaml` (a name you list under `plugins.disabled` stays off). A plugin of that name teamai did not write is left alone, also on uninstall, and `teamai pull` and `teamai doctor` say so. According to Hermes' documentation it builds the section once for each new session from the session's directory and keeps it through compression and resume. A section holds at most 4,000 characters, and all plugin sections together at most 8,000. When this member's instructions for the project are longer, Hermes skips them and `teamai pull` says so: teamai does not cut them or write them to `AGENTS.md`. Outside a project the section is empty, and Hermes may log that it skipped an empty section. -OpenCode loads a file only when its config lists it in `instructions`, so teamai adds that one entry and keeps your other entries and keys; the root `opencode.json` and OpenCode's own `AGENTS.md` files are left alone. While `~/.config/opencode/AGENTS.md` does not exist, OpenCode reads `~/.claude/CLAUDE.md` instead; when Claude Code gets the user blocks there, OpenCode already has them, so teamai writes no second user copy for OpenCode and says so in the pull output. Blocks left there by a Claude Code you excluded count too, since OpenCode reads them all the same; the pull then warns that nothing keeps them current. A config file teamai cannot parse as JSON (for example one with comments) is left unchanged with a warning; add the entry by hand. +OpenCode loads a file only when its config lists it in `instructions`. teamai adds that entry only when the target already matches the desired blocks or its update succeeds. A malformed target or a failed write does not activate stale blocks. It keeps your other entries and keys; the root `opencode.json` and OpenCode's own `AGENTS.md` files are left alone. While `~/.config/opencode/AGENTS.md` does not exist, OpenCode reads `~/.claude/CLAUDE.md` instead; when Claude Code gets the user blocks there, OpenCode already has them, so teamai writes no second user copy for OpenCode and says so in the pull output. Blocks left there by a Claude Code you excluded count too, since OpenCode reads them all the same; the pull then warns that nothing keeps them current. A config file teamai cannot parse as JSON (for example one with comments) is left unchanged with a warning; add the entry by hand. Oh My Pi reads `RULES.md` as an always-applied rule beside its single user context file. In project scope teamai's OMP extension asks `teamai` for the blocks when the session starts and adds them to each turn's system prompt, from the project root and any subdirectory. Without that extension (for example with hooks removed), an Oh My Pi project session gets no team blocks. Prompts the HTTP local agent delivers for a project reach Pi, Oh My Pi and Hermes the same way, through their extension or plugin, and the Codex family through its session-start and subagent-start hooks. @@ -1750,6 +1750,8 @@ When using `teamai init --http `, the endpoint must implement the follo } ``` +Removing the final HTTP prompt is acknowledged as `failed` when its target cannot be updated. The cached prompt and manifest record remain available for a retry after repairing the markers or file permissions. + The backend may push an **`apply_model_config`** task whose `cmd` is JSON. Both the documented candidate-set shape and the legacy single-model shape are accepted. `{"models":[...]}` is a full snapshot; a direct model object is an incremental upsert. @@ -2860,6 +2862,8 @@ An instructions file several tools map is cleaned per block: a teamai block stay Shared resources (the env block, docs directory, and `~/.teamai/`) are removed **only when the target itself has teamai resources AND is the last tool still using teamai** — otherwise they are kept for the remaining tools. (So targeting a tool that has no teamai resources of its own is a no-op and leaves shared resources in place, even if it happens to be the only tool.) +If removing an OpenCode entry added by teamai fails, its ownership record stays while the shared data directory survives. Repair the config or its permissions, then retry `teamai uninstall --agent opencode`. + The exclusion is durable: `uninstall --agent ` drops the tool from `enabledAgents` and records it in `disabledAgents`, so a later `pull` (or another tool's session-start hook) will not resurrect its skills, rules, agents, team instruction blocks, or hooks. Running `init --agent ` again clears the exclusion and re-enables sync for that tool. The same `enabledAgents` whitelist (from `init --agent`) also gates CLI built-in skills/rules/agents and team instruction blocks: an already-installed tool outside the list is neither written to nor deleted from, even if its root directory already exists. `teamai remove` respects the same whitelist for agents, rules, and skills, `teamai push` reads no rules or agents from a tool outside it, and `teamai pull` / `teamai mcp inject` respect it for MCP servers. Editing `enabledAgents` without `init` still invalidates the last-pull skip cache for newly added tools. diff --git a/docs/usage-guide.zh-CN.md b/docs/usage-guide.zh-CN.md index bbdd24f29..eda37bc2a 100644 --- a/docs/usage-guide.zh-CN.md +++ b/docs/usage-guide.zh-CN.md @@ -1552,7 +1552,7 @@ CodeBuddy 和 WorkBuddy 的规则文件带有 `alwaysApply: true`,CodeBuddy 在项目中,teamai 安装 Hermes 插件 `$HERMES_HOME/plugins/teamai-instructions/`,并把它加入 `$HERMES_HOME/config.yaml` 的 `plugins.enabled`(列在 `plugins.disabled` 中的名称保持关闭)。同名但并非 teamai 写入的插件保持不变,卸载时也一样,`teamai pull` 和 `teamai doctor` 会指出这一点。根据 Hermes 的文档,它在每个新会话开始时根据会话目录生成该段落,并在压缩和恢复后保留。一个段落最多 4,000 个字符,所有插件段落合计最多 8,000 个字符。当该成员在此项目的指令更长时,Hermes 会跳过它们,`teamai pull` 会给出提示:teamai 不会截断它们,也不会写入 `AGENTS.md`。在项目之外段落为空,Hermes 可能记录它跳过了一个空段落。 -OpenCode 只加载配置中 `instructions` 列出的文件,因此 teamai 只添加这一项,并保留你的其他条目和键;根目录的 `opencode.json` 以及 OpenCode 自己的 `AGENTS.md` 文件保持不变。当 `~/.config/opencode/AGENTS.md` 不存在时,OpenCode 会改为读取 `~/.claude/CLAUDE.md`;若 Claude Code 的用户块已在那里,OpenCode 已经获得它们,因此 teamai 不会为 OpenCode 再写一份用户副本,并在 pull 输出中说明。被排除的 Claude Code 留在那里的块同样算数,因为 OpenCode 照样读取它们;此时 pull 会警告没有任何工具再更新它们。teamai 无法按 JSON 解析的配置文件(例如带注释的文件)保持不变并给出警告,请手动添加该条目。 +OpenCode 只加载配置中 `instructions` 列出的文件。仅当目标已包含所需的块或更新成功时,teamai 才添加该条目;标记不完整或写入失败时,不会激活旧块。teamai 只添加这一项,并保留你的其他条目和键;根目录的 `opencode.json` 以及 OpenCode 自己的 `AGENTS.md` 文件保持不变。当 `~/.config/opencode/AGENTS.md` 不存在时,OpenCode 会改为读取 `~/.claude/CLAUDE.md`;若 Claude Code 的用户块已在那里,OpenCode 已经获得它们,因此 teamai 不会为 OpenCode 再写一份用户副本,并在 pull 输出中说明。被排除的 Claude Code 留在那里的块同样算数,因为 OpenCode 照样读取它们;此时 pull 会警告没有任何工具再更新它们。teamai 无法按 JSON 解析的配置文件(例如带注释的文件)保持不变并给出警告,请手动添加该条目。 Oh My Pi 把 `RULES.md` 作为始终应用的规则读取,与其唯一的用户上下文文件并存。在项目范围内,teamai 的 OMP 扩展在会话开始时向 `teamai` 获取这些块,并加入每轮的系统提示,无论会话从项目根目录还是子目录启动。没有该扩展时(例如移除了 hooks),Oh My Pi 的项目会话不会获得团队块。HTTP local agent 为项目下发的 prompt 也以同样方式,通过扩展或插件到达 Pi、Oh My Pi 和 Hermes,并通过 session-start 和 subagent-start hook 到达 Codex 系列。 @@ -1628,6 +1628,8 @@ cat ~/.claude/CLAUDE.md } ``` +删除最后一个 HTTP prompt 时,若目标无法更新,回执为 `failed`。缓存中的 prompt 和 manifest 记录均予以保留,修复标记或文件权限后可重试。 + 后端可下发 **`apply_model_config`** 任务,其 `cmd` 为 JSON。客户端同时兼容设计文档中的候选集结构和 旧版单模型结构:`{"models":[...]}` 按完整快照处理,直接模型对象按增量 upsert 处理。 `max_tokens` 可选(对应 CodeBuddy / WorkBuddy 的 `maxOutputTokens`);缺省或 `0` 时默认 `4096`。Claude 不使用该字段。 @@ -2671,6 +2673,8 @@ teamai uninstall --agent claude 跨工具共享资源(shell profile env 块、docs 目录、`~/.teamai/`)**仅当该工具自身存在 teamai 资源、且它是最后一个仍在使用 teamai 的工具时**才一并移除,否则会为其余工具保留。(因此,定向卸载一个自身没有任何 teamai 资源的工具是 no-op,即便它恰好是唯一的工具,也不会删除共享资源。) +若删除 teamai 添加的 OpenCode 条目失败,只要共享数据目录仍在,其所有权记录就会保留。修复配置或权限后,重试 `teamai uninstall --agent opencode`。 + 该排除是持久的:`uninstall --agent ` 会把该工具从 `enabledAgents` 移除并记入 `disabledAgents`,因此之后的 `pull`(或其他工具的 session-start hook)不会再把它的 skills、rules、agents、团队指令块或 hooks 重新装回。重新执行 `init --agent ` 会清除该排除、恢复对该工具的同步。 同一套 `enabledAgents` 白名单(来自 `init --agent`)也约束 CLI 内置 skills/rules/agents 以及团队指令块:即使工具根目录已经存在,白名单外的已安装工具也不会被写入或删除。`teamai remove` 对 agents、rules 和 skills 同样遵守该白名单,`teamai push` 也不会从白名单外的工具读取 rules 和 agents,`teamai pull` / `teamai mcp inject` 对 MCP servers 也遵守该白名单。不经过 `init` 直接把工具加进 `enabledAgents` 时,last-pull 跳过缓存会对新加入的工具失效。 diff --git a/skill-data/setup/references/uninstall.md b/skill-data/setup/references/uninstall.md index aae4600d1..ad77a0a85 100644 --- a/skill-data/setup/references/uninstall.md +++ b/skill-data/setup/references/uninstall.md @@ -63,6 +63,9 @@ and give it your team repo URL."* 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 changes you need, then delete the copy manually. +- If an OpenCode config entry cannot be removed, repair its config or permissions + and retry `teamai uninstall --agent opencode`. Its ownership record stays + while the shared data directory survives. - Do **not** delete the team repo on the Git platform — uninstall never touches it, and neither should you. - If the user only wants to stop auto-sync for one tool but keep TeamAI otherwise, diff --git a/src/__tests__/e2e/instruction-targets.test.ts b/src/__tests__/e2e/instruction-targets.test.ts index daed61c98..8a155dd80 100644 --- a/src/__tests__/e2e/instruction-targets.test.ts +++ b/src/__tests__/e2e/instruction-targets.test.ts @@ -803,6 +803,31 @@ describe('instruction block targets on real CLI pull (#945)', () => { expect(JSON.parse(fs.readFileSync(config, 'utf8'))).toEqual({ instructions: ['docs/style.md'] }); }); + it('activates OpenCode instructions only after repairing a malformed target', async () => { + const sandbox = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'teamai-945-e2e-'))); + sandboxes.push(sandbox); + const member = makeProjectMember(sandbox, makeTeamAndProject(sandbox), 'dev', 'developer', ['.opencode/skills']); + const contextFile = path.join(member.projectRoot, '.opencode', 'teamai-context.md'); + const config = path.join(member.projectRoot, '.opencode', 'opencode.json'); + fs.writeFileSync(contextFile, `${CLAUDEMD_START}\nPRODUCT-STALE\n`); + fs.writeFileSync(config, JSON.stringify({ instructions: ['docs/style.md'] })); + for (const args of [['--dry-run'], []]) { + const pull = await pullAs(member, args); + expect(pull.code, pull.output).toBe(0); + expect(pull.output).toContain('incomplete teamai claudemd block'); + expect(JSON.parse(fs.readFileSync(config, 'utf8')).instructions).toEqual(['docs/style.md']); + console.log(`pull ${args.join(' ')}: malformed target warned; OpenCode instructions=${JSON.stringify(JSON.parse(fs.readFileSync(config, 'utf8')).instructions)}`); + } + fs.writeFileSync(contextFile, `${CLAUDEMD_START}\nPRODUCT-STALE\n${CLAUDEMD_END}\n`); + const retry = await pullAs(member); + expect(retry.code, retry.output).toBe(0); + const content = fs.readFileSync(contextFile, 'utf8'); + expect(content).toContain('DEVELOPMENT-SENTINEL'); + expect(content).not.toContain('PRODUCT-STALE'); + expect(JSON.parse(fs.readFileSync(config, 'utf8')).instructions).toEqual(['docs/style.md', '.opencode/teamai-context.md']); + console.log('pull after repair: DEVELOPMENT=1 PRODUCT-STALE=0; OpenCode instructions=["docs/style.md",".opencode/teamai-context.md"]'); + }); + it('drops OpenCode\'s instructions entry on uninstall even when the member\'s text keeps the file', async () => { const sandbox = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'teamai-945-e2e-'))); sandboxes.push(sandbox); @@ -821,4 +846,3 @@ describe('instruction block targets on real CLI pull (#945)', () => { expect(JSON.parse(fs.readFileSync(config, 'utf8')).instructions ?? []).toEqual([]); }); }); - diff --git a/src/__tests__/instruction-targets.test.ts b/src/__tests__/instruction-targets.test.ts index c6d204089..b33bc12f9 100644 --- a/src/__tests__/instruction-targets.test.ts +++ b/src/__tests__/instruction-targets.test.ts @@ -1,6 +1,7 @@ -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { execFileSync } from 'node:child_process'; import fs from 'node:fs'; +import fse from 'fs-extra'; import os from 'node:os'; import path from 'node:path'; import { @@ -349,6 +350,38 @@ describe('OpenCode\'s Claude fallback (#945)', () => { }); describe('OpenCode instructions registration (#945)', () => { + it.each(['malformed', 'write failure', 'current', 'written'])('registers only delivered instructions when the target is %s', async (state) => { + const root = fs.realpathSync.native(fs.mkdtempSync(path.join(os.tmpdir(), 'teamai-945-ocreg-'))); + try { + const projectRoot = path.join(root, 'project'); + const file = path.join(projectRoot, '.opencode', 'teamai-context.md'); + fs.mkdirSync(path.join(projectRoot, '.opencode', 'skills'), { recursive: true }); + fs.writeFileSync(file, state === 'malformed' ? `${TEAMAI_CLAUDEMD_START}\nold selection\n` : `${claudemd(state === 'current' ? 'desired' : 'old selection')}\n`); + const localConfig = { + repo: { localPath: path.join(root, 'repo'), remote: 'https://example.invalid/t.git' }, + username: 'u', additionalRoles: [], scope: 'project', projectRoot, + } as unknown as LocalConfig; + const teamConfig = TeamaiConfigSchema.parse({ team: 't', repo: 'https://example.invalid/t.git' }); + const resolved = await resolveInstructionTargets(teamConfig, localConfig); + const plan = await planInstructionFiles(resolved.targets, { claudemd: claudemd('desired') }); + if (state === 'write failure') vi.spyOn(fse, 'writeFile').mockRejectedValueOnce(new Error('EACCES')); + const { failures } = await applyInstructionPlan(plan, { dryRun: false }); + await registerOpencodeContext(teamConfig, localConfig, resolved, false, [], [...plan.warnings, ...failures]); + + const config = path.join(projectRoot, '.opencode', 'opencode.json'); + if (state === 'current' || state === 'written') { + expect(JSON.parse(fs.readFileSync(config, 'utf8')).instructions).toEqual(['.opencode/teamai-context.md']); + expect(fs.readFileSync(file, 'utf8')).toContain('desired'); + } else { + expect(fs.existsSync(config)).toBe(false); + expect(fs.readFileSync(file, 'utf8')).toContain('old selection'); + } + } finally { + vi.restoreAllMocks(); + fs.rmSync(root, { recursive: true, force: true }); + } + }); + it('leaves the member\'s own listed teamai-context.md entry alone, in a dry run and a real pull', async () => { const root = fs.realpathSync.native(fs.mkdtempSync(path.join(os.tmpdir(), 'teamai-945-ocreg-'))); try { @@ -511,4 +544,3 @@ describe('every tool and toolPaths shape keeps its instructions (#945)', () => { } }); }); - diff --git a/src/__tests__/local-agent.test.ts b/src/__tests__/local-agent.test.ts index 33bc35f95..876c3ecb8 100644 --- a/src/__tests__/local-agent.test.ts +++ b/src/__tests__/local-agent.test.ts @@ -1700,6 +1700,38 @@ describe('local-agent: cmds[] migration', () => { expect(await fse.readFile(legacy, 'utf8')).toBe('# mine\n'); }); + it.each(['malformed', 'write failure'])('keeps the final HTTP prompt retryable when removal encounters %s (#945)', async (failure) => { + await runResponse({ cmds: [{ + id: 61, type: 'install_prompt_rule', handle_type: 'prompt', slug: 'doc-a', + version: '1.0.0', download_url: 'http://127.0.0.1:42100/doc-a.md', scope: 'user', + }] }); + const target = path.join(tmpDir, '.codebuddy', 'CODEBUDDY.md'); + // Keep member text so removal writes a file rather than deleting it. + const installed = '# My notes\n' + await fse.readFile(target, 'utf8'); + const retained = failure === 'malformed' ? installed.replace('', '') : installed; + await fse.writeFile(target, retained); + const originalWrite = fse.writeFile.bind(fse); + if (failure === 'write failure') { + vi.spyOn(fse, 'writeFile').mockImplementation((...args: Parameters) => { + if (args[0] === target) return Promise.reject(new Error('EACCES')); + return originalWrite(...args); + }); + } + const command = { id: 62, type: 'uninstall_prompt_rule', handle_type: 'prompt', slug: 'doc-a', scope: 'user' }; + const acks = await runResponse({ cmds: [command] }); + expect(acks.find((ack) => ack.id === 62)?.status).toBe('failed'); + expect(await fse.readFile(target, 'utf8')).toBe(retained); + const manifestFile = path.join(tmpDir, '.teamai', 'local-agent', 'manifest.json'); + expect((await fse.readJson(manifestFile)).scopes.user.claudemd['doc-a']).toBeDefined(); + expect(await fse.readFile(path.join(tmpDir, '.teamai', 'local-agent', 'resources', 'user', 'claudemd', 'doc-a.md'), 'utf8')).toContain('# content'); + vi.restoreAllMocks(); + await fse.writeFile(target, installed); + const retry = await runResponse({ cmds: [{ ...command, id: 63 }] }); + expect(retry.find((ack) => ack.id === 63)?.status).toBe('success'); + expect(await fse.readFile(target, 'utf8')).not.toContain(TEAMAI_CLAUDEMD_START); + expect((await fse.readJson(manifestFile)).scopes.user.claudemd['doc-a']).toBeUndefined(); + }); + // Codex's default `claudemd` (#938) makes a Codex report a target of this sync. it('handle_type=prompt from Codex writes the prompt into ~/.codex/AGENTS.md when ~/.codex exists', async () => { await fse.ensureDir(path.join(tmpDir, '.codex')); diff --git a/src/__tests__/uninstall.test.ts b/src/__tests__/uninstall.test.ts index 21c7a0bec..d849c3bca 100644 --- a/src/__tests__/uninstall.test.ts +++ b/src/__tests__/uninstall.test.ts @@ -1694,6 +1694,48 @@ describe('uninstall', () => { expect((await loadStateForScope(localConfig)).opencodeContextEntries).toEqual([{ config: siblingConfig, entry }]); }); + it.each(['write failure', 'unreadable'])('keeps the OpenCode ownership record for a retry after %s (#945)', async (failure) => { + const projectRoot = path.join(tmpDir, 'oc-retry-project'); + const repoPath = path.join(projectRoot, '.teamai', 'team-repo'); + await fse.ensureDir(path.join(projectRoot, '.opencode', 'skills')); + await fse.outputFile(path.join(repoPath, 'skills', 'team-skill', 'SKILL.md'), '# Team Skill'); + await fse.outputFile(path.join(projectRoot, '.claude', 'skills', 'team-skill', 'SKILL.md'), '# Team Skill'); + const config = path.join(projectRoot, '.opencode', 'opencode.json'); + const entry = '.opencode/teamai-context.md'; + await fse.outputJson(config, { instructions: [entry] }); + vi.stubEnv('HOME', path.join(tmpDir, 'home')); + vi.stubEnv('SHELL', '/bin/zsh'); + const teamConfig = makeTeamConfig({ toolPaths: { + opencode: { skills: '.opencode/skills', rules: '.opencode/rules' }, + claude: { skills: '.claude/skills', rules: '.claude/rules' }, + } }); + const localConfig = makeLocalConfig(projectRoot, repoPath, { scope: 'project', projectRoot }); + mockAutoDetectInit.mockResolvedValue({ localConfig, teamConfig }); + const ref = { config, entry }; + await saveStateForScope({ ...await loadStateForScope(localConfig), opencodeContextEntries: [ref] }, localConfig); + const originalRename = fse.rename.bind(fse); + const originalRead = fse.readFile.bind(fse); + if (failure === 'write failure') { + vi.spyOn(fse, 'rename').mockImplementation((...args: Parameters) => { + if (args[1] === config) return Promise.reject(new Error('EACCES')); + return originalRename(...args); + }); + } else { + vi.spyOn(fse, 'readFile').mockImplementation((...args: Parameters) => { + if (args[0] === config) return Promise.reject(new Error('EACCES')); + return originalRead(...args); + }); + } + await uninstall({ force: true, agent: 'opencode' }); + expect((await loadStateForScope(localConfig)).opencodeContextEntries).toEqual([ref]); + vi.restoreAllMocks(); + expect((await fse.readJson(config)).instructions).toEqual([entry]); + mockAutoDetectInit.mockResolvedValue({ localConfig, teamConfig }); + await uninstall({ force: true, agent: 'opencode' }); + expect((await fse.readJson(config)).instructions).toBeUndefined(); + expect((await loadStateForScope(localConfig)).opencodeContextEntries).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/instruction-targets.ts b/src/instruction-targets.ts index c5d455e68..1e3dfe1a7 100644 --- a/src/instruction-targets.ts +++ b/src/instruction-targets.ts @@ -469,6 +469,8 @@ export async function registerOpencodeContext( resolved: Pick, dryRun: boolean, planned: readonly string[] = [], + /** The plan's warnings and write failures: a file they name was left as it was. */ + problems: readonly string[] = [], ): Promise { const paths = scopedToolPaths(teamConfig, localConfig).opencode; const contextFile = paths && instructionTargetPath('opencode', paths, localConfig); @@ -478,7 +480,8 @@ export async function registerOpencodeContext( const delivered = await holdsInstructionBlocks(contextFile) || (dryRun && planned.includes(contextFile)); // A same-named file of the member's: its entry, if any, is theirs too. if (!delivered && await pathExists(contextFile)) return null; - const present = wanted && delivered; + // Old or malformed blocks the plan could not replace are not this sync's. + const present = wanted && delivered && !problems.some((problem) => problem.includes(contextFile)); const { config, entry } = opencodeContextReference(contextFile, localConfig.scope, resolveToolBaseDir('opencode', localConfig)); // An entry goes only if teamai recorded adding it: one the member listed // before teamai wrote the file is theirs. diff --git a/src/local-agent.ts b/src/local-agent.ts index 4ed7ed13f..712b59042 100644 --- a/src/local-agent.ts +++ b/src/local-agent.ts @@ -2066,8 +2066,15 @@ async function uninstallResource(input: { } else if (input.kind === 'rule') { await new RulesHandler().removeItem(input.slug, teamConfig, localConfig); } else { - await remove(path.join(repoPath, 'claudemd', `${input.slug}.md`)); - await syncClaudemd(teamConfig, localConfig, repoPath, input.workspacePath, fullTeamConfig); + const dest = path.join(repoPath, 'claudemd', `${input.slug}.md`); + const previous = await readFileSafe(dest); + await remove(dest); + try { + await syncClaudemd(teamConfig, localConfig, repoPath, input.workspacePath, fullTeamConfig); + } catch (error) { + if (previous !== null) await fse.writeFile(dest, previous); + throw error; + } } delete scopeManifest[manifestKind(input.kind)][input.slug]; @@ -2205,7 +2212,8 @@ async function syncClaudemd( for (const line of report) log.info(`${line}: no installed tool loads them from this file`); for (const failure of failures) log.warn(failure); - if (files.length > 0 && !syncedAny) { + // Removing the last prompt fails too when a target kept it. + if (!syncedAny && (files.length > 0 || skipped.length > 0)) { throw new Error(['CLAUDE.md sync landed on no tool: every configured target was skipped.', ...skipped].join(' ')); } } diff --git a/src/pull.ts b/src/pull.ts index d02b034c3..9b7d6dcd7 100644 --- a/src/pull.ts +++ b/src/pull.ts @@ -1924,7 +1924,7 @@ async function syncManagedInstructions( for (const failure of failures) log.warn(`[${scopeLabel}] ${failure}`); try { const planned = plan.changes.filter((change) => change.content !== null).map((change) => change.path); - const registered = await registerOpencodeContext(config, localConfig, resolved, dryRun, planned); + const registered = await registerOpencodeContext(config, localConfig, resolved, dryRun, planned, [...plan.warnings, ...failures]); if (registered && dryRun) log.info(`[dry-run] ${registered}`); else if (registered) log.debug(registered); } catch (e) { diff --git a/src/recall-toggle.ts b/src/recall-toggle.ts index 8b94cb280..8947e79ca 100644 --- a/src/recall-toggle.ts +++ b/src/recall-toggle.ts @@ -84,7 +84,7 @@ async function writeRecallBlock( const { report, failures } = await applyInstructionPlan(plan, { dryRun: false }); for (const line of report) log.debug(line); for (const failure of failures) log.warn(failure); - await registerOpencodeContext(teamConfig, localConfig, resolved, false); + await registerOpencodeContext(teamConfig, localConfig, resolved, false, [], [...plan.warnings, ...failures]); } async function deployRecallArtifacts(teamConfig: TeamaiConfig, localConfig: LocalConfig): Promise { diff --git a/src/uninstall.ts b/src/uninstall.ts index 30629d00b..962432b87 100644 --- a/src/uninstall.ts +++ b/src/uninstall.ts @@ -1409,13 +1409,20 @@ export async function uninstall(opts: UninstallOptions): Promise { await executeRemoval(plan); - // The OpenCode entries uninstall removed are no longer teamai's to track. + // The OpenCode entries uninstall removed are no longer teamai's to track; + // one still listed (the write failed) stays recorded for the next try. if (plan.opencodeInstructions.length > 0 && !plan.includeShared) { const { loadStateForScope, saveStateForScope } = await import('./config.js'); + const { readOpencodeInstructionList } = await import('./resources/opencode-config.js'); const state = await loadStateForScope(localConfig!); if (state.opencodeContextEntries) { + const removed: typeof plan.opencodeInstructions = []; + for (const ref of plan.opencodeInstructions) { + const listed = await readOpencodeInstructionList(ref.config); + if (listed !== null && !listed.includes(ref.entry)) removed.push(ref); + } state.opencodeContextEntries = state.opencodeContextEntries.filter( - (ref) => !plan.opencodeInstructions.some((e) => e.config === ref.config && e.entry === ref.entry), + (ref) => !removed.some((e) => e.config === ref.config && e.entry === ref.entry), ); await saveStateForScope(state, localConfig!); } From cf3b25138292f070a4cd39e0913b3dcdf571ed07 Mon Sep 17 00:00:00 2001 From: Saul Moro Date: Fri, 2 Oct 2026 13:37:26 +0200 Subject: [PATCH 34/41] fix(instructions): confirm replacements before retiring delivery (#945) --- docs/usage-guide.md | 20 ++- docs/usage-guide.zh-CN.md | 20 ++- skill-data/core/references/troubleshooting.md | 12 ++ skill-data/setup/references/uninstall.md | 6 +- src/__tests__/e2e/instruction-targets.test.ts | 146 +++++++++++++++++- src/__tests__/helpers/pi-extensions.ts | 3 +- src/__tests__/instruction-targets.test.ts | 111 ++++++++++++- src/__tests__/local-agent.test.ts | 64 +++++++- src/__tests__/omp-hooks.test.ts | 11 ++ src/__tests__/pi-hooks.test.ts | 11 ++ src/__tests__/recall-toggle.test.ts | 21 +++ src/__tests__/uninstall.test.ts | 84 ++++++++-- src/doctor-delivery.ts | 8 +- src/hook-handlers.ts | 9 +- src/instruction-targets.ts | 91 +++++++++-- src/local-agent.ts | 36 ++--- src/omp-hooks.ts | 2 +- src/pi-hooks.ts | 2 +- src/pull.ts | 71 +++++++-- src/recall-toggle.ts | 4 +- src/uninstall.ts | 93 +++++------ 21 files changed, 672 insertions(+), 153 deletions(-) diff --git a/docs/usage-guide.md b/docs/usage-guide.md index 4bd80a2b1..358a1c124 100644 --- a/docs/usage-guide.md +++ b/docs/usage-guide.md @@ -1674,11 +1674,11 @@ The CodeBuddy and WorkBuddy rule files carry `alwaysApply: true`, which CodeBudd In a project, teamai installs the Hermes plugin `$HERMES_HOME/plugins/teamai-instructions/` and adds it to `plugins.enabled` in `$HERMES_HOME/config.yaml` (a name you list under `plugins.disabled` stays off). A plugin of that name teamai did not write is left alone, also on uninstall, and `teamai pull` and `teamai doctor` say so. According to Hermes' documentation it builds the section once for each new session from the session's directory and keeps it through compression and resume. A section holds at most 4,000 characters, and all plugin sections together at most 8,000. When this member's instructions for the project are longer, Hermes skips them and `teamai pull` says so: teamai does not cut them or write them to `AGENTS.md`. Outside a project the section is empty, and Hermes may log that it skipped an empty section. -OpenCode loads a file only when its config lists it in `instructions`. teamai adds that entry only when the target already matches the desired blocks or its update succeeds. A malformed target or a failed write does not activate stale blocks. It keeps your other entries and keys; the root `opencode.json` and OpenCode's own `AGENTS.md` files are left alone. While `~/.config/opencode/AGENTS.md` does not exist, OpenCode reads `~/.claude/CLAUDE.md` instead; when Claude Code gets the user blocks there, OpenCode already has them, so teamai writes no second user copy for OpenCode and says so in the pull output. Blocks left there by a Claude Code you excluded count too, since OpenCode reads them all the same; the pull then warns that nothing keeps them current. A config file teamai cannot parse as JSON (for example one with comments) is left unchanged with a warning; add the entry by hand. +OpenCode loads a file only when its config lists it in `instructions`. teamai adds that entry only when the target already matches the desired blocks or its update succeeds. A malformed target or a failed write does not activate stale blocks. A failed edit keeps an existing instructions entry, including when malformed recall markers prevent a recall toggle. It keeps your other entries and keys; the root `opencode.json` and OpenCode's own `AGENTS.md` files are left alone. While `~/.config/opencode/AGENTS.md` does not exist, OpenCode reads `~/.claude/CLAUDE.md` instead; when Claude Code gets the user blocks there, OpenCode already has them, so teamai writes no second user copy for OpenCode and says so in the pull output. Blocks left there by a Claude Code you excluded count too, since OpenCode reads them all the same; the pull then warns that nothing keeps them current. A config file teamai cannot parse as JSON (for example one with comments) is left unchanged with a warning; add the entry by hand. -Oh My Pi reads `RULES.md` as an always-applied rule beside its single user context file. In project scope teamai's OMP extension asks `teamai` for the blocks when the session starts and adds them to each turn's system prompt, from the project root and any subdirectory. Without that extension (for example with hooks removed), an Oh My Pi project session gets no team blocks. Prompts the HTTP local agent delivers for a project reach Pi, Oh My Pi and Hermes the same way, through their extension or plugin, and the Codex family through its session-start and subagent-start hooks. +Oh My Pi reads `RULES.md` as an always-applied rule beside its single user context file. In project scope teamai's OMP extension asks `teamai` for the blocks when the session starts and adds them to each turn's system prompt, from the project root and any subdirectory. Without that extension (for example with hooks removed), an Oh My Pi project session gets no team blocks. Prompts the HTTP local agent delivers for a project reach Pi, Oh My Pi and Hermes the same way, through their extension or plugin, and the Codex family through its session-start and subagent-start hooks. Pi and Oh My Pi wait for foreground session-start dispatch, including HTTP prompt sync, before caching the project instructions for the first prompt. Codex reads its HTTP prompt cache after the same sync, before returning SessionStart context. -A pull from an earlier release may have left these blocks in a file listed below. The next pull removes them, and names each file it changes: +A pull from an earlier release may have left these blocks in a file listed below. A pull removes them only after every installed tool that wrote that file has its replacement instructions. Failed target writes, foreign files, missing extensions or disabled plugins keep the old blocks for a retry. Excluded tools' retired files stay unchanged. The pull names each file it changes: - Claude Code, project scope: `.claude/CLAUDE.md` - CodeBuddy, project scope: `.codebuddy/CODEBUDDY.md` @@ -1691,6 +1691,8 @@ A pull from an earlier release may have left these blocks in a file listed below `teamai doctor` checks that each installed tool can load these blocks: that each file holds the current blocks, that OpenCode's config lists its file, that the Pi or Oh My Pi extension and the Hermes plugin are installed and enabled, that the Hermes section fits its limit, and that no file an earlier release wrote still holds blocks. +If a requested block has incomplete or duplicated markers, the entire file stays unchanged, including its other managed blocks. Fix the named markers, then run `teamai pull` again. + A file named like a teamai target that teamai did not write is left alone and not listed in OpenCode's `instructions` (an entry you listed for it stays), and the pull warns about it. A team rule named `teamai-context` is not delivered, since it would land on that file; the pull names it, and removes a copy an earlier release delivered unless you changed it. teamai does not change `.gitignore`, `.git/info/exclude` or the git index. A team that wants to keep these files out of commits excludes them itself. ### Viewing the result @@ -2208,7 +2210,7 @@ Team hooks still come from the team's `hooks/hooks.yaml`: edit that source in th - **Scopes.** Project skills and TeamAI-managed rules are written to `.pi/skills/` and `.pi/rules/`. User-scope copies use `~/.pi/agent/skills/` and `~/.pi/agent/rules/`. - **Instructions.** Pi reads the project's own `AGENTS.md` (or `CLAUDE.md`); TeamAI leaves it unchanged. User-scope team instructions go to `~/.pi/agent/AGENTS.md`. In a project, the TeamAI Pi extension asks `teamai` for the member's team instructions when the session starts and adds them to the system prompt of each run. -- **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. Any targeted removal — the explicit `teamai hooks remove` command, or a scoped `teamai uninstall --agent pi` — deletes this shared extension outright, the same single-file removal semantics as the OMP adapter: Pi has no way to scope one shared file to a single project, so it doesn't pretend to preserve it for other projects while the extension keeps firing for this one anyway; 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`. Because the extension is one shared file rather than a per-project one, a scoped removal is not durable in a multi-project setup: the next `teamai init`/`pull` in any other scope where Pi is still enabled re-creates it, and hook dispatch has no per-project exclusion check, so hooks can resume firing in the project that was just uninstalled from. This is the same trade-off the OMP adapter already ships with. +- **Hooks.** TeamAI generates one user-scoped `teamai-hooks.ts` under `~/.pi/agent/extensions/`. It maps `session_start` → session-start, `before_agent_start` → prompt-submit, and `agent_settled` → stop; `tool_execution_start` caches the tool's input, and `tool_execution_end` dispatches post-tool-use forwarding that cached input as `tool_input`, plus the result's text as `tool_response` and a `tool_status` from its error flag. Every event carries the Pi session id (`ctx.sessionManager.getSessionId()`), the same id Pi's bash tool exports as `PI_SESSION_ID`, so a `teamai recall` run there joins the session its hooks carry and upvote **adoption** runs for Pi. Pi loads both user and project extension roots, so TeamAI never creates a project copy — a second copy would double-dispatch every event, the same single-copy policy as the OMP adapter. An older TeamAI-managed project copy is removed during the next sync, and injection never overwrites a same-named file that lacks the TeamAI marker. Pi has no settings file for self mode to commit, so a fresh clone still needs one `teamai init`/`pull` on that machine before Pi hooks are active there. The explicit `teamai hooks remove` command and user-scope `teamai uninstall --agent pi` delete this shared extension. Project uninstall preserves it for other projects and removes any legacy project copy; files without the TeamAI marker are never removed. `teamai hooks list` always reports this global path. Pi profile overrides (`PI_CODING_AGENT_DIR` / `PI_CONFIG_DIR`), which relocate the agent directory, are not supported for hooks — same as the OMP adapter — and the default `~/.pi/agent/` layout is used. Model profiles are separate and do read `PI_CODING_AGENT_DIR`. The shared extension remains installed after project uninstall; instruction dispatch checks the project's tool exclusion before adding its instructions. - **Team hooks boundary.** The Pi adapter installs only the built-in lifecycle bridge. Custom team hooks and built-in hook overrides declared in `hooks/hooks.yaml` are skipped with a warning. Full team-hook and per-project ownership semantics require a separate cross-adapter design and are deferred to a follow-up PR. - **Server-pushed agent hooks.** HTTP-source hooks are installed as `teamai-agent-.ts` extensions in the same global extension directory. Unsupported lifecycle events are skipped with a warning. - **MCP (Pi 0.99.0+).** Supports stdio and streamable HTTP; SSE is skipped. User configuration goes to `~/.pi/agent/mcp.json`, project configuration to `.pi/mcp.json`; Pi loads project configuration only after trusting the project. The native `codemode` default is retained, without forcing direct exposure; timeout values in `mcp.yaml` are converted from milliseconds to seconds. Local exposure/enabled changes to managed entries survive unchanged team definitions but are replaced when the team definition changes; doctor compares complete entries and reports these local differences. An extension taking over `/mcp` can disable built-in MCP; remove that extension to use the built-in support. @@ -2236,7 +2238,7 @@ These paths are verified against the ZCode desktop app: profiles created in its ### Oh My Pi -Oh My Pi (OMP) is available as a built-in target. TeamAI deploys skills, rules, and subagents to OMP's native directories — `.omp/skills/`, `.omp/rules/`, and `.omp/agents/` at project scope, and `~/.omp/agent/skills/`, `~/.omp/agent/rules/`, and `~/.omp/agent/agents/` at user scope (user-scope resources live under the agent directory `~/.omp/agent/`, a different prefix from the project one, so TeamAI switches prefixes with the scope). Team instructions go to `~/.omp/agent/RULES.md` in user scope and, in project scope, into each turn's system prompt through the extension below (see [Where the blocks go](#where-the-blocks-go)), and MCP servers merge into `~/.omp/agent/mcp.json` / `/.omp/mcp.json` (Claude `mcpServers` shape — see the MCP section above). Skills are one-level `/SKILL.md` bundles and TeamAI fills in a `description` on sync, which OMP's native skill provider requires to discover a skill. These paths follow OMP's documented discovery layout (verified against OMP 18.2.5). Hooks ride OMP's extension runner: `teamai pull` writes a single generated extension to `~/.omp/agent/extensions/teamai-hooks.ts` (never a project copy — OMP auto-loads both roots and would double-dispatch every event), which forwards OMP's `session_start` / `session_stop` / `before_agent_start` / `tool_result` events to the same `teamai hook-dispatch` entry point every other agent uses, gated on the session `cwd`. In a project session it also asks for the member's team instructions at `session_start` and appends them to the system prompt in `before_agent_start`. Every event carries the OMP session id (`ctx.sessionManager.getSessionId()`; a subagent has its own), and `tool_result` also the tool's text output and a status from `isError`, so upvote **adoption** runs for OMP's main agent: OMP sets no session variable in its shell, so a recall joins the session of the `bash` call that ran it, and a `read` with a line selector (`x.md:50-200`, `x.md:raw`) counts as a read of the file. From OMP 18.3.2 a subagent's events also carry its `ctx.agent` id and name, so the `teamai-recall` subagent's own reads never count. A subagent's session file sits under its parent's, whose header names the parent session, so the extension links the two on the subagent's tool calls, and a doc the main agent opens after a subagent's recall is upvoted (verified against OMP 18.4.8). The `session_stop` handler returns nothing, so a dispatch can never force a session continuation, and there is no matcher-scoped post-tool-use pass because OMP's tool ids are lowercase (`bash`, `read`, …) and it has no `Skill` / `TodoWrite` tool. `teamai uninstall` removes the extension. 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. +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. ### DeepSeek Harness @@ -2858,11 +2860,13 @@ What gets removed: `--agent ` removes only that tool's teamai resources (hooks, team instruction blocks, skills, rules, team-synced custom agents, and built-in agents). The tool name is a key of `toolPaths` (e.g. `claude`, `codex`, `codebuddy`) and is matched case-insensitively. An unknown tool name aborts without deleting anything, lists the available tools, and exits with a non-zero status. -An instructions file several tools map is cleaned per block: a teamai block stays while a remaining tool on that file still writes it. The common case is `.codebuddy/rules/teamai-context.md`, which CodeBuddy and WorkBuddy share: `--agent workbuddy` keeps it while CodeBuddy is installed. A file an earlier release wrote the blocks to, such as the project `AGENTS.md`, is read by no tool now, so its teamai blocks go and your own text stays. A file teamai created goes with its last block; an instructions file you had before stays, even an empty one. +An instructions file several tools map is cleaned per block: a teamai block stays while a remaining tool on that file still writes it. The common case is `.codebuddy/rules/teamai-context.md`, which CodeBuddy and WorkBuddy share: `--agent workbuddy` keeps it while CodeBuddy is installed. A file an earlier release wrote the blocks to, such as the project `AGENTS.md`, is read by no tool now, so its teamai blocks go and your own text stays. A file teamai created goes with its last block; an instructions file you had before stays, even an empty one. A configured `claudemd` remains a member file even when its basename is `teamai-context.md`. + +Shared resources (the env block, docs directory, and `~/.teamai/`) are removed **only when the target itself has teamai resources AND is the last tool still using teamai** — otherwise they are kept for the remaining tools. Targeting a tool with no local resources leaves shared resources in place, even if it is the only tool. Project uninstall still records the exclusion for Pi, Oh My Pi and Hermes, whose instruction channels are global. -Shared resources (the env block, docs directory, and `~/.teamai/`) are removed **only when the target itself has teamai resources AND is the last tool still using teamai** — otherwise they are kept for the remaining tools. (So targeting a tool that has no teamai resources of its own is a no-op and leaves shared resources in place, even if it happens to be the only tool.) +If removing an OpenCode entry added by teamai fails, uninstall exits with an error and keeps the shared data directory and ownership record, even when OpenCode is the last tool. Repair the config or its permissions, then retry the same uninstall command. -If removing an OpenCode entry added by teamai fails, its ownership record stays while the shared data directory survives. Repair the config or its permissions, then retry `teamai uninstall --agent opencode`. +Project uninstall keeps Pi's and Oh My Pi's global extensions and Hermes' global plugin and configuration, which other projects use. A targeted uninstall excludes the tool in this project's config when that config survives. User-scope uninstall removes these global delivery channels. The exclusion is durable: `uninstall --agent ` drops the tool from `enabledAgents` and records it in `disabledAgents`, so a later `pull` (or another tool's session-start hook) will not resurrect its skills, rules, agents, team instruction blocks, or hooks. Running `init --agent ` again clears the exclusion and re-enables sync for that tool. diff --git a/docs/usage-guide.zh-CN.md b/docs/usage-guide.zh-CN.md index eda37bc2a..6ec4c1b94 100644 --- a/docs/usage-guide.zh-CN.md +++ b/docs/usage-guide.zh-CN.md @@ -1552,11 +1552,11 @@ CodeBuddy 和 WorkBuddy 的规则文件带有 `alwaysApply: true`,CodeBuddy 在项目中,teamai 安装 Hermes 插件 `$HERMES_HOME/plugins/teamai-instructions/`,并把它加入 `$HERMES_HOME/config.yaml` 的 `plugins.enabled`(列在 `plugins.disabled` 中的名称保持关闭)。同名但并非 teamai 写入的插件保持不变,卸载时也一样,`teamai pull` 和 `teamai doctor` 会指出这一点。根据 Hermes 的文档,它在每个新会话开始时根据会话目录生成该段落,并在压缩和恢复后保留。一个段落最多 4,000 个字符,所有插件段落合计最多 8,000 个字符。当该成员在此项目的指令更长时,Hermes 会跳过它们,`teamai pull` 会给出提示:teamai 不会截断它们,也不会写入 `AGENTS.md`。在项目之外段落为空,Hermes 可能记录它跳过了一个空段落。 -OpenCode 只加载配置中 `instructions` 列出的文件。仅当目标已包含所需的块或更新成功时,teamai 才添加该条目;标记不完整或写入失败时,不会激活旧块。teamai 只添加这一项,并保留你的其他条目和键;根目录的 `opencode.json` 以及 OpenCode 自己的 `AGENTS.md` 文件保持不变。当 `~/.config/opencode/AGENTS.md` 不存在时,OpenCode 会改为读取 `~/.claude/CLAUDE.md`;若 Claude Code 的用户块已在那里,OpenCode 已经获得它们,因此 teamai 不会为 OpenCode 再写一份用户副本,并在 pull 输出中说明。被排除的 Claude Code 留在那里的块同样算数,因为 OpenCode 照样读取它们;此时 pull 会警告没有任何工具再更新它们。teamai 无法按 JSON 解析的配置文件(例如带注释的文件)保持不变并给出警告,请手动添加该条目。 +OpenCode 只加载配置中 `instructions` 列出的文件。仅当目标已包含所需的块或更新成功时,teamai 才添加该条目;标记不完整或写入失败时,不会激活旧块。编辑失败会保留已有的 instructions 条目,recall 标记不完整导致切换失败时也一样。teamai 只添加这一项,并保留你的其他条目和键;根目录的 `opencode.json` 以及 OpenCode 自己的 `AGENTS.md` 文件保持不变。当 `~/.config/opencode/AGENTS.md` 不存在时,OpenCode 会改为读取 `~/.claude/CLAUDE.md`;若 Claude Code 的用户块已在那里,OpenCode 已经获得它们,因此 teamai 不会为 OpenCode 再写一份用户副本,并在 pull 输出中说明。被排除的 Claude Code 留在那里的块同样算数,因为 OpenCode 照样读取它们;此时 pull 会警告没有任何工具再更新它们。teamai 无法按 JSON 解析的配置文件(例如带注释的文件)保持不变并给出警告,请手动添加该条目。 -Oh My Pi 把 `RULES.md` 作为始终应用的规则读取,与其唯一的用户上下文文件并存。在项目范围内,teamai 的 OMP 扩展在会话开始时向 `teamai` 获取这些块,并加入每轮的系统提示,无论会话从项目根目录还是子目录启动。没有该扩展时(例如移除了 hooks),Oh My Pi 的项目会话不会获得团队块。HTTP local agent 为项目下发的 prompt 也以同样方式,通过扩展或插件到达 Pi、Oh My Pi 和 Hermes,并通过 session-start 和 subagent-start hook 到达 Codex 系列。 +Oh My Pi 把 `RULES.md` 作为始终应用的规则读取,与其唯一的用户上下文文件并存。在项目范围内,teamai 的 OMP 扩展在会话开始时向 `teamai` 获取这些块,并加入每轮的系统提示,无论会话从项目根目录还是子目录启动。没有该扩展时(例如移除了 hooks),Oh My Pi 的项目会话不会获得团队块。HTTP local agent 为项目下发的 prompt 也以同样方式,通过扩展或插件到达 Pi、Oh My Pi 和 Hermes,并通过 session-start 和 subagent-start hook 到达 Codex 系列。Pi 和 Oh My Pi 会等待前台 session-start 派发(包括 HTTP prompt 同步)完成,再为首个 prompt 缓存项目指令。Codex 也会在该同步完成后读取 HTTP prompt 缓存,再返回 SessionStart 上下文。 -早期版本的 pull 可能把这些块留在下列文件中。下一次 pull 会移除它们,并列出所修改的每个文件: +早期版本的 pull 可能把这些块留在下列文件中。只有曾写入该文件的每个已安装工具都获得替代指令后,pull 才移除旧块。目标写入失败、文件并非 teamai 所有、扩展缺失或插件被禁用时,旧块保留以便重试。被排除工具的旧指令文件保持不变。pull 会列出所修改的每个文件: - Claude Code,项目范围:`.claude/CLAUDE.md` - CodeBuddy,项目范围:`.codebuddy/CODEBUDDY.md` @@ -1569,6 +1569,8 @@ Oh My Pi 把 `RULES.md` 作为始终应用的规则读取,与其唯一的用 `teamai doctor` 会检查每个已安装工具能否加载这些块:每个文件是否包含当前的块,OpenCode 配置是否列出其文件,Pi 或 Oh My Pi 扩展和 Hermes 插件是否已安装并启用,Hermes 段落是否在限制内,以及早期版本写过的文件中是否仍残留块。 +若请求更新的块存在不完整或重复的标记,整个文件保持不变,包括其他托管块。修复提示中的标记后,再运行 `teamai pull`。 + 与 teamai 目标同名但并非 teamai 写入的文件保持不变,也不会被列入 OpenCode 的 `instructions`(你自己为它列的条目保留不动),pull 会给出警告。名为 `teamai-context` 的团队 rule 不会被分发,因为它会落在该文件上;pull 会指出它,并删除早期版本分发的副本(除非你改过它)。teamai 不修改 `.gitignore`、`.git/info/exclude` 或 git 索引。若团队希望这些文件不进入提交,需要自行排除。 ### 查看效果 @@ -2071,7 +2073,7 @@ GitHub Copilot CLI 已支持其官方自定义指令、Rules、Skills、自定 - **作用域。** 项目级 Skills 和 TeamAI 管理的 Rules 写入 `.pi/skills/`、`.pi/rules/`;用户级副本写入 `~/.pi/agent/skills/`、`~/.pi/agent/rules/`。 - **指令文件。** Pi 读取项目自己的 `AGENTS.md`(或 `CLAUDE.md`),TeamAI 不修改它。用户范围的团队指令写入 `~/.pi/agent/AGENTS.md`。在项目中,TeamAI 的 Pi 扩展在会话开始时向 `teamai` 获取成员的团队指令,并加入每次运行的系统提示。 -- **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`,或者某个 scope 下的 `teamai uninstall --agent pi`——都会直接删除这份共享扩展,和 OMP 适配器的单文件删除语义完全一致:Pi 没有办法把一份共享文件限定在某一个项目里,所以不会假装"为其他项目保留"却让这份扩展继续对当前项目触发;没有 TeamAI 标记的同名文件不会被删除。`teamai hooks list` 始终显示这个全局路径。Pi 的 profile 覆盖项(`PI_CODING_AGENT_DIR` / `PI_CONFIG_DIR`,会迁移 agent 目录)在 hooks 中暂不支持,与 OMP 适配器一致,使用默认的 `~/.pi/agent/` 布局。模型配置是另一回事,会读取 `PI_CODING_AGENT_DIR`。由于这份扩展是机器级共享的单个文件而非按项目隔离,某个 scope 下的移除在多项目场景中并不持久:只要 Pi 在其他任意 scope 仍处于启用状态,下一次在那里执行 `teamai init`/`pull` 就会把它重新生成,而 hook 派发本身没有按项目排除的检查,因此刚被卸载的项目里 hooks 仍可能重新触发。这与 OMP 适配器早已上线的取舍完全一致。 +- **Hooks。** TeamAI 只在用户级 `~/.pi/agent/extensions/` 生成一份 `teamai-hooks.ts`,把 `session_start` 映射为 session-start、`before_agent_start` 映射为 prompt-submit、`agent_settled` 映射为 stop;`tool_execution_start` 缓存工具输入,`tool_execution_end` 派发 post-tool-use 时把缓存的输入转发为 `tool_input`,并附上结果文本 `tool_response` 和根据错误标志得出的 `tool_status`。每个事件都携带 Pi 会话 id(`ctx.sessionManager.getSessionId()`),与 Pi 的 bash 工具导出的 `PI_SESSION_ID` 相同,因此在其中运行的 `teamai recall` 会归入其 hooks 携带的同一会话,upvote **采纳(adoption)**在 Pi 上同样生效。Pi 会同时加载用户级与项目级扩展目录,因此 TeamAI 不创建项目副本——第二份副本会导致每个事件被派发两次,这与 OMP 适配器的单副本策略一致。早期版本遗留且带 TeamAI 标记的项目副本会在下次同步时移除,注入逻辑也不会覆盖没有 TeamAI 标记的同名文件。Pi 没有可供 self mode 提交的设置文件,所以 fresh clone 仍需在该机器上手动跑一次 `teamai init`/`pull` 才能激活 Pi hooks。显式执行 `teamai hooks remove` 或用户级 `teamai uninstall --agent pi` 会删除这份共享扩展。项目级卸载为其他项目保留它,并移除旧的项目副本;没有 TeamAI 标记的同名文件不会被删除。`teamai hooks list` 始终显示这个全局路径。Pi 的 profile 覆盖项(`PI_CODING_AGENT_DIR` / `PI_CONFIG_DIR`,会迁移 agent 目录)在 hooks 中暂不支持,与 OMP 适配器一致,使用默认的 `~/.pi/agent/` 布局。模型配置是另一回事,会读取 `PI_CODING_AGENT_DIR`。项目级卸载后共享扩展仍已安装;指令派发会先检查此项目对该工具的排除设置。 - **团队 Hooks 边界。** Pi 适配器只安装内置生命周期桥接。`hooks/hooks.yaml` 声明的自定义团队 Hooks 和内置 Hook 覆盖会被跳过并给出警告。完整团队 Hooks 与逐项目归属语义需要单独的跨适配器设计,留待后续 PR。 - **服务端下发的 Agent Hooks。** HTTP source hooks 会以同一用户级扩展目录中的 `teamai-agent-.ts` 形式安装。不支持的生命周期事件会警告并跳过。 - **MCP(Pi 0.99.0+)。** 支持 stdio 和 streamable HTTP;SSE 会跳过。用户级写入 `~/.pi/agent/mcp.json`,项目级写入 `.pi/mcp.json`;项目配置需要 Pi 信任项目后才加载。保留原生 `codemode` 默认值,不强制 direct;`mcp.yaml` 的 timeout 从毫秒转换成秒。受管条目的本地 exposure/启用状态在团队定义不变时保留,团队定义更新时会被替换;doctor 按完整条目比较,会报告这些本地差异。接管 `/mcp` 的扩展可能禁用内置 MCP;使用内置支持需移除此类扩展。 @@ -2099,7 +2101,7 @@ ZCode 已作为内置目标支持。Skills 下发到 `.zcode/skills/`(ZCode ### Oh My Pi -Oh My Pi(OMP)已作为内置目标支持。TeamAI 将 Skills、Rules 和 Subagents 下发到 OMP 的原生目录——项目级为 `.omp/skills/`、`.omp/rules/` 和 `.omp/agents/`,用户级为 `~/.omp/agent/skills/`、`~/.omp/agent/rules/` 和 `~/.omp/agent/agents/`(用户级资源位于 agent 目录 `~/.omp/agent/` 下,与项目级前缀不同,TeamAI 会随作用域自动切换)。团队指令在用户范围写入 `~/.omp/agent/RULES.md`,在项目范围由下文的 extension 加入每轮的系统提示(见[这些块写到哪里](#这些块写到哪里));MCP Server 合并进 `~/.omp/agent/mcp.json` / `/.omp/mcp.json`(Claude `mcpServers` 结构,见上文 MCP 章节)。Skills 采用一层 `/SKILL.md` 目录结构,TeamAI 在同步时补全 `description`——OMP 原生 skill 发现要求该字段。以上路径遵循 OMP 官方文档的发现布局(对照 OMP 18.2.5 验证)。Hooks 走 OMP 的 extension runner:`teamai pull` 会生成唯一的 extension 写入 `~/.omp/agent/extensions/teamai-hooks.ts`(绝不写项目副本——OMP 会同时加载两个根并导致每个事件双派发),它把 OMP 的 `session_start` / `session_stop` / `before_agent_start` / `tool_result` 事件转发给所有 agent 共用的 `teamai hook-dispatch` 入口,并按会话 `cwd` 做项目门控。在项目会话中,它还会在 `session_start` 时获取成员的团队指令,并在 `before_agent_start` 中追加到系统提示。每个事件都携带 OMP 会话 id(`ctx.sessionManager.getSessionId()`;subagent 有自己的会话),`tool_result` 还带上工具的文本输出和根据 `isError` 得出的状态,因此 upvote **采纳(adoption)**在 OMP 主 agent 上生效:OMP 不在其 shell 中设置会话变量,所以 recall 归入运行它的那次 `bash` 调用所在的会话;带行选择器的 `read`(`x.md:50-200`、`x.md:raw`)计为对该文件的读取。从 OMP 18.3.2 起,subagent 的事件还会携带其 `ctx.agent` 的 id 和名称,因此 `teamai-recall` subagent 自身的读取从不计入。subagent 的会话文件位于父会话文件之下,父会话文件的头部写明父会话 id,因此 extension 会在 subagent 的工具调用中关联这两个会话,主 agent 在 subagent recall 之后打开的文档会被 upvote(对照 OMP 18.4.8 验证)。`session_stop` 处理器不返回任何值,分发绝不会强制会话继续;由于 OMP 的工具名是小写(`bash`、`read` 等)且没有 `Skill` / `TodoWrite` 工具,post-tool-use 不做 matcher 定向分发。`teamai uninstall` 会移除该 extension。与 Pi 一样,不带 TeamAI 标记的同名文件绝不会被覆盖或删除。OMP 的 profile(`OMP_PROFILE` / `PI_CODING_AGENT_DIR` / `PI_CONFIG_DIR`,会迁移 agent 目录)暂不支持,使用默认的 `~/.omp/agent/` 布局。 +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/` 布局。 ### DeepSeek Harness @@ -2669,11 +2671,13 @@ teamai uninstall --agent claude `--agent ` 只移除该工具的 teamai 资源(hooks、团队指令块、skills、rules、团队同步的自定义 agents、内置 agents)。工具名即 `toolPaths` 的键(如 `claude`、`codex`、`codebuddy`),匹配大小写不敏感。传入未知工具名会直接报错并列出可用工具、不执行任何删除,并以非零状态码退出。 -多个工具共同映射的指令文件按区块清理:只要该文件上仍有剩余工具会写入某个 teamai 区块,该区块就保留。最常见的是 CodeBuddy 与 WorkBuddy 共用的 `.codebuddy/rules/teamai-context.md`:只要 CodeBuddy 仍已安装,`--agent workbuddy` 就会保留它。早期版本写过这些块的文件(例如项目 `AGENTS.md`)现在没有任何工具读取,因此其中的 teamai 区块会被移除,你自己的内容保留。teamai 创建的文件随最后一个区块一起删除;你原有的指令文件(即使是空文件)会保留。 +多个工具共同映射的指令文件按区块清理:只要该文件上仍有剩余工具会写入某个 teamai 区块,该区块就保留。最常见的是 CodeBuddy 与 WorkBuddy 共用的 `.codebuddy/rules/teamai-context.md`:只要 CodeBuddy 仍已安装,`--agent workbuddy` 就会保留它。早期版本写过这些块的文件(例如项目 `AGENTS.md`)现在没有任何工具读取,因此其中的 teamai 区块会被移除,你自己的内容保留。teamai 创建的文件随最后一个区块一起删除;你原有的指令文件(即使是空文件)会保留。配置的 `claudemd` 即使名为 `teamai-context.md`,也仍是成员文件。 + +跨工具共享资源(shell profile env 块、docs 目录、`~/.teamai/`)**仅当该工具自身存在 teamai 资源、且它是最后一个仍在使用 teamai 的工具时**才一并移除,否则会为其余工具保留。没有本地资源的工具即使是唯一的工具,定向卸载也会保留共享资源。Pi、Oh My Pi 和 Hermes 的指令通道位于全局,因此项目级卸载仍会记录对它们的排除设置。 -跨工具共享资源(shell profile env 块、docs 目录、`~/.teamai/`)**仅当该工具自身存在 teamai 资源、且它是最后一个仍在使用 teamai 的工具时**才一并移除,否则会为其余工具保留。(因此,定向卸载一个自身没有任何 teamai 资源的工具是 no-op,即便它恰好是唯一的工具,也不会删除共享资源。) +若删除 teamai 添加的 OpenCode 条目失败,卸载以错误状态退出,并保留共享数据目录和所有权记录,即使 OpenCode 是最后一个工具。修复配置或权限后,重试同一卸载命令。 -若删除 teamai 添加的 OpenCode 条目失败,只要共享数据目录仍在,其所有权记录就会保留。修复配置或权限后,重试 `teamai uninstall --agent opencode`。 +项目级卸载保留 Pi 和 Oh My Pi 的全局扩展,以及 Hermes 的全局插件和配置,其他项目仍会使用它们。单工具卸载在项目配置仍保留时,将该工具加入此项目的排除列表。用户级卸载才移除这些全局投递通道。 该排除是持久的:`uninstall --agent ` 会把该工具从 `enabledAgents` 移除并记入 `disabledAgents`,因此之后的 `pull`(或其他工具的 session-start hook)不会再把它的 skills、rules、agents、团队指令块或 hooks 重新装回。重新执行 `init --agent ` 会清除该排除、恢复对该工具的同步。 diff --git a/skill-data/core/references/troubleshooting.md b/skill-data/core/references/troubleshooting.md index 159c029bb..4b6fdc62b 100644 --- a/skill-data/core/references/troubleshooting.md +++ b/skill-data/core/references/troubleshooting.md @@ -226,3 +226,15 @@ SessionEnd, or at the next `teamai pull`. - `teamai status` shows exactly how local differs from the team repo. - Report unexpected behavior at https://github.com/Tencent/teamai-cli/issues with the agent name, platform, and the step that failed. + +## "Pull left an instruction file unchanged" + +If pull reports incomplete TeamAI markers, it keeps the entire file unchanged. +Fix the named block so it has exactly one start marker followed by one end +marker, then run `teamai pull` again. Other files can still sync successfully. + +Pull keeps retired instruction blocks until every installed tool that wrote the +file has a working replacement. Repair the named target, extension or plugin +and run `teamai pull` again. Excluded tools' retired files stay unchanged. +An HTTP prompt sync that cannot clean retired blocks reports a failed ACK and +keeps its previous cache and manifest for the server's retry. diff --git a/skill-data/setup/references/uninstall.md b/skill-data/setup/references/uninstall.md index ad77a0a85..a0899239f 100644 --- a/skill-data/setup/references/uninstall.md +++ b/skill-data/setup/references/uninstall.md @@ -64,8 +64,10 @@ and give it your team repo URL."* Without that record, it keeps the copy and names it in a warning. Save any changes you need, then delete the copy manually. - If an OpenCode config entry cannot be removed, repair its config or permissions - and retry `teamai uninstall --agent opencode`. Its ownership record stays - while the shared data directory survives. + and retry the same uninstall command. Uninstall reports failure and keeps + its ownership record and shared data directory, even for the last tool. +- Project uninstall keeps the global Pi and Oh My Pi extensions and Hermes + plugin and config for other projects. User-scope uninstall removes them. - Do **not** delete the team repo on the Git platform — uninstall never touches it, and neither should you. - If the user only wants to stop auto-sync for one tool but keep TeamAI otherwise, diff --git a/src/__tests__/e2e/instruction-targets.test.ts b/src/__tests__/e2e/instruction-targets.test.ts index 8a155dd80..73ff153e6 100644 --- a/src/__tests__/e2e/instruction-targets.test.ts +++ b/src/__tests__/e2e/instruction-targets.test.ts @@ -1,5 +1,6 @@ import { afterEach, beforeAll, describe, expect, it } from 'vitest'; import { execFileSync, spawn } from 'node:child_process'; +import { createServer } from 'node:http'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; @@ -512,6 +513,81 @@ describe('instruction block targets on real CLI pull (#945)', () => { expect(context).not.toContain('teamai-recall'); }); + it('retains Claude legacy instructions until a foreign replacement is repaired', async () => { + const sandbox = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'teamai-945-migration-'))); + sandboxes.push(sandbox); + const member = makeProjectMember(sandbox, makeTeamAndProject(sandbox), 'dev', 'developer', ['.claude/skills']); + const legacy = path.join(member.projectRoot, '.claude', 'CLAUDE.md'); + const original = `# Mine\n${CLAUDEMD_START}\nworking old prompt\n${CLAUDEMD_END}\n`; + fs.writeFileSync(legacy, original); + const replacement = path.join(member.projectRoot, '.claude', 'rules', 'teamai-context.md'); + fs.mkdirSync(path.dirname(replacement), { recursive: true }); + fs.writeFileSync(replacement, '# Foreign file\n'); + const blocked = await pullAs(member); + expect(blocked.code, blocked.output).toBe(0); + expect(fs.readFileSync(legacy, 'utf8')).toBe(original); + console.log('pull with foreign Claude target: old working prompt retained'); + fs.unlinkSync(replacement); + // Exercise the unchanged-revision path as well as the first full pull. + const retry = await runCLI(['pull'], { HOME: member.home }, member.projectRoot); + expect(retry.code, retry.output).toBe(0); + expect(fs.readFileSync(replacement, 'utf8')).toContain('DEVELOPMENT-SENTINEL'); + expect(fs.readFileSync(legacy, 'utf8')).toBe('# Mine\n'); + console.log('pull after repair: replacement delivered, old prompt removed'); + }); + + it('retains Pi legacy instructions until its extension is ready, and protects exclusions', async () => { + const sandbox = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'teamai-945-migration-'))); + sandboxes.push(sandbox); + const member = makeProjectMember(sandbox, makeTeamAndProject(sandbox), 'pm', 'product', ['.pi/skills']); + const extension = path.join(member.home, '.pi', 'agent', 'extensions', 'teamai-hooks.ts'); + fs.mkdirSync(path.dirname(extension), { recursive: true }); + fs.writeFileSync(extension, '// Foreign extension\n'); + const legacy = path.join(member.projectRoot, 'AGENTS.md'); + const original = `${PROJECT_AGENTS_MD}${CLAUDEMD_START}\nworking old prompt\n${CLAUDEMD_END}\n`; + fs.writeFileSync(legacy, original); + const blocked = await pullAs(member); + expect(blocked.code, blocked.output).toBe(0); + expect(fs.readFileSync(legacy, 'utf8')).toBe(original); + console.log('pull with foreign Pi extension: old working prompt retained'); + fs.unlinkSync(extension); + const configFile = memberData(member).config; + const config = fs.readFileSync(configFile, 'utf8'); + fs.writeFileSync(configFile, `${config}\ndisabledAgents: [pi]\n`); + const excluded = await pullAs(member); + expect(excluded.code, excluded.output).toBe(0); + expect(fs.existsSync(extension)).toBe(false); + expect(fs.readFileSync(legacy, 'utf8')).toBe(original); + console.log('pull with Pi excluded: extension and legacy instructions unchanged'); + fs.writeFileSync(configFile, config); + const repaired = await runCLI(['pull'], { HOME: member.home }, member.projectRoot); + expect(repaired.code, repaired.output).toBe(0); + expect(fs.readFileSync(extension, 'utf8')).toContain('hook-dispatch'); + expect(fs.readFileSync(legacy, 'utf8')).toBe(PROJECT_AGENTS_MD); + console.log('pull with Pi enabled: extension installed before old prompt removed'); + }); + + it('uninstalls Pi from one project while keeping delivery to another project on the same machine', async () => { + const sandbox = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'teamai-945-uninstall-'))); + sandboxes.push(sandbox); + const fixture = makeTeamAndProject(sandbox); + const developer = makeProjectMember(sandbox, fixture, 'dev', 'developer', ['.pi/skills']); + const product = { ...makeProjectMember(sandbox, fixture, 'pm', 'product', ['.pi/skills']), home: developer.home }; + fs.mkdirSync(path.join(developer.home, '.pi'), { recursive: true }); + for (const member of [developer, product]) { + const result = await pullAs(member); + expect(result.code, result.output).toBe(0); + } + const extension = path.join(developer.home, '.pi', 'agent', 'extensions', 'teamai-hooks.ts'); + const before = fs.readFileSync(extension, 'utf8'); + const removed = await runCLI(['uninstall', '--force', '--agent', 'pi'], { HOME: developer.home }, developer.projectRoot); + expect(removed.code, removed.output).toBe(0); + expect(fs.readFileSync(extension, 'utf8')).toBe(before); + expect(await sessionInstructions('pi', product.home, product.projectRoot)).toContain('PRODUCT-SENTINEL'); + expect(await sessionInstructions('pi', developer.home, developer.projectRoot)).toBe(''); + console.log('uninstall Pi from project A: global extension unchanged; project B instructions still delivered; project A empty'); + }); + it('gives Hermes its project blocks through its plugin and frees the project AGENTS.md', async () => { const sandbox = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'teamai-945-e2e-'))); sandboxes.push(sandbox); @@ -809,12 +885,14 @@ describe('instruction block targets on real CLI pull (#945)', () => { const member = makeProjectMember(sandbox, makeTeamAndProject(sandbox), 'dev', 'developer', ['.opencode/skills']); const contextFile = path.join(member.projectRoot, '.opencode', 'teamai-context.md'); const config = path.join(member.projectRoot, '.opencode', 'opencode.json'); - fs.writeFileSync(contextFile, `${CLAUDEMD_START}\nPRODUCT-STALE\n`); + const malformed = `${CULTURE_START}\nOLD-CULTURE\n${CULTURE_END}\n\n${CLAUDEMD_START}\nPRODUCT-STALE\n`; + fs.writeFileSync(contextFile, malformed); fs.writeFileSync(config, JSON.stringify({ instructions: ['docs/style.md'] })); for (const args of [['--dry-run'], []]) { const pull = await pullAs(member, args); expect(pull.code, pull.output).toBe(0); expect(pull.output).toContain('incomplete teamai claudemd block'); + expect(fs.readFileSync(contextFile, 'utf8')).toBe(malformed); expect(JSON.parse(fs.readFileSync(config, 'utf8')).instructions).toEqual(['docs/style.md']); console.log(`pull ${args.join(' ')}: malformed target warned; OpenCode instructions=${JSON.stringify(JSON.parse(fs.readFileSync(config, 'utf8')).instructions)}`); } @@ -828,6 +906,72 @@ describe('instruction block targets on real CLI pull (#945)', () => { console.log('pull after repair: DEVELOPMENT=1 PRODUCT-STALE=0; OpenCode instructions=["docs/style.md",".opencode/teamai-context.md"]'); }); + it('reports OpenCode delivery only after its config lists the replacement', async () => { + const sandbox = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'teamai-945-registration-'))); + sandboxes.push(sandbox); + const member = makeProjectMember(sandbox, makeTeamAndProject(sandbox), 'dev', 'developer', ['.opencode/skills']); + const config = path.join(member.projectRoot, '.opencode', 'opencode.json'); + fs.writeFileSync(config, '{ invalid JSON'); + const blocked = await pullAs(member); + expect(blocked.code, blocked.output).toBe(0); + expect(blocked.output).not.toContain('Synced team culture'); + expect(blocked.output).not.toContain('Synced shared instructions'); + expect(fs.readFileSync(config, 'utf8')).toBe('{ invalid JSON'); + expect(fs.readFileSync(path.join(member.projectRoot, '.opencode', 'teamai-context.md'), 'utf8')).toContain('DEVELOPMENT-SENTINEL'); + console.log('pull with malformed OpenCode config: replacement file written; delivery not reported as successful'); + fs.writeFileSync(config, '{}'); + const repaired = await runCLI(['pull'], { HOME: member.home }, member.projectRoot); + expect(repaired.code, repaired.output).toBe(0); + expect(repaired.output).toContain('Synced shared instructions'); + expect(JSON.parse(fs.readFileSync(config, 'utf8')).instructions).toEqual(['.opencode/teamai-context.md']); + console.log('pull after OpenCode config repair: entry listed; delivery reported as successful'); + }); + + it('includes a freshly downloaded HTTP prompt in the first real Codex SessionStart', async () => { + const sandbox = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'teamai-945-first-http-'))); + sandboxes.push(sandbox); + const member = makeProjectMember(sandbox, makeTeamAndProject(sandbox), 'dev', 'developer', []); + fs.mkdirSync(path.join(member.home, '.codex', 'skills'), { recursive: true }); + const prepared = await pullAs(member); + expect(prepared.code, prepared.output).toBe(0); + let endpoint = ''; + const acks: Array<{ status: string }> = []; + const server = createServer((request, response) => { + let body = ''; + request.on('data', (chunk: Buffer) => { body += chunk.toString(); }); + request.on('end', () => { + if (request.url?.endsWith('/first.md')) { + setTimeout(() => response.end('FIRST-HTTP-PROMPT-SENTINEL'), 100); + return; + } + response.setHeader('Content-Type', 'application/json'); + if (request.url?.endsWith('/commands/ack')) acks.push(JSON.parse(body)); + response.end(JSON.stringify(request.url?.endsWith('/local-agent/sync') ? { + ok: true, cmds: [{ id: 1, type: 'install_prompt_rule', handle_type: 'prompt', slug: 'first', version: '1', + scope: 'workspace', workspace_path: member.projectRoot, download_url: `${endpoint}/first.md`, + }], + } : { ok: true })); + }); + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + endpoint = `http://127.0.0.1:${(server.address() as { port: number }).port}`; + try { + const agentDir = path.join(member.home, '.teamai', 'local-agent'); + fs.mkdirSync(agentDir, { recursive: true }); + fs.writeFileSync(path.join(agentDir, 'config.json'), JSON.stringify({ endpoint, token: 'fixture-token', + localAgentId: 'fixture', createdAt: '2026-01-01T00:00:00.000Z', workspaceBindings: {}, + })); + const first = await runCLI(['hook-dispatch', 'session-start', '--tool', 'codex'], { HOME: member.home }, member.projectRoot, + JSON.stringify({ cwd: member.projectRoot, session_id: 'first-http', hook_event_name: 'SessionStart', source: 'startup' })); + expect(first.code, first.output).toBe(0); + expect(first.stdout).toContain('FIRST-HTTP-PROMPT-SENTINEL'); + expect(acks).toEqual([expect.objectContaining({ status: 'success' })]); + console.log('real Codex SessionStart: empty HTTP cache → download ACK success → FIRST-HTTP-PROMPT-SENTINEL in first output'); + } finally { + await new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve())); + } + }); + it('drops OpenCode\'s instructions entry on uninstall even when the member\'s text keeps the file', async () => { const sandbox = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'teamai-945-e2e-'))); sandboxes.push(sandbox); diff --git a/src/__tests__/helpers/pi-extensions.ts b/src/__tests__/helpers/pi-extensions.ts index 6e41777f4..a8a04d35b 100644 --- a/src/__tests__/helpers/pi-extensions.ts +++ b/src/__tests__/helpers/pi-extensions.ts @@ -69,8 +69,9 @@ export function loadOmpExtension(stdoutFor: (args: string[]) => string = () => ' const $ = (_strings: TemplateStringsArray, args: string[], stdin: Response) => { const run = (async () => { dispatches.push({ args, payload: JSON.parse(await stdin.text()) as Record }); + return stdoutFor(args); })(); - const output = Object.assign(run, { text: async () => { await run; return stdoutFor(args); } }); + const output = Object.assign(run, { text: async () => run }); return { quiet: () => ({ nothrow: () => output }) }; }; const on = register(buildOmpExtensionSource(), { $, Response, fs, path, Buffer }); diff --git a/src/__tests__/instruction-targets.test.ts b/src/__tests__/instruction-targets.test.ts index b33bc12f9..b06683012 100644 --- a/src/__tests__/instruction-targets.test.ts +++ b/src/__tests__/instruction-targets.test.ts @@ -10,6 +10,7 @@ import { instructionChannelProblems, planInstructionFiles, registerOpencodeContext, + retiredFilesOfReached, resolveInstructionTargets, type InstructionTarget, } from '../instruction-targets.js'; @@ -86,6 +87,43 @@ describe('instruction file planning (#945)', () => { expect(plan.changes).toEqual([]); }); + it('reports each file outcome independently of paths mentioned in warnings', async () => { + const ready = target('ready.md'); + const blocked = target('ready.md.blocked'); + const failed = target('failed.md'); + const removed = target('removed.md'); + fs.writeFileSync(ready.path, `${culture('current')}\n`); + fs.writeFileSync(blocked.path, `${TEAMAI_CULTURE_START}\n${ready.path}\n`); + fs.writeFileSync(failed.path, `${culture('old')}\n`); + fs.writeFileSync(removed.path, `${culture('old')}\n`); + const plan = await planInstructionFiles([ready, blocked, failed], { culture: culture('current') }, [removed]); + const write = vi.spyOn(fse, 'writeFile').mockRejectedValueOnce(new Error('EACCES')); + try { + const { files } = await applyInstructionPlan(plan, { dryRun: false }); + expect(files).toEqual([ + { path: ready.path, status: 'current' }, + { path: blocked.path, status: 'blocked' }, + { path: failed.path, status: 'failed' }, + { path: removed.path, status: 'removed' }, + ]); + expect(fs.readFileSync(ready.path, 'utf8')).toBe(`${culture('current')}\n`); + expect(fs.readFileSync(failed.path, 'utf8')).toBe(`${culture('old')}\n`); + expect(fs.existsSync(removed.path)).toBe(false); + } finally { + write.mockRestore(); + } + }); + + it('keeps the whole file unchanged when one requested block is malformed', async () => { + const file = target('mixed.md'); + const original = `${culture('old')}\n\n${TEAMAI_CLAUDEMD_START}\nold selection\n`; + fs.writeFileSync(file.path, original); + const plan = await planInstructionFiles([file], { culture: culture('current'), claudemd: claudemd('current') }); + const { files } = await applyInstructionPlan(plan, { dryRun: false }); + expect(files).toEqual([{ path: file.path, status: 'blocked' }]); + expect(fs.readFileSync(file.path, 'utf8')).toBe(original); + }); + it('leaves a block with a missing end marker intact and warns', async () => { const file = path.join(dir, 'AGENTS.md'); const original = `# Project\n\n${TEAMAI_CLAUDEMD_START}\nold selection\n`; @@ -136,7 +174,7 @@ describe('instruction file planning (#945)', () => { fs.writeFileSync(file, original); const plan = await planInstructionFiles([target('CLAUDE.local.md')], { culture: culture('c') }, [target('AGENTS.md', { tools: [] })]); - const { report } = await applyInstructionPlan(plan, { dryRun: true }); + const { report, files } = await applyInstructionPlan(plan, { dryRun: true }); expect(fs.readFileSync(file, 'utf8')).toBe(original); expect(fs.existsSync(path.join(dir, 'CLAUDE.local.md'))).toBe(false); @@ -144,6 +182,10 @@ describe('instruction file planning (#945)', () => { `Would write teamai instruction blocks to ${path.join(dir, 'CLAUDE.local.md')}`, `Would remove teamai instruction blocks from ${file}`, ]); + expect(files).toEqual([ + { path: path.join(dir, 'CLAUDE.local.md'), status: 'would-write' }, + { path: file, status: 'would-write' }, + ]); }); it('keeps a same-named file teamai does not own and reports it', async () => { @@ -217,6 +259,54 @@ describe('instruction channel problems (#945)', () => { }); describe('instruction targets shared by several tools (#945)', () => { + it('retires a shared file only when all its installed former writers reached their replacements', async () => { + const root = fs.realpathSync.native(fs.mkdtempSync(path.join(os.tmpdir(), 'teamai-945-writers-'))); + vi.stubEnv('HOME', path.join(root, 'home')); + try { + const projectRoot = path.join(root, 'project'); + fs.mkdirSync(path.join(root, 'home', '.pi'), { recursive: true }); + fs.mkdirSync(path.join(projectRoot, '.claude'), { recursive: true }); + const localConfig = { + repo: { localPath: path.join(root, 'repo'), remote: 'https://example.invalid/t.git' }, + username: 'u', additionalRoles: [], scope: 'project', projectRoot, + } as unknown as LocalConfig; + const teamConfig = TeamaiConfigSchema.parse({ team: 't', repo: 'https://example.invalid/t.git', toolPaths: { + claude: { rules: '.claude/rules', claudemd: 'AGENTS.md' }, pi: { claudemd: 'AGENTS.md' }, + } }); + const legacy = path.join(projectRoot, 'AGENTS.md'); + expect((await retiredFilesOfReached(teamConfig, localConfig, ['claude'])).map((target) => target.path)).not.toContain(legacy); + expect((await retiredFilesOfReached(teamConfig, localConfig, ['pi'])).map((target) => target.path)).not.toContain(legacy); + expect((await retiredFilesOfReached(teamConfig, localConfig, ['claude', 'pi'])).map((target) => target.path)).toContain(legacy); + } finally { + vi.unstubAllEnvs(); + fs.rmSync(root, { recursive: true, force: true }); + } + }); + + it.each(['pi', 'omp', 'hermes', 'codex'])('protects retired files of excluded project hook tool %s', async (tool) => { + const root = fs.realpathSync.native(fs.mkdtempSync(path.join(os.tmpdir(), 'teamai-945-excluded-'))); + vi.stubEnv('HOME', path.join(root, 'home')); + vi.stubEnv('HERMES_HOME', path.join(root, 'home', '.hermes')); + try { + fs.mkdirSync(path.join(root, 'home', `.${tool}`), { recursive: true }); + const projectRoot = path.join(root, 'project'); + const localConfig = { + repo: { localPath: path.join(root, 'repo'), remote: 'https://example.invalid/t.git' }, + username: 'u', additionalRoles: [], scope: 'project', projectRoot, disabledAgents: [tool], + } as unknown as LocalConfig; + const teamConfig = TeamaiConfigSchema.parse({ team: 't', repo: 'https://example.invalid/t.git', + toolPaths: { [tool]: { settings: `.${tool}/hooks.json`, claudemd: 'AGENTS.md' } }, + }); + const { targets, hooks, stale } = await resolveInstructionTargets(teamConfig, localConfig); + expect(targets).toEqual([]); + expect(hooks).toEqual([]); + expect(stale.map((target) => target.path)).not.toContain(path.join(projectRoot, 'AGENTS.md')); + } finally { + vi.unstubAllEnvs(); + fs.rmSync(root, { recursive: true, force: true }); + } + }); + it('gives a shared file the subagent recall block only when every tool reading it has the subagent', async () => { const root = fs.realpathSync.native(fs.mkdtempSync(path.join(os.tmpdir(), 'teamai-945-shared-'))); try { @@ -365,8 +455,8 @@ describe('OpenCode instructions registration (#945)', () => { const resolved = await resolveInstructionTargets(teamConfig, localConfig); const plan = await planInstructionFiles(resolved.targets, { claudemd: claudemd('desired') }); if (state === 'write failure') vi.spyOn(fse, 'writeFile').mockRejectedValueOnce(new Error('EACCES')); - const { failures } = await applyInstructionPlan(plan, { dryRun: false }); - await registerOpencodeContext(teamConfig, localConfig, resolved, false, [], [...plan.warnings, ...failures]); + const { files } = await applyInstructionPlan(plan, { dryRun: false }); + await registerOpencodeContext(teamConfig, localConfig, resolved, false, files); const config = path.join(projectRoot, '.opencode', 'opencode.json'); if (state === 'current' || state === 'written') { @@ -398,8 +488,11 @@ describe('OpenCode instructions registration (#945)', () => { const teamConfig = TeamaiConfigSchema.parse({ team: 't', repo: 'https://example.invalid/t.git' }); const resolved = await resolveInstructionTargets(teamConfig, localConfig); - expect(await registerOpencodeContext(teamConfig, localConfig, resolved, true)).toBeNull(); - expect(await registerOpencodeContext(teamConfig, localConfig, resolved, false)).toBeNull(); + const plan = await planInstructionFiles(resolved.targets, { claudemd: claudemd('desired') }); + for (const dryRun of [true, false]) { + const { files } = await applyInstructionPlan(plan, { dryRun }); + expect(await registerOpencodeContext(teamConfig, localConfig, resolved, dryRun, files)).toBeNull(); + } expect(fs.readFileSync(config, 'utf8')).toBe(listed); } finally { fs.rmSync(root, { recursive: true, force: true }); @@ -477,17 +570,19 @@ describe('OpenCode instructions ownership (#945)', () => { const teamConfig = TeamaiConfigSchema.parse({ team: 't', repo: 'https://example.invalid/t.git' }); const { loadStateForScope } = await import('../config.js'); - await registerOpencodeContext(teamConfig, localConfig, await resolveInstructionTargets(teamConfig, localConfig), false); + const initial = await resolveInstructionTargets(teamConfig, localConfig); + const { files } = await applyInstructionPlan(await planInstructionFiles(initial.targets, { claudemd: claudemd('team') }), { dryRun: false }); + await registerOpencodeContext(teamConfig, localConfig, initial, false, files); expect((await loadStateForScope(localConfig)).opencodeContextEntries).toEqual([{ config, entry: '.opencode/teamai-context.md' }]); fs.rmSync(context); const resolved = await resolveInstructionTargets(teamConfig, localConfig); - await registerOpencodeContext(teamConfig, localConfig, { targets: [], stale: resolved.targets }, false); + await registerOpencodeContext(teamConfig, localConfig, { targets: [], stale: resolved.targets }, false, []); expect((await loadStateForScope(localConfig)).opencodeContextEntries).toEqual([]); // An entry the member listed, which teamai did not record, stays. fs.writeFileSync(config, JSON.stringify({ instructions: ['.opencode/teamai-context.md'] })); - await registerOpencodeContext(teamConfig, localConfig, { targets: [], stale: resolved.targets }, false); + await registerOpencodeContext(teamConfig, localConfig, { targets: [], stale: resolved.targets }, false, []); expect(JSON.parse(fs.readFileSync(config, 'utf8')).instructions).toEqual(['.opencode/teamai-context.md']); } finally { process.env.HOME = prevHome; diff --git a/src/__tests__/local-agent.test.ts b/src/__tests__/local-agent.test.ts index 876c3ecb8..b05ca075c 100644 --- a/src/__tests__/local-agent.test.ts +++ b/src/__tests__/local-agent.test.ts @@ -1732,6 +1732,42 @@ describe('local-agent: cmds[] migration', () => { expect((await fse.readJson(manifestFile)).scopes.user.claudemd['doc-a']).toBeUndefined(); }); + it.each([ + ['install', 'malformed'], ['remove', 'malformed'], ['install', 'write failure'], ['remove', 'write failure'], + ])('fails the HTTP %s ACK and retains its manifest when retired cleanup encounters %s (#945)', async (operation, failure) => { + const command = { + id: 64, type: 'install_prompt_rule', handle_type: 'prompt', slug: 'doc-a', + version: '1.0.0', download_url: 'http://127.0.0.1:42100/doc-a.md', scope: 'user', + }; + if (operation === 'remove') await runResponse({ cmds: [command] }); + const legacy = path.join(tmpDir, 'AGENTS.md'); + const retained = `# My notes\n${TEAMAI_CLAUDEMD_START}\nold instructions\n${failure === 'malformed' ? '' : '\n'}`; + await fse.writeFile(legacy, retained); + if (failure === 'write failure') { + const originalWrite = fse.writeFile.bind(fse); + vi.spyOn(fse, 'writeFile').mockImplementation((...args: Parameters) => { + if (args[0] === legacy) return Promise.reject(new Error('EACCES')); + return originalWrite(...args); + }); + } + const acks = await runResponse({ cmds: [{ ...command, id: 65, + ...(operation === 'remove' ? { type: 'uninstall_prompt_rule' } : {}), + }] }); + expect(acks.find((ack) => ack.id === 65)?.status).toBe('failed'); + expect(await fse.readFile(legacy, 'utf8')).toBe(retained); + const manifestFile = path.join(tmpDir, '.teamai', 'local-agent', 'manifest.json'); + const manifest = await fse.pathExists(manifestFile) ? await fse.readJson(manifestFile) : null; + expect(Boolean(manifest?.scopes.user?.claudemd?.['doc-a'])).toBe(operation === 'remove'); + const cache = path.join(tmpDir, '.teamai', 'local-agent', 'resources', 'user', 'claudemd', 'doc-a.md'); + expect(await fse.pathExists(cache)).toBe(operation === 'remove'); + vi.restoreAllMocks(); + await fse.writeFile(legacy, '# My notes\n'); + const retry = await runResponse({ cmds: [{ ...command, id: 66, + ...(operation === 'remove' ? { type: 'uninstall_prompt_rule' } : {}), + }] }); + expect(retry.find((ack) => ack.id === 66)?.status).toBe('success'); + }); + // Codex's default `claudemd` (#938) makes a Codex report a target of this sync. it('handle_type=prompt from Codex writes the prompt into ~/.codex/AGENTS.md when ~/.codex exists', async () => { await fse.ensureDir(path.join(tmpDir, '.codex')); @@ -2103,7 +2139,7 @@ describe('local-agent: per-worktree claudemd isolation (issue #374 P1-2C)', () = }); describe('local-agent: project prompts reach every installed tool (#945)', () => { - async function installProjectPrompt(tool: string, toolDirs: string[], options: { prompt?: string; files?: Record } = {}) { + async function installProjectPrompt(tool: string, toolDirs: string[], options: { prompt?: string; files?: Record; sessionStart?: boolean } = {}) { const { execFileSync } = await import('node:child_process'); const repo = path.join(tmpDir, 'project'); await fse.ensureDir(repo); @@ -2131,9 +2167,18 @@ describe('local-agent: project prompts reach every installed tool (#945)', () => if (url.includes('/commands/ack')) acks.push(JSON.parse(init?.body ?? '{}')); return new Response(JSON.stringify({ ok: true })); })); - const { reportAndSyncLocalAgent } = await import('../local-agent.js'); - await reportAndSyncLocalAgent({ cwd: repo, tool, status: 'running' }); - return { repo, ack: acks.find((a) => a.id === 201) }; + let output: string | null = null; + if (options.sessionStart) { + const { createDispatcher } = await import('../hook-dispatch.js'); + const { buildHandlerRegistry, filterHandlersForConfig } = await import('../hook-handlers.js'); + const dispatcher = createDispatcher({ localConfig: null, handlers: filterHandlersForConfig(buildHandlerRegistry(), null) }); + const result = await dispatcher.dispatch('session-start', '*', { cwd: repo, hook_event_name: 'SessionStart', source: 'startup' }, tool, 'foreground'); + output = result.output; + } else { + const { reportAndSyncLocalAgent } = await import('../local-agent.js'); + await reportAndSyncLocalAgent({ cwd: repo, tool, status: 'running' }); + } + return { repo, ack: acks.find((a) => a.id === 201), output }; } it('installs a WorkBuddy project prompt where .codebuddy/ does not exist', async () => { @@ -2171,6 +2216,8 @@ describe('local-agent: project prompts reach every installed tool (#945)', () => it('gives Codex a project prompt through its SessionStart and SubagentStart hooks', async () => { // Codex is installed for the member (~/.codex), not in the project. await fse.ensureDir(path.join(tmpDir, '.codex', 'skills')); + const { reconcileHooks } = await import('../hooks.js'); + await reconcileHooks(path.join(tmpDir, '.codex', 'hooks.json'), 'codex', []); const { repo, ack } = await installProjectPrompt('codex', []); expect(await fse.pathExists(path.join(repo, '.codex'))).toBe(false); @@ -2185,6 +2232,15 @@ describe('local-agent: project prompts reach every installed tool (#945)', () => } }); + it('includes a newly downloaded HTTP prompt in Codex\'s first SessionStart output', async () => { + await fse.ensureDir(path.join(tmpDir, '.codex', 'skills')); + const { reconcileHooks } = await import('../hooks.js'); + await reconcileHooks(path.join(tmpDir, '.codex', 'hooks.json'), 'codex', []); + const { ack, output } = await installProjectPrompt('codex', [], { sessionStart: true }); + expect(ack?.status).toBe('success'); + expect(output).toContain('PROJECT-PROMPT'); + }); + it('records the OpenCode entry it adds in the project\'s data home, where uninstall reads it', async () => { // A partitioned project: its data home is under ~/.teamai/projects. const { projectSlug } = await import('../utils/partition.js'); diff --git a/src/__tests__/omp-hooks.test.ts b/src/__tests__/omp-hooks.test.ts index 48113ff8f..87a102114 100644 --- a/src/__tests__/omp-hooks.test.ts +++ b/src/__tests__/omp-hooks.test.ts @@ -221,6 +221,17 @@ describe('OMP extension: team instructions in the system prompt (#945)', () => { expect(dispatches.find((d) => d.args[1] === 'instructions')?.payload).toEqual({ cwd: '/work/proj/src', session_id: 'omp-main' }); }); + it('waits for session-start HTTP sync before reading the first prompt cache', async () => { + let synced = false; + const { on } = loadOmpExtension((args) => { + if (args[1] === 'session-start') synced = true; + return args[1] === 'instructions' ? context(synced ? 'FRESH-HTTP-PROMPT' : '')(args) : ''; + }); + await on.session_start({}, ctx); + expect(await on.before_agent_start({ prompt: 'one', systemPrompt: ['BASE'] }, ctx)) + .toEqual({ systemPrompt: ['BASE', 'FRESH-HTTP-PROMPT'] }); + }); + it('leaves the system prompt alone when there are no blocks (user scope, or teamai unavailable)', async () => { const { on } = loadOmpExtension(); await on.session_start({}, ctx); diff --git a/src/__tests__/pi-hooks.test.ts b/src/__tests__/pi-hooks.test.ts index 50ac0e213..219d4ae27 100644 --- a/src/__tests__/pi-hooks.test.ts +++ b/src/__tests__/pi-hooks.test.ts @@ -393,6 +393,17 @@ describe('Pi extension: team instructions in the system prompt (#945)', () => { await on.session_start({}, ctx); expect(await on.before_agent_start({ prompt: 'one', systemPrompt: 'BASE' }, ctx)).toBeUndefined(); }); + + it('waits for session-start HTTP sync before reading the first prompt cache', async () => { + let synced = false; + const { on } = loadPiExtension((args) => { + if (args[1] === 'session-start') synced = true; + return args[1] === 'instructions' ? context(synced ? 'FRESH-HTTP-PROMPT' : '')(args) : ''; + }); + await on.session_start({}, ctx); + expect(await on.before_agent_start({ prompt: 'one', systemPrompt: 'BASE' }, ctx)) + .toEqual({ systemPrompt: 'BASE\n\nFRESH-HTTP-PROMPT' }); + }); }); describe('Pi extension: bridge payloads (#884)', () => { diff --git a/src/__tests__/recall-toggle.test.ts b/src/__tests__/recall-toggle.test.ts index 45179ef59..9027dc23f 100644 --- a/src/__tests__/recall-toggle.test.ts +++ b/src/__tests__/recall-toggle.test.ts @@ -28,6 +28,7 @@ vi.mock('../utils/logger.js', () => ({ })); import { recallDisable, recallEnable } from '../recall-toggle.js'; +import { loadStateForScope, saveStateForScope } from '../config.js'; import { TeamaiConfigSchema, TEAMAI_RECALL_RULES_START } from '../types.js'; import type { LocalConfig, TeamaiConfig } from '../types.js'; @@ -121,6 +122,26 @@ describe('recall toggle native agent cleanup', () => { expect(await fse.readFile(backup, 'utf8')).toBe('user backup'); }); + it('keeps OpenCode registered when malformed recall markers block an edit of a file with other instructions', async () => { + const projectRoot = path.join(tmpDir, 'project'); + const configFile = path.join(projectRoot, '.opencode', 'opencode.json'); + const entry = '.opencode/teamai-context.md'; + const contextFile = path.join(projectRoot, entry); + const original = `\nCulture\n\n${TEAMAI_RECALL_RULES_START}\nIncomplete recall\n`; + await fse.outputFile(contextFile, original); + await fse.outputJson(configFile, { instructions: [entry] }); + const localConfig = { repo: { localPath: path.join(tmpDir, 'team-repo'), remote: 'https://example.invalid/t.git' }, + username: 'u', scope: 'project', projectRoot, enabledAgents: ['opencode'], additionalRoles: [], + } as LocalConfig; + const teamConfig = TeamaiConfigSchema.parse({ team: 't', repo: 'https://example.invalid/t.git' }); + mockAutoDetectInit.mockResolvedValue({ localConfig, teamConfig }); + await saveStateForScope({ ...await loadStateForScope(localConfig), opencodeContextEntries: [{ config: configFile, entry }] }, localConfig); + await recallDisable({}); + expect(await fse.readFile(contextFile, 'utf8')).toBe(original); + expect((await fse.readJson(configFile)).instructions).toEqual([entry]); + expect((await loadStateForScope(localConfig)).opencodeContextEntries).toEqual([{ config: configFile, entry }]); + }); + it('uses custom COPILOT_HOME for recall injection and cleanup', async () => { const copilotHome = path.join(tmpDir, 'copilot-home'); const instructionPath = path.join(copilotHome, 'copilot-instructions.md'); diff --git a/src/__tests__/uninstall.test.ts b/src/__tests__/uninstall.test.ts index d849c3bca..bd5932113 100644 --- a/src/__tests__/uninstall.test.ts +++ b/src/__tests__/uninstall.test.ts @@ -472,11 +472,7 @@ describe('uninstall', () => { expect(bashrcAfter).not.toContain(TEAMAI_ENV_START); }); - it('project-scope Pi uninstall also removes the global extension', async () => { - // Mirrors OMP: Pi has no way to scope its single shared extension to one - // project — the generated extension fires for every Pi session - // machine-wide — so a scoped uninstall removes it outright rather than - // preserving a file that would keep firing for this project anyway. + it('project-scope Pi uninstall preserves the global extension and removes a legacy project copy', async () => { const { homeDir, repoPath } = await setupFixture(tmpDir); const projectRoot = path.join(tmpDir, 'business-repo'); vi.stubEnv('HOME', homeDir); @@ -513,7 +509,67 @@ describe('uninstall', () => { await uninstall({ force: true, agent: 'pi' }); expect(await fse.pathExists(projectPiHook)).toBe(false); - expect(await fse.pathExists(globalPiHook)).toBe(false); + expect(await fse.readFile(globalPiHook, 'utf8')).toBe(TEAMAI_PI_HOOK); + }); + + it('keeps a tracked retired configured claudemd named teamai-context.md as a member file', async () => { + const projectRoot = path.join(tmpDir, 'member-file-project'); + const file = path.join(projectRoot, '.claude', 'teamai-context.md'); + await fse.outputFile(file, `${TEAMAI_CULTURE_START}\nold culture\n${TEAMAI_CULTURE_END}\n`); + execFileSync('git', ['init', '-q'], { cwd: projectRoot }); + execFileSync('git', ['add', '.claude/teamai-context.md'], { cwd: projectRoot }); + const localConfig = makeLocalConfig(projectRoot, path.join(projectRoot, '.teamai', 'team-repo'), { scope: 'project', projectRoot }); + const teamConfig = makeTeamConfig({ toolPaths: { claude: { + skills: '.claude/skills', rules: '.claude/rules', claudemd: '.claude/teamai-context.md', + } } }); + mockAutoDetectInit.mockResolvedValue({ localConfig, teamConfig }); + await uninstall({ force: true, agent: 'claude' }); + expect(await fse.readFile(file, 'utf8')).toBe(''); + }); + + it.each([['omp', true], ['omp', false], ['hermes', true], ['hermes', false]])( + 'project uninstall keeps %s global delivery, targeted: %s', async (tool, targeted) => { + const { homeDir, repoPath } = await setupFixture(tmpDir); + const projectRoot = path.join(tmpDir, 'business-repo'); + vi.stubEnv('HOME', homeDir); + vi.stubEnv('HERMES_HOME', path.join(homeDir, '.hermes')); + await fse.ensureDir(projectRoot); + let globalFile: string; + let configBefore: string | undefined; + if (tool === 'omp') { + const { injectOmpHooks, resolveOmpExtensionsDir, OMP_HOOK_FILE } = await import('../omp-hooks.js'); + await injectOmpHooks(); + globalFile = path.join(resolveOmpExtensionsDir(), OMP_HOOK_FILE); + } else { + const { injectHermesHooks, getInstructionsPluginDir } = await import('../hermes-hooks.js'); + await injectHermesHooks(); + globalFile = path.join(getInstructionsPluginDir(), '__init__.py'); + configBefore = await fse.readFile(path.join(homeDir, '.hermes', 'config.yaml'), 'utf8'); + } + const before = await fse.readFile(globalFile, 'utf8'); + const teamConfig = TeamaiConfigSchema.parse({ team: 't', repo: 'https://example.invalid/t.git' }); + const localConfig = makeLocalConfig(homeDir, repoPath, { scope: 'project', projectRoot }); + mockAutoDetectInit.mockResolvedValue({ localConfig, teamConfig }); + await fse.writeFile(path.join(projectRoot, 'AGENTS.md'), `${TEAMAI_CULTURE_START}\nold\n${TEAMAI_CULTURE_END}\n`); + await uninstall({ force: true, ...(targeted ? { agent: tool as string } : {}) }); + expect(await fse.readFile(globalFile, 'utf8')).toBe(before); + if (configBefore) expect(await fse.readFile(path.join(homeDir, '.hermes', 'config.yaml'), 'utf8')).toBe(configBefore); + }, + ); + + it.each(['pi', 'omp', 'hermes'])('excludes %s from a project with no local files to delete', async (tool) => { + const homeDir = path.join(tmpDir, 'home'); + const projectRoot = path.join(tmpDir, 'project'); + const repoPath = path.join(projectRoot, '.teamai', 'team-repo'); + await fse.ensureDir(repoPath); + vi.stubEnv('HOME', homeDir); + vi.stubEnv('HERMES_HOME', path.join(homeDir, '.hermes')); + const teamConfig = TeamaiConfigSchema.parse({ team: 't', repo: 'https://example.invalid/t.git' }); + const localConfig = makeLocalConfig(homeDir, repoPath, { scope: 'project', projectRoot }); + mockAutoDetectInit.mockResolvedValue({ localConfig, teamConfig }); + await uninstall({ force: true, agent: tool }); + expect(mockSaveLocalConfigForScope).toHaveBeenCalledWith(expect.objectContaining({ disabledAgents: [tool] }), 'project', projectRoot); + expect(await fse.pathExists(repoPath)).toBe(true); }); it('user-scope Pi uninstall removes a server-pushed agent hook even without the main extension', async () => { @@ -1694,12 +1750,15 @@ describe('uninstall', () => { expect((await loadStateForScope(localConfig)).opencodeContextEntries).toEqual([{ config: siblingConfig, entry }]); }); - it.each(['write failure', 'unreadable'])('keeps the OpenCode ownership record for a retry after %s (#945)', async (failure) => { + it.each([ + ['write failure', 'other tool'], ['unreadable', 'other tool'], + ['write failure', 'last tool'], ['write failure', 'full uninstall'], ['unreadable', 'last tool'], + ])('keeps the OpenCode ownership record for a retry after %s during %s (#945)', async (failure, scenario) => { const projectRoot = path.join(tmpDir, 'oc-retry-project'); const repoPath = path.join(projectRoot, '.teamai', 'team-repo'); await fse.ensureDir(path.join(projectRoot, '.opencode', 'skills')); await fse.outputFile(path.join(repoPath, 'skills', 'team-skill', 'SKILL.md'), '# Team Skill'); - await fse.outputFile(path.join(projectRoot, '.claude', 'skills', 'team-skill', 'SKILL.md'), '# Team Skill'); + if (scenario === 'other tool') await fse.outputFile(path.join(projectRoot, '.claude', 'skills', 'team-skill', 'SKILL.md'), '# Team Skill'); const config = path.join(projectRoot, '.opencode', 'opencode.json'); const entry = '.opencode/teamai-context.md'; await fse.outputJson(config, { instructions: [entry] }); @@ -1726,14 +1785,17 @@ describe('uninstall', () => { return originalRead(...args); }); } - await uninstall({ force: true, agent: 'opencode' }); + const previousExitCode = process.exitCode; + await uninstall({ force: true, ...(scenario !== 'full uninstall' ? { agent: 'opencode' } : {}) }); + expect(process.exitCode).toBe(1); + process.exitCode = previousExitCode; expect((await loadStateForScope(localConfig)).opencodeContextEntries).toEqual([ref]); vi.restoreAllMocks(); expect((await fse.readJson(config)).instructions).toEqual([entry]); mockAutoDetectInit.mockResolvedValue({ localConfig, teamConfig }); - await uninstall({ force: true, agent: 'opencode' }); + await uninstall({ force: true, ...(scenario !== 'full uninstall' ? { agent: 'opencode' } : {}) }); expect((await fse.readJson(config)).instructions).toBeUndefined(); - expect((await loadStateForScope(localConfig)).opencodeContextEntries).toEqual([]); + if (scenario === 'other tool') expect((await loadStateForScope(localConfig)).opencodeContextEntries).toEqual([]); }); // A relocated Claude Code root (toolRoots) moves the HOME hook file, but the diff --git a/src/doctor-delivery.ts b/src/doctor-delivery.ts index 21f374a79..bfd408660 100644 --- a/src/doctor-delivery.ts +++ b/src/doctor-delivery.ts @@ -1157,7 +1157,7 @@ export async function buildInstructionDeliveryChecks(ctx: DoctorContext): Promis const { localConfig, teamConfig } = ctx; if (!teamConfig) return []; const { - holdsInstructionBlocks, hookLimitProblem, instructionHookChannel, instructionHookTextFor, instructionTargetPath, + holdsInstructionBlocks, hookLimitProblem, instructionHookChannel, instructionHookText, instructionTargetPath, planInstructionFiles, resolveInstructionTargets, } = await import('./instruction-targets.js'); const { resolveInstructionBlocks } = await import('./pull.js'); @@ -1198,8 +1198,10 @@ export async function buildInstructionDeliveryChecks(ctx: DoctorContext): Promis } for (const hook of hooks) { - const channel = await instructionHookChannel(hook.tool); - const overLimit = hookLimitProblem(hook, await instructionHookTextFor(teamConfig, localConfig, hook.tool)); + const text = instructionHookText(blocks, hook.recall); + if (!text) continue; + const channel = await instructionHookChannel(hook.tool, { teamConfig, localConfig }); + const overLimit = hookLimitProblem(hook, text); checks.push({ name: `${hook.tool} adds the team instructions to its prompt`, source: 'local', diff --git a/src/hook-handlers.ts b/src/hook-handlers.ts index 0dd9cf912..d1f0ba063 100644 --- a/src/hook-handlers.ts +++ b/src/hook-handlers.ts @@ -799,7 +799,13 @@ const localAgentHandler: HookHandler = { name: 'local-agent-sync', async execute(stdin, tool) { const { reportAndSyncFromHook } = await import('./local-agent.js'); - return reportAndSyncFromHook(stdin, tool); + const output = await reportAndSyncFromHook(stdin, tool); + // Codex's first prompt must read the cache after this sync, rather than + // racing it in another handler. SubagentStart reads the parent's cache. + if (stdin.hook_event_name === 'SessionStart') { + return await localAgentInstructionsHandler.execute(stdin, tool, null) ?? output; + } + return output; }, }; @@ -906,7 +912,6 @@ export function buildHandlerRegistry(): HandlerRegistration[] { { event: 'session-start', matcher: '*', handler: pullHandler, timeoutMs: PULL_TIMEOUT_MS, background: true }, { event: 'session-start', matcher: '*', handler: dashboardReportHandler, timeoutMs: FOREGROUND_HOOK_TIMEOUT_MS, requiresConfig: true }, { event: 'session-start', matcher: '*', handler: teamRulesHandler, timeoutMs: FOREGROUND_HOOK_TIMEOUT_MS, requiresConfig: true }, - { event: 'session-start', matcher: '*', handler: localAgentInstructionsHandler, timeoutMs: FOREGROUND_HOOK_TIMEOUT_MS }, { event: 'session-start', matcher: '*', handler: mrHintHandler, timeoutMs: FOREGROUND_HOOK_TIMEOUT_MS, gitOnly: true, requiresConfig: true }, { event: 'session-start', matcher: '*', handler: packageHintHandler, timeoutMs: FOREGROUND_HOOK_TIMEOUT_MS, requiresConfig: true }, { event: 'session-start', matcher: '*', handler: secretsHintHandler, timeoutMs: FOREGROUND_HOOK_TIMEOUT_MS, requiresConfig: true }, diff --git a/src/instruction-targets.ts b/src/instruction-targets.ts index 1e3dfe1a7..f14b1ae98 100644 --- a/src/instruction-targets.ts +++ b/src/instruction-targets.ts @@ -2,6 +2,7 @@ import path from 'node:path'; import { isToolInstalledForConfig } from './resources/base.js'; import { pathExists, readFileSafe, remove, writeFile } from './utils/fs.js'; import { getUserHome } from './utils/home.js'; +import { CODEX_TOOL_IDS } from './utils/tool-names.js'; import { opencodeClaudeFallback, opencodeContextReference, readOpencodeInstructionList, reconcileOpencodeInstructions, } from './resources/opencode-config.js'; @@ -13,6 +14,7 @@ import { HERMES_SECTION_LIMIT } from './hermes-hooks.js'; import { isAgentExcluded, resolveToolBaseDir, + resolveHookScope, scopedToolPaths, TEAMAI_CLAUDEMD_END, TEAMAI_CLAUDEMD_START, @@ -268,7 +270,10 @@ export function hookLimitProblem(hook: InstructionHook, text: string): string | * Whether the extension or plugin that adds a hook tool's team instructions is * installed as this build writes it, and if not, what to do. */ -export async function instructionHookChannel(tool: string): Promise<{ ready: boolean; fix: string }> { +export async function instructionHookChannel( + tool: string, + context?: { teamConfig: TeamaiConfig; localConfig: LocalConfig }, +): Promise<{ ready: boolean; fix: string }> { const rerun = 'Run `teamai hooks inject` to reinstall it; `teamai hooks remove` takes it away.'; if (tool === 'omp' || tool === 'pi') { const { buildOmpExtensionSource, resolveOmpExtensionsDir, OMP_HOOK_FILE } = await import('./omp-hooks.js'); @@ -299,6 +304,16 @@ export async function instructionHookChannel(tool: string): Promise<{ ready: boo + 'Add it there (or remove it from plugins.disabled), then start a new Hermes session.', }; } + if (CODEX_TOOL_IDS.some((id) => id === tool)) { + const { getHookStatus } = await import('./hooks.js'); + const hookScope = context && resolveHookScope(context.localConfig); + const settings = context && hookScope && scopedToolPaths(context.teamConfig, { ...context.localConfig, scope: hookScope.scope })[tool]?.settings; + const file = settings && hookScope ? path.resolve(hookScope.baseDir, settings) : undefined; + return { + ready: file !== undefined && await getHookStatus(file, tool) === 'installed', + fix: `${file ?? `${tool}'s hooks file`} is missing or out of date, so ${tool} sessions get no team instructions. ${rerun}`, + }; + } return { ready: true, fix: '' }; } @@ -311,12 +326,14 @@ export async function instructionChannelProblems(teamConfig: TeamaiConfig, local const { hooks } = await resolveInstructionTargets(teamConfig, localConfig); const problems: string[] = []; for (const hook of hooks) { - const channel = await instructionHookChannel(hook.tool); + const text = await instructionHookTextFor(teamConfig, localConfig, hook.tool); + if (!text) continue; + const channel = await instructionHookChannel(hook.tool, { teamConfig, localConfig }); if (!channel.ready) { problems.push(channel.fix); continue; } - const overLimit = hookLimitProblem(hook, await instructionHookTextFor(teamConfig, localConfig, hook.tool)); + const overLimit = hookLimitProblem(hook, text); if (overLimit) problems.push(overLimit); } return problems; @@ -423,7 +440,11 @@ export async function resolveInstructionTargets( probeConfig = { ...localConfig, scope: 'user', toolRoots }; probePaths = scopedToolPaths(teamConfig, probeConfig)[tool] ?? paths; } - if (!isAgentExcluded(localConfig, tool) && await isInstructionToolInstalled(tool, probePaths, probeConfig)) { + if (isAgentExcluded(localConfig, tool)) { + for (const file of retiredInstructionFiles(tool, paths, localConfig.scope)) { + inUse.add(path.resolve(resolveToolBaseDir(tool, localConfig), file)); + } + } else if (await isInstructionToolInstalled(tool, probePaths, probeConfig)) { hooks.push({ tool, recall: Boolean(paths.agents), limit: entry.hookLimit }); } continue; @@ -456,6 +477,24 @@ export async function resolveInstructionTargets( return { targets: [...targets.values()], hooks, stale, opencodeFallback, opencodeFallbackStale: Boolean(opencodeFallback) && claudeHolds }; } +/** Retire a file only after every installed tool that wrote it has replacement delivery. */ +export async function retiredFilesOfReached( + teamConfig: TeamaiConfig, + localConfig: LocalConfig, + reached: readonly string[], +): Promise { + const { stale, hooks } = await resolveInstructionTargets(teamConfig, localConfig); + const writers = new Map(); + for (const [tool, paths] of Object.entries(scopedToolPaths(teamConfig, localConfig))) { + if (!hooks.some((hook) => hook.tool === tool) && !await isInstructionToolInstalled(tool, paths, localConfig)) continue; + for (const file of retiredInstructionFiles(tool, paths, localConfig.scope)) { + const absolute = path.resolve(resolveToolBaseDir(tool, localConfig), file); + writers.set(absolute, [...writers.get(absolute) ?? [], tool]); + } + } + return stale.filter((target) => (writers.get(target.path) ?? []).every((tool) => reached.includes(tool))); +} + /** * List teamai's OpenCode instruction file in OpenCode's `instructions` while * it holds teamai's blocks, and drop the entry teamai recorded adding once the @@ -468,20 +507,22 @@ export async function registerOpencodeContext( localConfig: LocalConfig, resolved: Pick, dryRun: boolean, - planned: readonly string[] = [], - /** The plan's warnings and write failures: a file they name was left as it was. */ - problems: readonly string[] = [], + files: readonly InstructionFileResult[], ): Promise { const paths = scopedToolPaths(teamConfig, localConfig).opencode; const contextFile = paths && instructionTargetPath('opencode', paths, localConfig); if (!contextFile) return null; const wanted = resolved.targets.some((target) => target.path === contextFile); if (!wanted && !resolved.stale.some((target) => target.path === contextFile)) return null; - const delivered = await holdsInstructionBlocks(contextFile) || (dryRun && planned.includes(contextFile)); + const result = files.find((file) => file.path === contextFile); + // An unsuccessful edit cannot activate a new entry or remove a working one. + if (wanted && (!result || result.status === 'blocked' || result.status === 'failed')) return null; + const ready = result?.status === 'current' || result?.status === 'written' || (dryRun && result?.status === 'would-write'); + const delivered = await holdsInstructionBlocks(contextFile) || (dryRun && result?.status === 'would-write'); // A same-named file of the member's: its entry, if any, is theirs too. if (!delivered && await pathExists(contextFile)) return null; // Old or malformed blocks the plan could not replace are not this sync's. - const present = wanted && delivered && !problems.some((problem) => problem.includes(contextFile)); + const present = wanted && delivered && ready; const { config, entry } = opencodeContextReference(contextFile, localConfig.scope, resolveToolBaseDir('opencode', localConfig)); // An entry goes only if teamai recorded adding it: one the member listed // before teamai wrote the file is theirs. @@ -522,6 +563,12 @@ export interface InstructionFileChange { export interface InstructionPlan { changes: InstructionFileChange[]; warnings: string[]; + files: Array<{ path: string; status: 'current' | 'planned' | 'blocked' }>; +} + +export interface InstructionFileResult { + path: string; + status: 'current' | 'written' | 'removed' | 'would-write' | 'would-remove' | 'blocked' | 'failed'; } type BlockEdit = { content: string } | { malformed: true }; @@ -603,7 +650,7 @@ async function planFile( const edited = editBlock(content, pair, block); if ('malformed' in edited) { warnings.push(`${target.path} has an incomplete teamai ${pair[2]} block, so teamai left it unchanged. Fix or remove its ${pair[2]} markers by hand.`); - continue; + return null; } content = edited.content; } @@ -651,7 +698,10 @@ export async function clearInstructionFile( const blocks = starts === undefined ? STALE_BLOCKS : STALE_BLOCKS.filter(([start]) => starts.includes(start)); const warnings: string[] = []; const change = await planFile(target, blocks.map((pair) => [pair, null] as const), 'cleanup', warnings); - const plan: InstructionPlan = { changes: change ? [change] : [], warnings }; + const plan: InstructionPlan = { + changes: change ? [change] : [], warnings, + files: [{ path: file, status: warnings.length > 0 ? 'blocked' : change ? 'planned' : 'current' }], + }; const { failures } = await applyInstructionPlan(plan, { dryRun: false }); if (failures.length > 0) throw new Error(failures.join(' ')); return { changed: plan.changes.length > 0, warnings: plan.warnings }; @@ -668,20 +718,25 @@ export async function planInstructionFiles( ): Promise { const warnings: string[] = []; const changes: InstructionFileChange[] = []; + const files: InstructionPlan['files'] = []; for (const target of targets) { const edits: Array = []; if (blocks.culture !== undefined) edits.push([CULTURE, blocks.culture]); if (blocks.claudemd !== undefined) edits.push([CLAUDEMD, blocks.claudemd]); const recall = target.recall ? blocks.recall : blocks.directRecall; if (recall !== undefined) edits.push([RECALL, recall]); + const before = warnings.length; const change = await planFile(target, edits, 'write', warnings); if (change) changes.push(change); + files.push({ path: target.path, status: warnings.length > before ? 'blocked' : change ? 'planned' : 'current' }); } for (const file of stale) { + const before = warnings.length; const change = await planFile(file, STALE_BLOCKS.map((pair) => [pair, null] as const), 'cleanup', warnings); if (change) changes.push(change); + files.push({ path: file.path, status: warnings.length > before ? 'blocked' : change ? 'planned' : 'current' }); } - return { changes, warnings }; + return { changes, warnings, files }; } /** @@ -692,9 +747,13 @@ export async function planInstructionFiles( export async function applyInstructionPlan( plan: InstructionPlan, options: { dryRun: boolean }, -): Promise<{ report: string[]; failures: string[] }> { +): Promise<{ report: string[]; failures: string[]; files: InstructionFileResult[] }> { const report: string[] = []; const failures: string[] = []; + const files = new Map(); + for (const file of plan.files) { + if (file.status !== 'planned') files.set(file.path, { ...file, status: file.status }); + } for (const { path: file, content, kind } of plan.changes) { if (!options.dryRun) { try { @@ -702,12 +761,16 @@ export async function applyInstructionPlan( else await writeFile(file, content); } catch (e) { failures.push(`Could not update ${file}: ${(e as Error).message}. Check that it is a writable file, then run teamai pull again.`); + files.set(file, { path: file, status: 'failed' }); continue; } } + files.set(file, { path: file, status: options.dryRun + ? content === null ? 'would-remove' : 'would-write' + : content === null ? 'removed' : 'written' }); report.push(kind === 'write' ? `${options.dryRun ? 'Would write' : 'Wrote'} teamai instruction blocks to ${file}` : `${options.dryRun ? 'Would remove' : 'Removed'} teamai instruction blocks from ${file}`); } - return { report, failures }; + return { report, failures, files: plan.files.map((file) => files.get(file.path)!) }; } diff --git a/src/local-agent.ts b/src/local-agent.ts index 712b59042..d5a0e39ee 100644 --- a/src/local-agent.ts +++ b/src/local-agent.ts @@ -54,7 +54,7 @@ import { logHttpRequest, logHttpResponse } from './utils/http-log.js'; import { applyInstructionPlan, deliversInstructionsByHook, instructionHookChannel, instructionHookText, instructionHookTextFor, instructionTargetAt, instructionTargetFile, isInstructionToolInstalled, planInstructionFiles, registerOpencodeContext, resolveInstructionTargets, - retiredInstructionFiles, type InstructionTarget, + retiredFilesOfReached, } from './instruction-targets.js'; import { opencodeClaudeFallback } from './resources/opencode-config.js'; import { reconcilePlugins, teardownAllPlugins, parseGetConfig, substituteVars, unresolvedPlaceholders, type ReconcileDeps, type PluginState } from './plugin-lifecycle.js'; @@ -2180,14 +2180,14 @@ async function syncClaudemd( skipped.push(...plan.warnings); continue; } - const { failures } = await applyInstructionPlan(plan, { dryRun: false }); + const { failures, files } = await applyInstructionPlan(plan, { dryRun: false }); if (failures.length > 0) { log.warn(`Failed to sync CLAUDE.md instructions to ${tool}: ${failures.join(' ')}`); skipped.push(...failures); continue; } if (tool === 'opencode') { - await registerOpencodeContext(teamConfig, localConfig, { targets: [target], stale: [] }, false); + await registerOpencodeContext(teamConfig, localConfig, { targets: [target], stale: [] }, false, files); // OpenCode reads the file only through its `instructions` entry. if (block) { const { opencodeContextReference, readOpencodeInstructionList } = await import('./resources/opencode-config.js'); @@ -2212,35 +2212,17 @@ async function syncClaudemd( for (const line of report) log.info(`${line}: no installed tool loads them from this file`); for (const failure of failures) log.warn(failure); + if (cleanup.warnings.length > 0 || failures.length > 0) { + throw new Error(['CLAUDE.md sync could not remove the retired instructions. Repair the files and retry.', + ...cleanup.warnings, ...failures].join(' ')); + } + // Removing the last prompt fails too when a target kept it. if (!syncedAny && (files.length > 0 || skipped.length > 0)) { throw new Error(['CLAUDE.md sync landed on no tool: every configured target was skipped.', ...skipped].join(' ')); } } -/** - * The files earlier releases wrote blocks to that this sync may strip: no - * installed tool reads them now, and every installed tool that wrote them, if - * any, got this sync's instructions in their place. Another tool's old blocks stay - * until a sync reaches it, since the HTTP agent delivers to one tool at a time. - */ -async function retiredFilesOfReached( - fullTeamConfig: TeamaiConfig, - localConfig: LocalConfig, - reached: readonly string[], -): Promise { - const { stale } = await resolveInstructionTargets(fullTeamConfig, localConfig); - const writers = new Map(); - for (const [tool, paths] of Object.entries(scopedToolPaths(fullTeamConfig, localConfig))) { - if (!await isInstructionToolInstalled(tool, paths, localConfig)) continue; - for (const file of retiredInstructionFiles(tool, paths, localConfig.scope)) { - const absolute = path.resolve(resolveToolBaseDir(tool, localConfig), file); - writers.set(absolute, [...writers.get(absolute) ?? [], tool]); - } - } - return stale.filter((target) => (writers.get(target.path) ?? []).every((tool) => reached.includes(tool))); -} - /** * Why a hook tool cannot add the HTTP agent's instructions in this scope, or * null when it can: not installed, its extension or plugin not ready, or the @@ -2255,7 +2237,7 @@ async function hookDeliveryProblem( ): Promise { const hook = (await resolveInstructionTargets(teamConfig, localConfig)).hooks.find((entry) => entry.tool === tool); if (!hook) return `${tool} is not installed here.`; - const channel = await instructionHookChannel(tool); + const channel = await instructionHookChannel(tool, { teamConfig, localConfig }); if (!channel.ready) return channel.fix; if (hook.limit === undefined) return null; const parts = [block ? instructionHookText({ claudemd: block }, false) : '']; diff --git a/src/omp-hooks.ts b/src/omp-hooks.ts index 2eed0dac9..e87ca4d4f 100644 --- a/src/omp-hooks.ts +++ b/src/omp-hooks.ts @@ -197,8 +197,8 @@ export default function teamaiHooks(pi) { }; pi.on("session_start", async (_event, ctx) => { - instructions = loadInstructions(ctx); await dispatch("session-start", ctx); + instructions = loadInstructions(ctx); }); pi.on("session_stop", async (_event, ctx) => { diff --git a/src/pi-hooks.ts b/src/pi-hooks.ts index 308070888..9409faebb 100644 --- a/src/pi-hooks.ts +++ b/src/pi-hooks.ts @@ -155,8 +155,8 @@ export default function teamaiHooks(pi) { }); pi.on("session_start", async (_event, ctx) => { - instructions = loadInstructions(ctx); await dispatch("session-start", ctx); + instructions = loadInstructions(ctx); }); pi.on("agent_settled", async (_event, ctx) => { diff --git a/src/pull.ts b/src/pull.ts index 9b7d6dcd7..a36dcb754 100644 --- a/src/pull.ts +++ b/src/pull.ts @@ -14,7 +14,10 @@ import { indexableLearningsRoots } from './utils/learnings-roots.js'; import { log, spinner } from './utils/logger.js'; import { pathExists, remove, listFiles, listDirs, listFilesRecursive, readFileSafe, dirContentEqual, hasVcsMetadataRecursive } from './utils/fs.js'; import { reconcilePlacementRecords } from './utils/pending-push.js'; -import { applyInstructionPlan, planInstructionFiles, registerOpencodeContext, resolveInstructionTargets, type InstructionBlocks } from './instruction-targets.js'; +import { + applyInstructionPlan, hookLimitProblem, instructionHookChannel, instructionHookText, planInstructionFiles, + registerOpencodeContext, resolveInstructionTargets, retiredFilesOfReached, type InstructionBlocks, +} from './instruction-targets.js'; import { getHandler, RulesHandler, DocsHandler, EnvHandler, AgentsHandler } from './resources/index.js'; import { reportHeldAgents, type RedeployedCopy } from './resources/agents.js'; import { listStaleDocDirectories, resolveDesiredDocs, resolveDocsDestination } from './resources/docs.js'; @@ -895,6 +898,7 @@ async function pullForScope( result?: { completed: boolean; docsSyncFailed: boolean; agentModelsHeld: boolean }, /** Collects this scope's env resolution for the stages after it (see resolvePullEnv). */ teamEnvs?: Map, + instructions?: Map, ): Promise { const scopeLabel = localConfig.scope; const revisionField = policy.revisionField ?? 'lastPullRev'; @@ -1232,7 +1236,8 @@ async function pullForScope( log.warn(`[${scopeLabel}] The earlier copy of the team rule teamai-context was not reclaimed: ${(error as Error).message}. Run \`teamai pull --force\` to retry.`); } } - await syncManagedInstructions(freshConfig, localConfig, roleContext, scopeLabel); + const delivery = await syncManagedInstructions(freshConfig, localConfig, roleContext, scopeLabel); + instructions?.set(localConfig, delivery); // Same reason: a machine that already pulled a tombstone with an older // CLI keeps the copies that CLI failed to delete, and its stored rev // never moves again. Re-run the cleanup so the upgrade reaches it (#576). @@ -1587,7 +1592,8 @@ async function pullForScope( await syncLearningsAndRebuildIndex(); // Steps 3.6-3.8: Deliver team culture, shared instructions and the recall block. - await syncManagedInstructions(freshConfig, localConfig, roleContext, scopeLabel, options.dryRun); + const delivery = await syncManagedInstructions(freshConfig, localConfig, roleContext, scopeLabel, options.dryRun); + instructions?.set(localConfig, delivery); // Step 4: Deploy CLI built-in skills if (!options.dryRun) { @@ -1898,46 +1904,62 @@ export async function resolveInstructionBlocks( * that calls it; any other gets the one that runs `teamai recall` directly. * A dry run reports the files it would change. */ +interface InstructionDelivery { + blocks: InstructionBlocks; + reached: string[]; +} + async function syncManagedInstructions( config: TeamaiConfig, localConfig: LocalConfig, roleContext: RolePullContext | null, scopeLabel: string, dryRun = false, -): Promise { +): Promise { const { blocks, claudemdFiles } = await resolveInstructionBlocks(config, localConfig, roleContext); const resolved = await resolveInstructionTargets(config, localConfig); - const { targets, stale, opencodeFallback, opencodeFallbackStale } = resolved; + const { targets, opencodeFallback, opencodeFallbackStale } = resolved; if (opencodeFallback && opencodeFallbackStale) { log.warn(`[${scopeLabel}] OpenCode reads the team instructions from ${opencodeFallback}, its fallback while ~/.config/opencode/AGENTS.md does not exist, but teamai no longer updates them there: Claude Code is excluded or not installed. Create that AGENTS.md to have teamai deliver them to OpenCode's own file, or remove the teamai blocks from ${opencodeFallback}.`); } else if (opencodeFallback) { log.info(`[${scopeLabel}] OpenCode reads the team instructions from ${opencodeFallback}, its fallback while ~/.config/opencode/AGENTS.md does not exist, so teamai adds no second copy for it. Create that AGENTS.md to have teamai deliver them to OpenCode's own file instead.`); } - const plan = await planInstructionFiles(targets, blocks, stale); + // Retired files are cleaned after hook reconciliation, using the delivery + // results from this pass. A failed replacement must keep its working copy. + const plan = await planInstructionFiles(targets, blocks); for (const warning of plan.warnings) log.warn(`[${scopeLabel}] ${warning}`); - const { report, failures } = await applyInstructionPlan(plan, { dryRun }); + const { report, failures, files } = await applyInstructionPlan(plan, { dryRun }); for (const line of report) { if (dryRun) log.info(`[dry-run] ${line}`); else if (line.startsWith('Removed')) log.info(`[${scopeLabel}] ${line}: no installed tool loads them from this file`); else log.debug(line); } for (const failure of failures) log.warn(`[${scopeLabel}] ${failure}`); + let opencodeReady = true; try { - const planned = plan.changes.filter((change) => change.content !== null).map((change) => change.path); - const registered = await registerOpencodeContext(config, localConfig, resolved, dryRun, planned, [...plan.warnings, ...failures]); + const registered = await registerOpencodeContext(config, localConfig, resolved, dryRun, files); if (registered && dryRun) log.info(`[dry-run] ${registered}`); else if (registered) log.debug(registered); + if (!dryRun && targets.some((target) => target.tools.includes('opencode')) + && Object.values(blocks).some(Boolean)) { + const { opencodeContextReference, readOpencodeInstructionList } = await import('./resources/opencode-config.js'); + const target = targets.find((target) => target.tools.includes('opencode'))!; + const { config: file, entry } = opencodeContextReference(target.path, localConfig.scope, resolveToolBaseDir('opencode', localConfig)); + opencodeReady = (await readOpencodeInstructionList(file))?.includes(entry) ?? false; + } } catch (e) { + opencodeReady = false; log.warn(`[${scopeLabel}] Failed to list the team instructions in OpenCode's config: ${(e as Error).message}. Run \`teamai doctor\`, which names the config file and the entry to add by hand.`); } // Hook targets (Pi, OMP, Hermes, Codex) are reported after the hooks are // reconciled, since that is what installs their extensions and plugins. - // A target named in a warning or failure was left as it was. - const problems = [...plan.warnings, ...failures]; - const reached = targets.filter((target) => !problems.some((problem) => problem.includes(target.path))); - if (dryRun || reached.length === 0) return; + const reached = targets.filter((target) => files.some((file) => file.path === target.path + && ['current', 'written', 'removed', ...(dryRun ? ['would-write', 'would-remove'] : [])].includes(file.status))) + .flatMap((target) => target.tools).filter((tool) => tool !== 'opencode' || opencodeReady); + if (dryRun || reached.length === 0) return { blocks, reached }; if (blocks.culture) log.success('Synced team culture'); if (blocks.claudemd) log.success(`[${scopeLabel}] Synced shared instructions (${claudemdFiles} file(s))`); + return { blocks, reached }; } /** @@ -2126,6 +2148,7 @@ export async function pull( const syncResult = { completed: false, docsSyncFailed: false, agentModelsHeld: false }; // Each scope's env, resolved once by its env stage (resolvePullEnv). const teamEnvs = new Map(); + const instructions = new Map(); // Whether HOME's settings.json still has the pre-dispatch hook format. Read now // (HOME-only, no shared clone), but the actual reinject runs later under the @@ -2227,7 +2250,7 @@ export async function pull( } else { activeUserConfig = loadedUserConfig; if (await lockScope(activeUserConfig)) { - await pullForScope(activeUserConfig, options, reported, {}, syncResult, teamEnvs); + await pullForScope(activeUserConfig, options, reported, {}, syncResult, teamEnvs, instructions); } } } else if (inheritUserScope) { @@ -2244,7 +2267,7 @@ export async function pull( if (projectConfig) { try { if (await lockScope(projectConfig)) { - await pullForScope(projectConfig, options, reported, {}, syncResult, teamEnvs); + await pullForScope(projectConfig, options, reported, {}, syncResult, teamEnvs, instructions); } } catch (e) { log.warn(`Project-scope pull error: ${(e as Error).message}`); @@ -2287,7 +2310,7 @@ export async function pull( // what self-heals new built-in hooks and applies hooks.yaml changes on every // session start. In project mode user is null, even when safe resources are // inherited, so executable hook configuration is never composed implicitly. - await reconcileHooksAllScopes(reconcileUser, reconcileProject, options); + await reconcileHooksAllScopes(reconcileUser, reconcileProject, options, instructions); // 3.6. Reconcile team MCP servers. Outside pullForScope for the same reason as // hooks. User-scope MCP remains isolated in project mode. @@ -2541,6 +2564,7 @@ async function reconcileHooksAllScopes( userConfig: LocalConfig | null, projectConfig: LocalConfig | null, options: GlobalOptions, + instructions: Map, ): Promise { // A dry run still resolves the entries, so the warnings a maintainer runs // `--dry-run` to see — an unknown id, a deprecated per-entry `roles:`, a @@ -2564,6 +2588,21 @@ async function reconcileHooksAllScopes( // claim a reconcile that did not happen. log.debug(`[${localConfig.scope}] ${options.dryRun ? 'Would apply' : 'Reconciled'} ${reconciled.defs.length} team hook(s)`); } + const delivery = instructions.get(localConfig); + if (delivery) { + const { hooks } = await resolveInstructionTargets(teamConfig, localConfig); + for (const hook of hooks) { + const channel = await instructionHookChannel(hook.tool, { teamConfig, localConfig }); + if (channel.ready && !hookLimitProblem(hook, instructionHookText(delivery.blocks, hook.recall))) { + delivery.reached.push(hook.tool); + } + } + const cleanup = await planInstructionFiles([], {}, await retiredFilesOfReached(teamConfig, localConfig, delivery.reached)); + for (const warning of cleanup.warnings) log.warn(`[${localConfig.scope}] ${warning}`); + const applied = await applyInstructionPlan(cleanup, { dryRun: Boolean(options.dryRun) }); + for (const line of applied.report) log.info(`${options.dryRun ? '[dry-run]' : `[${localConfig.scope}]`} ${line}: replacement instructions are ready`); + for (const failure of applied.failures) log.warn(`[${localConfig.scope}] ${failure}`); + } // The hooks install the extensions and plugins that add team // instructions for Pi, OMP and Hermes (#945); say which cannot. The // session-start pull is silent, and doctor reports the same. diff --git a/src/recall-toggle.ts b/src/recall-toggle.ts index 8947e79ca..932efaf31 100644 --- a/src/recall-toggle.ts +++ b/src/recall-toggle.ts @@ -81,10 +81,10 @@ async function writeRecallBlock( const files = blocks === null ? [...targets, ...stale] : targets; const plan = await planInstructionFiles(files, blocks ?? { recall: null, directRecall: null }); for (const warning of plan.warnings) log.warn(warning); - const { report, failures } = await applyInstructionPlan(plan, { dryRun: false }); + const { report, failures, files: results } = await applyInstructionPlan(plan, { dryRun: false }); for (const line of report) log.debug(line); for (const failure of failures) log.warn(failure); - await registerOpencodeContext(teamConfig, localConfig, resolved, false, [], [...plan.warnings, ...failures]); + await registerOpencodeContext(teamConfig, localConfig, resolved, false, results); } async function deployRecallArtifacts(teamConfig: TeamaiConfig, localConfig: LocalConfig): Promise { diff --git a/src/uninstall.ts b/src/uninstall.ts index 962432b87..b646fd003 100644 --- a/src/uninstall.ts +++ b/src/uninstall.ts @@ -370,7 +370,7 @@ async function discoverToolResources( // project copy, so there is just the one place to look. const { hasOmpHooks, resolveOmpExtensionsDir, OMP_HOOK_FILE } = await import('./omp-hooks.js'); const extFile = path.join(resolveOmpExtensionsDir(), OMP_HOOK_FILE); - if (await hasOmpHooks()) { + if (scope === 'user' && await hasOmpHooks()) { res.ompHookFile = extFile; } } else if (tool === 'pi') { @@ -381,13 +381,9 @@ async function discoverToolResources( resolvePiProjectExtensionsDir, PI_HOOK_FILE, } = await import('./pi-hooks.js'); - // Mirrors OMP: a targeted uninstall removes the single global extension - // outright, regardless of scope. Pi has no way to scope a shared file to - // one project — the generated extension fires for every Pi session - // machine-wide — so a scoped "preserve for other projects" guarantee was - // never actually enforceable, and pretending otherwise just left Pi still - // firing hooks for a project that had supposedly uninstalled it. - if (await hasPiHooks()) { + // Project uninstall owns only legacy project copies; other projects still + // use the single global extension and server-pushed agent hooks. + if (scope === 'user' && await hasPiHooks()) { res.piHookFiles.push(path.join(resolvePiExtensionsDir(), PI_HOOK_FILE)); } // Server-pushed agent hooks (teamai-agent-.ts) always install into @@ -396,7 +392,7 @@ async function discoverToolResources( // leftover-plugin pattern so a Pi-only agent-hook install isn't missed. // Each match is marker-checked by its own slug so a same-named file a // user authored by hand is never swept up. - for (const file of await listFiles(resolvePiExtensionsDir())) { + for (const file of scope === 'user' ? await listFiles(resolvePiExtensionsDir()) : []) { const base = path.basename(file); if (!base.startsWith('teamai-agent-') || !base.endsWith('.ts')) continue; const slug = base.slice('teamai-agent-'.length, -'.ts'.length); @@ -642,9 +638,11 @@ async function buildRemovalPlan( ? opencodeContextReference(path.resolve(resolveToolBaseDir('opencode', localConfig), contextFile), localConfig.scope, resolveToolBaseDir('opencode', localConfig)) : undefined; const recorded = (await loadStateForScope(localConfig)).opencodeContextEntries ?? []; - if (own && recorded.some((ref) => ref.config === own.config && ref.entry === own.entry) - && (await readOpencodeInstructionList(own.config))?.includes(own.entry)) { - opencodeRes.opencodeInstructions.push(own); + if (own && recorded.some((ref) => ref.config === own.config && ref.entry === own.entry)) { + const listed = await readOpencodeInstructionList(own.config); + if (listed?.includes(own.entry) || (listed === null && await pathExists(own.config))) { + opencodeRes.opencodeInstructions.push(own); + } } } @@ -718,7 +716,7 @@ async function buildRemovalPlan( teamaiHomeExists: includeShared && await pathExists(teamaiHome), unpublishedQueues: includeShared ? await listQueuesIn(teamaiHome) : [], includeShared, - hermesCleanup: toolsToMerge.includes('hermes'), + hermesCleanup: localConfig.scope === 'user' && toolsToMerge.includes('hermes'), scope: localConfig.scope, }; @@ -766,8 +764,8 @@ async function buildRemovalPlan( const content = await readFileSafe(file) ?? ''; const kept = retainedBlocks.get(file); const blocks = CLAUDEMD_MARKER_PAIRS.filter(([start]) => content.includes(start) && !kept?.has(start)); - const owned = path.basename(file).startsWith(`${TEAMAI_CONTEXT_RULE_NAME}.`); - if (blocks.length > 0) plan.claudeMdFiles.push({ path: file, blocks, owned }); + // Retired paths are configured member files, whatever their basename. + if (blocks.length > 0) plan.claudeMdFiles.push({ path: file, blocks, owned: false }); } plan.skillDirs.push(...res.skillDirs); plan.ruleFiles.push(...res.ruleFiles); @@ -1028,7 +1026,8 @@ async function teardownPlugins(): Promise { } } -async function executeRemoval(plan: RemovalPlan): Promise { +async function executeRemoval(plan: RemovalPlan): Promise { + const pendingOpencode: RemovalPlan['opencodeInstructions'] = []; // (a) Remove hooks from tool settings (built-in A + team B via the manifest). // Each settings entry carries the manifest for its own location (HOME/user // or a legacy /project copy), so team hooks are stripped at the @@ -1105,7 +1104,7 @@ async function executeRemoval(plan: RemovalPlan): Promise { // heavy dependency graph out of uninstall's static import chain. Best-effort. try { const { removeAllAgentHooks } = await import('./local-agent.js'); - await removeAllAgentHooks(); + if (plan.scope === 'user') await removeAllAgentHooks(); } catch (e) { log.warn(`Failed to remove agent hooks: ${(e as Error).message}`); } @@ -1131,6 +1130,9 @@ async function executeRemoval(plan: RemovalPlan): Promise { } catch (e) { log.warn(`Failed to remove "${entry}" from the instructions of ${config}: ${(e as Error).message}`); } + const { readOpencodeInstructionList } = await import('./resources/opencode-config.js'); + const listed = await readOpencodeInstructionList(config); + if (listed === null || listed.includes(entry)) pendingOpencode.push({ config, entry }); } // (c) Remove synced skills. @@ -1238,7 +1240,7 @@ async function executeRemoval(plan: RemovalPlan): Promise { } // (g) Remove ~/.teamai/ directory (last — earlier steps read from it) - if (plan.teamaiHomeExists) { + if (plan.teamaiHomeExists && pendingOpencode.length === 0) { // Tear down plugins first: their manifest/config live under ~/.teamai/local-agent. await teardownPlugins(); try { @@ -1262,10 +1264,19 @@ async function executeRemoval(plan: RemovalPlan): Promise { log.debug(`Hermes uninstall cleanup skipped: ${(e as Error).message}`); } } + return pendingOpencode; } // ─── Public API ──────────────────────────────────────── +async function excludeUninstalledAgent(config: LocalConfig, agent: string): Promise { + // Keep an absent whitelist meaning "all other tools". + if (config.enabledAgents) config.enabledAgents = config.enabledAgents.filter((tool) => tool !== agent); + config.disabledAgents = [...new Set([...config.disabledAgents ?? [], agent])]; + if (config.scope === 'project') await saveLocalConfigForScope(config, config.scope, config.projectRoot); + else await saveLocalConfig(config); +} + export async function uninstall(opts: UninstallOptions): Promise { let localConfig: LocalConfig | null = null; let teamConfig: TeamaiConfig | null = null; @@ -1304,6 +1315,13 @@ export async function uninstall(opts: UninstallOptions): Promise { } if (isPlanEmpty(plan)) { + if (agentKey && localConfig.scope === 'project' && ['pi', 'omp', 'hermes'].includes(agentKey)) { + // The global channel belongs to other projects too; exclusion is this + // project's removal even when there are no local files to delete. + await excludeUninstalledAgent(localConfig, agentKey); + log.success(`Excluded ${agentKey} from this project; its global delivery channel is kept for other projects`); + return; + } log.info('Nothing to uninstall'); return; } @@ -1407,20 +1425,17 @@ export async function uninstall(opts: UninstallOptions): Promise { } } - await executeRemoval(plan); + const pendingOpencode = await executeRemoval(plan); // The OpenCode entries uninstall removed are no longer teamai's to track; // one still listed (the write failed) stays recorded for the next try. - if (plan.opencodeInstructions.length > 0 && !plan.includeShared) { + if (plan.opencodeInstructions.length > 0 && (!plan.includeShared || pendingOpencode.length > 0)) { const { loadStateForScope, saveStateForScope } = await import('./config.js'); - const { readOpencodeInstructionList } = await import('./resources/opencode-config.js'); const state = await loadStateForScope(localConfig!); if (state.opencodeContextEntries) { - const removed: typeof plan.opencodeInstructions = []; - for (const ref of plan.opencodeInstructions) { - const listed = await readOpencodeInstructionList(ref.config); - if (listed !== null && !listed.includes(ref.entry)) removed.push(ref); - } + const removed = plan.opencodeInstructions.filter((ref) => !pendingOpencode.some( + (pending) => pending.config === ref.config && pending.entry === ref.entry, + )); state.opencodeContextEntries = state.opencodeContextEntries.filter( (ref) => !removed.some((e) => e.config === ref.config && e.entry === ref.entry), ); @@ -1432,26 +1447,16 @@ export async function uninstall(opts: UninstallOptions): Promise { // hook) does not resurrect this tool's resources. Only meaningful when the // shared ~/.teamai home survives (non-last-tool uninstall); on a last-tool // uninstall the home is deleted and there is nothing to persist. - if (agentKey && !plan.includeShared) { - const cfg = localConfig!; - // Only prune an existing whitelist. Leaving `enabledAgents` undefined - // (meaning "all tools") as-is is important: collapsing it to [] would be - // read by the hook path as "whitelist nothing" and stop hook sync for the - // remaining tools too. The disabledAgents exclusion below is what actually - // keeps the uninstalled tool out on the next pull. - if (cfg.enabledAgents) { - cfg.enabledAgents = cfg.enabledAgents.filter((t) => t !== agentKey); - } - const prevDisabled = cfg.disabledAgents ?? []; - cfg.disabledAgents = [...new Set([...prevDisabled, agentKey])]; - if (cfg.scope === 'project') { - await saveLocalConfigForScope(cfg, cfg.scope, cfg.projectRoot); - } else { - await saveLocalConfig(cfg); - } + if (agentKey && (!plan.includeShared || pendingOpencode.length > 0)) { + await excludeUninstalledAgent(localConfig, agentKey); } - log.success('teamai uninstalled'); + if (pendingOpencode.length > 0) { + log.warn(`Uninstall incomplete: kept ${plan.teamaiHome} and OpenCode ownership so removal can be retried. Repair permissions or JSON in ${pendingOpencode.map((ref) => ref.config).join(', ')}, then run the same uninstall command again.`); + process.exitCode = 1; + } else { + log.success('teamai uninstalled'); + } } else { // Minimal uninstall — just try to remove ~/.teamai/ if (opts.agent) { From ca1f47b8c386de8a225428e037e1374fa862209f Mon Sep 17 00:00:00 2001 From: Saul Moro Date: Fri, 2 Oct 2026 14:00:31 +0200 Subject: [PATCH 35/41] fix(instructions): preserve unresolved blocks and shared tool state (#945) --- docs/usage-guide.md | 8 ++- docs/usage-guide.zh-CN.md | 8 ++- skill-data/core/references/troubleshooting.md | 5 ++ skill-data/setup/references/uninstall.md | 3 + src/__tests__/e2e/instruction-targets.test.ts | 47 ++++++++++++++++ src/__tests__/local-agent.test.ts | 55 +++++++++++++++++++ src/__tests__/pull-sync-truth.test.ts | 27 +++++++++ src/__tests__/uninstall.test.ts | 22 ++++++++ src/instruction-targets.ts | 12 +++- src/local-agent.ts | 32 ++++++++++- src/pull.ts | 2 +- src/uninstall.ts | 8 ++- 12 files changed, 221 insertions(+), 8 deletions(-) diff --git a/docs/usage-guide.md b/docs/usage-guide.md index 358a1c124..ddde76e65 100644 --- a/docs/usage-guide.md +++ b/docs/usage-guide.md @@ -1678,7 +1678,11 @@ OpenCode loads a file only when its config lists it in `instructions`. teamai ad Oh My Pi reads `RULES.md` as an always-applied rule beside its single user context file. In project scope teamai's OMP extension asks `teamai` for the blocks when the session starts and adds them to each turn's system prompt, from the project root and any subdirectory. Without that extension (for example with hooks removed), an Oh My Pi project session gets no team blocks. Prompts the HTTP local agent delivers for a project reach Pi, Oh My Pi and Hermes the same way, through their extension or plugin, and the Codex family through its session-start and subagent-start hooks. Pi and Oh My Pi wait for foreground session-start dispatch, including HTTP prompt sync, before caching the project instructions for the first prompt. Codex reads its HTTP prompt cache after the same sync, before returning SessionStart context. -A pull from an earlier release may have left these blocks in a file listed below. A pull removes them only after every installed tool that wrote that file has its replacement instructions. Failed target writes, foreign files, missing extensions or disabled plugins keep the old blocks for a retry. Excluded tools' retired files stay unchanged. The pull names each file it changes: +A pull from an earlier release may have left these blocks in a file listed below. A pull removes each block only after its replacement was resolved and delivered to every installed tool that wrote that file. An unreadable or invalid culture source keeps the old culture block even if shared instructions and recall sync successfully. Failed target writes, foreign files, missing extensions or disabled plugins keep the old blocks for a retry. Excluded tools' retired files stay unchanged. + +HTTP prompt commands verify the current destinations of all installed former writers, including delivery from previous commands, before removing the retired shared-instructions block. A destination holding an older prompt does not count as delivered. HTTP cleanup preserves culture and recall blocks, which those commands do not replace. + +The pull names each file it changes: - Claude Code, project scope: `.claude/CLAUDE.md` - CodeBuddy, project scope: `.codebuddy/CODEBUDDY.md` @@ -2864,6 +2868,8 @@ An instructions file several tools map is cleaned per block: a teamai block stay Shared resources (the env block, docs directory, and `~/.teamai/`) are removed **only when the target itself has teamai resources AND is the last tool still using teamai** — otherwise they are kept for the remaining tools. Targeting a tool with no local resources leaves shared resources in place, even if it is the only tool. Project uninstall still records the exclusion for Pi, Oh My Pi and Hermes, whose instruction channels are global. +An enabled, installed Pi, Oh My Pi, Hermes or project Codex keeps the project state in use through its global delivery channel, even without a project-local tool directory. Uninstalling another tool preserves that state so the remaining tool can still deliver this project's instructions. + If removing an OpenCode entry added by teamai fails, uninstall exits with an error and keeps the shared data directory and ownership record, even when OpenCode is the last tool. Repair the config or its permissions, then retry the same uninstall command. Project uninstall keeps Pi's and Oh My Pi's global extensions and Hermes' global plugin and configuration, which other projects use. A targeted uninstall excludes the tool in this project's config when that config survives. User-scope uninstall removes these global delivery channels. diff --git a/docs/usage-guide.zh-CN.md b/docs/usage-guide.zh-CN.md index 6ec4c1b94..fb79cf786 100644 --- a/docs/usage-guide.zh-CN.md +++ b/docs/usage-guide.zh-CN.md @@ -1556,7 +1556,11 @@ OpenCode 只加载配置中 `instructions` 列出的文件。仅当目标已包 Oh My Pi 把 `RULES.md` 作为始终应用的规则读取,与其唯一的用户上下文文件并存。在项目范围内,teamai 的 OMP 扩展在会话开始时向 `teamai` 获取这些块,并加入每轮的系统提示,无论会话从项目根目录还是子目录启动。没有该扩展时(例如移除了 hooks),Oh My Pi 的项目会话不会获得团队块。HTTP local agent 为项目下发的 prompt 也以同样方式,通过扩展或插件到达 Pi、Oh My Pi 和 Hermes,并通过 session-start 和 subagent-start hook 到达 Codex 系列。Pi 和 Oh My Pi 会等待前台 session-start 派发(包括 HTTP prompt 同步)完成,再为首个 prompt 缓存项目指令。Codex 也会在该同步完成后读取 HTTP prompt 缓存,再返回 SessionStart 上下文。 -早期版本的 pull 可能把这些块留在下列文件中。只有曾写入该文件的每个已安装工具都获得替代指令后,pull 才移除旧块。目标写入失败、文件并非 teamai 所有、扩展缺失或插件被禁用时,旧块保留以便重试。被排除工具的旧指令文件保持不变。pull 会列出所修改的每个文件: +早期版本的 pull 可能把这些块留在下列文件中。只有某个区块的替代内容已成功解析并投递给曾写入该文件的每个已安装工具后,pull 才移除该旧区块。文化源文件不可读或无效时,即使共享指令和 recall 已成功同步,旧文化区块仍会保留。目标写入失败、文件并非 teamai 所有、扩展缺失或插件被禁用时,旧块保留以便重试。被排除工具的旧指令文件保持不变。 + +HTTP prompt 命令会检查所有已安装的旧写入工具的当前目标,包括先前命令的投递结果,确认后才移除旧共享指令区块。目标仍含旧 prompt 时不算投递成功。HTTP 清理保留文化和 recall 区块,因为这些命令不替换它们。 + +pull 会列出所修改的每个文件: - Claude Code,项目范围:`.claude/CLAUDE.md` - CodeBuddy,项目范围:`.codebuddy/CODEBUDDY.md` @@ -2675,6 +2679,8 @@ teamai uninstall --agent claude 跨工具共享资源(shell profile env 块、docs 目录、`~/.teamai/`)**仅当该工具自身存在 teamai 资源、且它是最后一个仍在使用 teamai 的工具时**才一并移除,否则会为其余工具保留。没有本地资源的工具即使是唯一的工具,定向卸载也会保留共享资源。Pi、Oh My Pi 和 Hermes 的指令通道位于全局,因此项目级卸载仍会记录对它们的排除设置。 +已启用且已安装的 Pi、Oh My Pi、Hermes 或项目级 Codex 通过全局投递通道继续使用项目状态,即使项目中没有该工具的本地目录。卸载其他工具时会保留此状态,以便剩余工具继续投递该项目的指令。 + 若删除 teamai 添加的 OpenCode 条目失败,卸载以错误状态退出,并保留共享数据目录和所有权记录,即使 OpenCode 是最后一个工具。修复配置或权限后,重试同一卸载命令。 项目级卸载保留 Pi 和 Oh My Pi 的全局扩展,以及 Hermes 的全局插件和配置,其他项目仍会使用它们。单工具卸载在项目配置仍保留时,将该工具加入此项目的排除列表。用户级卸载才移除这些全局投递通道。 diff --git a/skill-data/core/references/troubleshooting.md b/skill-data/core/references/troubleshooting.md index 4b6fdc62b..ecd4c5c17 100644 --- a/skill-data/core/references/troubleshooting.md +++ b/skill-data/core/references/troubleshooting.md @@ -236,5 +236,10 @@ marker, then run `teamai pull` again. Other files can still sync successfully. Pull keeps retired instruction blocks until every installed tool that wrote the file has a working replacement. Repair the named target, extension or plugin and run `teamai pull` again. Excluded tools' retired files stay unchanged. +If a block's source cannot be resolved, its old block stays even when other +blocks sync. Repair the source and pull again to complete its migration. +HTTP prompt commands verify earlier deliveries against the current prompt +before cleaning shared instructions. Older destination contents do not count; +culture and recall stay because HTTP prompt commands do not replace them. An HTTP prompt sync that cannot clean retired blocks reports a failed ACK and keeps its previous cache and manifest for the server's retry. diff --git a/skill-data/setup/references/uninstall.md b/skill-data/setup/references/uninstall.md index a0899239f..eb8165c55 100644 --- a/skill-data/setup/references/uninstall.md +++ b/skill-data/setup/references/uninstall.md @@ -68,6 +68,9 @@ and give it your team repo URL."* its ownership record and shared data directory, even for the last tool. - Project uninstall keeps the global Pi and Oh My Pi extensions and Hermes plugin and config for other projects. User-scope uninstall removes them. +- An enabled, installed Pi, Oh My Pi, Hermes or project Codex also keeps the + project's shared state in use without a local tool directory. Uninstalling + another tool preserves that state and the remaining tool's instructions. - Do **not** delete the team repo on the Git platform — uninstall never touches it, and neither should you. - If the user only wants to stop auto-sync for one tool but keep TeamAI otherwise, diff --git a/src/__tests__/e2e/instruction-targets.test.ts b/src/__tests__/e2e/instruction-targets.test.ts index 73ff153e6..7f1c27416 100644 --- a/src/__tests__/e2e/instruction-targets.test.ts +++ b/src/__tests__/e2e/instruction-targets.test.ts @@ -536,6 +536,53 @@ describe('instruction block targets on real CLI pull (#945)', () => { console.log('pull after repair: replacement delivered, old prompt removed'); }); + it('keeps unreadable culture in its retired file until that block is delivered', async () => { + const sandbox = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'teamai-945-block-migration-'))); + sandboxes.push(sandbox); + const member = makeProjectMember(sandbox, makeTeamAndProject(sandbox), 'dev', 'developer', ['.claude/skills']); + const source = path.join(member.projectRoot, '.teamai/team-repo/culture.md'); + fs.unlinkSync(source); + fs.mkdirSync(source); + const legacy = path.join(member.projectRoot, '.claude/CLAUDE.md'); + fs.writeFileSync(legacy, `# Mine\n${CULTURE_START}\nWORKING-CULTURE\n${CULTURE_END}\n${CLAUDEMD_START}\nold prompt\n${CLAUDEMD_END}\n`); + const result = await pullAs(member); + expect(result.code, result.output).toBe(0); + expect(result.output).toContain('Failed to read team culture'); + expect(fs.readFileSync(legacy, 'utf8')).toContain('WORKING-CULTURE'); + expect(fs.readFileSync(legacy, 'utf8')).not.toContain('old prompt'); + const replacement = path.join(member.projectRoot, '.claude/rules/teamai-context.md'); + expect(fs.readFileSync(replacement, 'utf8')).toContain('DEVELOPMENT-SENTINEL'); + console.log('unreadable culture: old culture retained; resolved shared instructions delivered and retired'); + + const repaired = path.join(memberData(member).teamRepo, 'culture.md'); + fs.rmdirSync(repaired); + fs.writeFileSync(repaired, '---\ncompany:\n name: Acme\n---\n\nRESTORED-CULTURE\n'); + const retry = await runCLI(['pull'], { HOME: member.home }, member.projectRoot); + expect(retry.code, retry.output).toBe(0); + expect(fs.readFileSync(legacy, 'utf8')).toBe('# Mine\n'); + expect(fs.readFileSync(replacement, 'utf8')).toContain('RESTORED-CULTURE'); + console.log('culture repair: replacement delivered; retired culture removed on the next pull'); + }); + + it('keeps globally installed Pi delivering after project WorkBuddy uninstall', async () => { + const sandbox = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'teamai-945-active-hook-'))); + sandboxes.push(sandbox); + const member = makeProjectMember(sandbox, makeTeamAndProject(sandbox), 'dev', 'developer', ['.workbuddy/skills']); + const config = path.join(member.projectRoot, '.teamai/config.yaml'); + fs.appendFileSync(config, '\nenabledAgents: [workbuddy, pi]\n'); + fs.mkdirSync(path.join(member.home, '.pi'), { recursive: true }); + const result = await pullAs(member); + expect(result.code, result.output).toBe(0); + expect(fs.existsSync(path.join(member.projectRoot, '.pi'))).toBe(false); + const data = memberData(member); + const removed = await runCLI(['uninstall', '--force', '--agent', 'workbuddy'], { HOME: member.home }, member.projectRoot); + expect(removed.code, removed.output).toBe(0); + expect(fs.existsSync(data.config)).toBe(true); + expect(fs.existsSync(path.join(member.projectRoot, '.codebuddy/rules/teamai-context.md'))).toBe(false); + expect(await sessionInstructions('pi', member.home, member.projectRoot)).toContain('DEVELOPMENT-SENTINEL'); + console.log('WorkBuddy project uninstall: project state preserved; global-only Pi still receives member instructions'); + }); + it('retains Pi legacy instructions until its extension is ready, and protects exclusions', async () => { const sandbox = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'teamai-945-migration-'))); sandboxes.push(sandbox); diff --git a/src/__tests__/local-agent.test.ts b/src/__tests__/local-agent.test.ts index b05ca075c..1fc8f83bc 100644 --- a/src/__tests__/local-agent.test.ts +++ b/src/__tests__/local-agent.test.ts @@ -2202,6 +2202,61 @@ describe('local-agent: project prompts reach every installed tool (#945)', () => expect(outputs.filter(Boolean).join('\n')).toContain('PROJECT-PROMPT'); }); + it.each([['pi', 'workbuddy'], ['workbuddy', 'pi']])('retires a shared HTTP prompt after separate %s then %s deliveries', async (first, second) => { + await fse.ensureDir(path.join(tmpDir, '.pi')); + const { injectPiHooks } = await import('../pi-hooks.js'); + if (first === 'pi') await injectPiHooks(); + const legacy = '# Team notes\n\nold member prompt\n\n\nworking culture\n\n'; + const { repo, ack } = await installProjectPrompt(first, ['.workbuddy/skills'], { files: { 'AGENTS.md': legacy } }); + expect(ack?.status).toBe('success'); + expect(await fse.readFile(path.join(repo, 'AGENTS.md'), 'utf8')).toContain('old member prompt'); + + await injectPiHooks(); + const next = await installProjectPrompt(second, []); + expect(next.ack?.status).toBe('success'); + const retired = await fse.readFile(path.join(repo, 'AGENTS.md'), 'utf8'); + expect(retired).not.toContain('old member prompt'); + expect(retired).toContain('working culture'); + expect(await fse.readFile(path.join(repo, '.codebuddy/rules/teamai-context.md'), 'utf8')).toContain('PROJECT-PROMPT'); + const { localAgentInstructionText } = await import('../local-agent.js'); + expect(await localAgentInstructionText(repo)).toContain('PROJECT-PROMPT'); + }); + + it('does not count a previous HTTP delivery after the cached prompt changes', async () => { + await fse.ensureDir(path.join(tmpDir, '.pi')); + const legacy = '# Notes\n\nlegacy prompt\n\n'; + const first = await installProjectPrompt('workbuddy', ['.workbuddy/skills'], { prompt: 'OLD-PROMPT', files: { 'AGENTS.md': legacy } }); + expect(first.ack?.status).toBe('success'); + const { injectPiHooks } = await import('../pi-hooks.js'); + await injectPiHooks(); + const next = await installProjectPrompt('pi', [], { prompt: 'NEW-PROMPT' }); + expect(next.ack?.status).toBe('success'); + expect(await fse.readFile(path.join(next.repo, 'AGENTS.md'), 'utf8')).toBe(legacy); + expect(await fse.readFile(path.join(next.repo, '.codebuddy/rules/teamai-context.md'), 'utf8')).toContain('OLD-PROMPT'); + + const retry = await installProjectPrompt('workbuddy', [], { prompt: 'NEW-PROMPT' }); + expect(retry.ack?.status).toBe('success'); + expect(await fse.readFile(path.join(next.repo, 'AGENTS.md'), 'utf8')).toBe('# Notes\n'); + }); + + it('retains a shared HTTP prompt while another writer has a rejected replacement', async () => { + await fse.ensureDir(path.join(tmpDir, '.pi')); + const { injectPiHooks } = await import('../pi-hooks.js'); + await injectPiHooks(); + const legacy = '# Notes\n\nlegacy prompt\n\n'; + const first = await installProjectPrompt('pi', ['.workbuddy/skills'], { files: { + 'AGENTS.md': legacy, '.codebuddy/rules/teamai-context.md': '# Foreign\n', + } }); + expect(first.ack?.status).toBe('success'); + const failed = await installProjectPrompt('workbuddy', []); + expect(failed.ack?.status).toBe('failed'); + expect(await fse.readFile(path.join(first.repo, 'AGENTS.md'), 'utf8')).toBe(legacy); + await fse.remove(path.join(first.repo, '.codebuddy/rules/teamai-context.md')); + const retry = await installProjectPrompt('workbuddy', []); + expect(retry.ack?.status).toBe('success'); + expect(await fse.readFile(path.join(first.repo, 'AGENTS.md'), 'utf8')).toBe('# Notes\n'); + }); + it('leaves the blocks an earlier release left for another installed tool that this prompt did not reach', async () => { const legacy = '# Team notes\n\n\nClaude\'s earlier selection\n\n'; const { repo, ack } = await installProjectPrompt('codebuddy', ['.codebuddy/skills', '.claude/skills'], { diff --git a/src/__tests__/pull-sync-truth.test.ts b/src/__tests__/pull-sync-truth.test.ts index e4d119159..fe466f140 100644 --- a/src/__tests__/pull-sync-truth.test.ts +++ b/src/__tests__/pull-sync-truth.test.ts @@ -143,6 +143,33 @@ describe('pull reports what reached the tool directory (#585)', () => { return vi.mocked(log.success).mock.calls.map(([msg]) => String(msg)); } + it('retains unresolved culture during migration while retiring delivered instruction blocks (#945)', async () => { + const projectRoot = path.join(tmpDir, 'project'); + localConfig.scope = 'project'; + localConfig.projectRoot = projectRoot; + localConfig.enabledAgents = ['claude']; + teamConfig.toolPaths.claude.claudemd = '.claude/CLAUDE.md'; + vi.mocked(detectProjectConfig).mockResolvedValue(localConfig); + const legacy = path.join(projectRoot, '.claude/CLAUDE.md'); + await fse.outputFile(legacy, '# Mine\n\nworking culture\n\n\nold prompt\n\n'); + // A directory makes the read fail deterministically, including as root. + await fse.ensureDir(path.join(repoPath, 'culture.md')); + await fse.outputFile(path.join(repoPath, 'claudemd', 'shared.md'), 'new prompt'); + + await pull({ force: true }); + + const retained = await fse.readFile(legacy, 'utf8'); + expect(retained).toContain('working culture'); + expect(retained).not.toContain('old prompt'); + expect(await fse.readFile(path.join(projectRoot, '.claude/rules/teamai-context.md'), 'utf8')).toContain('new prompt'); + + await fse.remove(path.join(repoPath, 'culture.md')); + await fse.writeFile(path.join(repoPath, 'culture.md'), '---\ncompany:\n name: Acme\n---\n\nrestored culture'); + await pull({ force: true }); + expect(await fse.readFile(legacy, 'utf8')).toBe('# Mine\n'); + expect(await fse.readFile(path.join(projectRoot, '.claude/rules/teamai-context.md'), 'utf8')).toContain('restored culture'); + }); + it.each(['copy', 'prune', 'unsafe destination', 'unreadable source', 'realpath'])( 'does not report a successful docs sync after %s fails, and retries on the next pull', async (failure) => { // Simulate a force-pull of an already-synced revision: failure must clear diff --git a/src/__tests__/uninstall.test.ts b/src/__tests__/uninstall.test.ts index bd5932113..50756624f 100644 --- a/src/__tests__/uninstall.test.ts +++ b/src/__tests__/uninstall.test.ts @@ -572,6 +572,28 @@ describe('uninstall', () => { expect(await fse.pathExists(repoPath)).toBe(true); }); + it.each(['pi', 'omp', 'hermes', 'codex'])('keeps project state when WorkBuddy is removed and global %s remains', async (tool) => { + const homeDir = path.join(tmpDir, 'home'); + const projectRoot = path.join(tmpDir, 'project'); + const repoPath = path.join(projectRoot, '.teamai', 'team-repo'); + vi.stubEnv('HOME', homeDir); + vi.stubEnv('HERMES_HOME', path.join(homeDir, '.hermes')); + await fse.ensureDir(path.join(homeDir, `.${tool}`)); + await fse.outputFile(path.join(projectRoot, '.codebuddy/rules/teamai-context.md'), `${TEAMAI_CLAUDEMD_START}\nold prompt\n${TEAMAI_CLAUDEMD_END}\n`); + await fse.ensureDir(path.join(projectRoot, '.workbuddy')); + const configFile = path.join(projectRoot, '.teamai', 'config.yaml'); + await fse.outputFile(configFile, 'scope: project\n'); + const teamConfig = TeamaiConfigSchema.parse({ team: 't', repo: 'https://example.invalid/t.git' }); + const localConfig = makeLocalConfig(homeDir, repoPath, { scope: 'project', projectRoot, enabledAgents: ['workbuddy', tool] }); + mockAutoDetectInit.mockResolvedValue({ localConfig, teamConfig }); + + await uninstall({ force: true, agent: 'workbuddy' }); + + expect(await fse.pathExists(configFile)).toBe(true); + expect(mockSaveLocalConfigForScope).toHaveBeenCalledWith(expect.objectContaining({ enabledAgents: [tool], disabledAgents: ['workbuddy'] }), 'project', projectRoot); + expect(await fse.pathExists(path.join(projectRoot, '.codebuddy/rules/teamai-context.md'))).toBe(false); + }); + it('user-scope Pi uninstall removes a server-pushed agent hook even without the main extension', async () => { const { homeDir, repoPath } = await setupFixture(tmpDir); vi.stubEnv('HOME', homeDir); diff --git a/src/instruction-targets.ts b/src/instruction-targets.ts index f14b1ae98..2831ac030 100644 --- a/src/instruction-targets.ts +++ b/src/instruction-targets.ts @@ -715,6 +715,8 @@ export async function planInstructionFiles( targets: readonly InstructionTarget[], blocks: InstructionBlocks, stale: readonly InstructionTarget[] = [], + /** Only retire block types whose replacements were resolved and delivered. */ + retiredBlocks?: InstructionBlocks, ): Promise { const warnings: string[] = []; const changes: InstructionFileChange[] = []; @@ -730,9 +732,17 @@ export async function planInstructionFiles( if (change) changes.push(change); files.push({ path: target.path, status: warnings.length > before ? 'blocked' : change ? 'planned' : 'current' }); } + const cleanupBlocks = retiredBlocks === undefined ? STALE_BLOCKS : [ + ...retiredBlocks.culture !== undefined ? [CULTURE] : [], + ...retiredBlocks.claudemd !== undefined ? [CLAUDEMD] : [], + ...retiredBlocks.recall !== undefined && retiredBlocks.directRecall !== undefined ? [RECALL] : [], + // Old combined rule blocks have no independently resolved replacement. + ...retiredBlocks.culture !== undefined && retiredBlocks.claudemd !== undefined + && retiredBlocks.recall !== undefined && retiredBlocks.directRecall !== undefined ? [LEGACY_RULES, TEAM_RULES] : [], + ]; for (const file of stale) { const before = warnings.length; - const change = await planFile(file, STALE_BLOCKS.map((pair) => [pair, null] as const), 'cleanup', warnings); + const change = await planFile(file, cleanupBlocks.map((pair) => [pair, null] as const), 'cleanup', warnings); if (change) changes.push(change); files.push({ path: file.path, status: warnings.length > before ? 'blocked' : change ? 'planned' : 'current' }); } diff --git a/src/local-agent.ts b/src/local-agent.ts index d5a0e39ee..06aed6737 100644 --- a/src/local-agent.ts +++ b/src/local-agent.ts @@ -2206,7 +2206,37 @@ async function syncClaudemd( reached.push(tool); } - const cleanup = await planInstructionFiles([], {}, await retiredFilesOfReached(fullTeamConfig, localConfig, reached)); + // Commands deliver to one tool at a time, but earlier commands may already + // have reached the other writers. Verify current destinations rather than + // forgetting those deliveries or trusting a receipt for an older prompt. + const resolved = await resolveInstructionTargets(fullTeamConfig, localConfig); + for (const hook of resolved.hooks) { + if (!reached.includes(hook.tool) && !await hookDeliveryProblem(fullTeamConfig, localConfig, hook.tool, block)) { + reached.push(hook.tool); + } + } + for (const target of resolved.targets) { + if (target.tools.every((tool) => reached.includes(tool))) continue; + // A failed write in this command cannot become a previous delivery. + if (target.tools.some((tool) => teamConfig.toolPaths[tool] && !reached.includes(tool))) continue; + try { + await readFileIfExists(target.path); + } catch (error) { + log.debug(`local-agent: retained retired instructions because ${target.path} could not be verified: ${(error as Error).message}`); + continue; + } + const verification = await planInstructionFiles([target], { claudemd: block }); + if (verification.files[0]?.status !== 'current') continue; + for (const tool of target.tools) { + if (tool === 'opencode' && block) { + const { opencodeContextReference, readOpencodeInstructionList } = await import('./resources/opencode-config.js'); + const { config, entry } = opencodeContextReference(target.path, localConfig.scope, resolveToolBaseDir(tool, localConfig)); + if (!(await readOpencodeInstructionList(config))?.includes(entry)) continue; + } + reached.push(tool); + } + } + const cleanup = await planInstructionFiles([], {}, await retiredFilesOfReached(fullTeamConfig, localConfig, reached), { claudemd: block }); for (const warning of cleanup.warnings) log.warn(warning); const { report, failures } = await applyInstructionPlan(cleanup, { dryRun: false }); for (const line of report) log.info(`${line}: no installed tool loads them from this file`); diff --git a/src/pull.ts b/src/pull.ts index a36dcb754..231adcf86 100644 --- a/src/pull.ts +++ b/src/pull.ts @@ -2597,7 +2597,7 @@ async function reconcileHooksAllScopes( delivery.reached.push(hook.tool); } } - const cleanup = await planInstructionFiles([], {}, await retiredFilesOfReached(teamConfig, localConfig, delivery.reached)); + const cleanup = await planInstructionFiles([], {}, await retiredFilesOfReached(teamConfig, localConfig, delivery.reached), delivery.blocks); for (const warning of cleanup.warnings) log.warn(`[${localConfig.scope}] ${warning}`); const applied = await applyInstructionPlan(cleanup, { dryRun: Boolean(options.dryRun) }); for (const line of applied.report) log.info(`${options.dryRun ? '[dry-run]' : `[${localConfig.scope}]`} ${line}: replacement instructions are ready`); diff --git a/src/uninstall.ts b/src/uninstall.ts index b646fd003..2a43eeca3 100644 --- a/src/uninstall.ts +++ b/src/uninstall.ts @@ -53,7 +53,7 @@ import { } from './builtin-skills.js'; import { getHermesHome } from './hermes-home.js'; import { CODEX_TOOL, SHARED_AGENT_SKILLS_PATH } from './resources/skills.js'; -import { clearInstructionFile, instructionTargetFile, retiredInstructionFiles } from './instruction-targets.js'; +import { clearInstructionFile, instructionTargetFile, retiredInstructionFiles, resolveInstructionTargets } from './instruction-targets.js'; import { pathExists, readFileSafe, @@ -666,7 +666,9 @@ async function buildRemovalPlan( // tool that was never enabled or set up. The probe path must be a // tool-specific root (skills/rules/settings), never `claudemd`: that's // exactly the shared, ambiguous path this check exists to disambiguate. - const activeTools = new Set(); + const { hooks: instructionHooks } = await resolveInstructionTargets(teamConfig, localConfig); + const hookTools = new Set(instructionHooks.map((hook) => hook.tool)); + const activeTools = new Set(hookTools); for (const [tool, toolPath] of Object.entries(toolPaths)) { if (isAgentExcluded(localConfig, tool)) continue; const probePath = toolPath.skills ?? toolPath.rules ?? toolPath.settings ?? toolPath.claudemd; @@ -684,7 +686,7 @@ async function buildRemovalPlan( const targetHasResources = targetRes ? hasToolResources(targetRes) : false; // Other tools still have teamai resources → keep shared resources. const othersHaveResources = [...perTool.entries()] - .some(([t, r]) => t !== agentFilter && activeTools.has(t) && hasToolResources(r)); + .some(([t, r]) => t !== agentFilter && activeTools.has(t) && (hasToolResources(r) || hookTools.has(t))); // Remove shared resources only when the target itself has resources AND is // the last tool using teamai. Targeting a tool with no teamai resources is a // no-op for shared resources (plan will be empty → "Nothing to uninstall"). From 0f5c3e6cb149f52e88d67c545fb24d28996e8fd0 Mon Sep 17 00:00:00 2001 From: Saul Moro Date: Fri, 2 Oct 2026 14:31:20 +0200 Subject: [PATCH 36/41] fix(instructions): preserve global Codex hooks and activation ownership (#945) --- docs/usage-guide.md | 8 +- docs/usage-guide.zh-CN.md | 8 +- skill-data/core/references/troubleshooting.md | 9 +- skill-data/setup/references/uninstall.md | 7 +- src/__tests__/e2e/instruction-targets.test.ts | 23 +++++ src/__tests__/instruction-targets.test.ts | 83 +++++++++++++++++++ src/__tests__/uninstall.test.ts | 41 +++++++++ src/instruction-targets.ts | 26 ++++-- src/uninstall.ts | 8 +- 9 files changed, 193 insertions(+), 20 deletions(-) diff --git a/docs/usage-guide.md b/docs/usage-guide.md index ddde76e65..8ca1a4e4a 100644 --- a/docs/usage-guide.md +++ b/docs/usage-guide.md @@ -1674,11 +1674,11 @@ The CodeBuddy and WorkBuddy rule files carry `alwaysApply: true`, which CodeBudd In a project, teamai installs the Hermes plugin `$HERMES_HOME/plugins/teamai-instructions/` and adds it to `plugins.enabled` in `$HERMES_HOME/config.yaml` (a name you list under `plugins.disabled` stays off). A plugin of that name teamai did not write is left alone, also on uninstall, and `teamai pull` and `teamai doctor` say so. According to Hermes' documentation it builds the section once for each new session from the session's directory and keeps it through compression and resume. A section holds at most 4,000 characters, and all plugin sections together at most 8,000. When this member's instructions for the project are longer, Hermes skips them and `teamai pull` says so: teamai does not cut them or write them to `AGENTS.md`. Outside a project the section is empty, and Hermes may log that it skipped an empty section. -OpenCode loads a file only when its config lists it in `instructions`. teamai adds that entry only when the target already matches the desired blocks or its update succeeds. A malformed target or a failed write does not activate stale blocks. A failed edit keeps an existing instructions entry, including when malformed recall markers prevent a recall toggle. It keeps your other entries and keys; the root `opencode.json` and OpenCode's own `AGENTS.md` files are left alone. While `~/.config/opencode/AGENTS.md` does not exist, OpenCode reads `~/.claude/CLAUDE.md` instead; when Claude Code gets the user blocks there, OpenCode already has them, so teamai writes no second user copy for OpenCode and says so in the pull output. Blocks left there by a Claude Code you excluded count too, since OpenCode reads them all the same; the pull then warns that nothing keeps them current. A config file teamai cannot parse as JSON (for example one with comments) is left unchanged with a warning; add the entry by hand. +OpenCode loads a file only when its config lists it in `instructions`. teamai adds that entry only when the target already matches the desired blocks or its update succeeds. A malformed target or a failed write does not activate stale blocks. A failed edit keeps an existing instructions entry, including when malformed recall markers prevent a recall toggle. TeamAI saves ownership before adding a new config entry; a failed state write prevents activation, and a failed config write can be retried. Entries you already listed remain yours. It keeps your other entries and keys; the root `opencode.json` and OpenCode's own `AGENTS.md` files are left alone. While `~/.config/opencode/AGENTS.md` does not exist, OpenCode reads `~/.claude/CLAUDE.md` instead; when Claude Code gets the user blocks there, OpenCode already has them, so teamai writes no second user copy for OpenCode and says so in the pull output. Blocks left there by a Claude Code you excluded count too, since OpenCode reads them all the same; the pull then warns that nothing keeps them current. A config file teamai cannot parse as JSON (for example one with comments) is left unchanged with a warning; add the entry by hand. Oh My Pi reads `RULES.md` as an always-applied rule beside its single user context file. In project scope teamai's OMP extension asks `teamai` for the blocks when the session starts and adds them to each turn's system prompt, from the project root and any subdirectory. Without that extension (for example with hooks removed), an Oh My Pi project session gets no team blocks. Prompts the HTTP local agent delivers for a project reach Pi, Oh My Pi and Hermes the same way, through their extension or plugin, and the Codex family through its session-start and subagent-start hooks. Pi and Oh My Pi wait for foreground session-start dispatch, including HTTP prompt sync, before caching the project instructions for the first prompt. Codex reads its HTTP prompt cache after the same sync, before returning SessionStart context. -A pull from an earlier release may have left these blocks in a file listed below. A pull removes each block only after its replacement was resolved and delivered to every installed tool that wrote that file. An unreadable or invalid culture source keeps the old culture block even if shared instructions and recall sync successfully. Failed target writes, foreign files, missing extensions or disabled plugins keep the old blocks for a retry. Excluded tools' retired files stay unchanged. +A pull from an earlier release may have left these blocks in a file listed below. A pull removes each block only after its replacement was resolved and delivered to every installed tool that wrote that file. An unreadable or invalid culture source keeps the old culture block even if shared instructions and recall sync successfully. Failed target writes, foreign files, missing extensions or disabled plugins keep the old blocks for a retry. Excluded tools' current and retired files stay unchanged and are excluded from doctor's stale-instruction check. HTTP prompt commands verify the current destinations of all installed former writers, including delivery from previous commands, before removing the retired shared-instructions block. A destination holding an older prompt does not count as delivered. HTTP cleanup preserves culture and recall blocks, which those commands do not replace. @@ -2866,13 +2866,13 @@ What gets removed: An instructions file several tools map is cleaned per block: a teamai block stays while a remaining tool on that file still writes it. The common case is `.codebuddy/rules/teamai-context.md`, which CodeBuddy and WorkBuddy share: `--agent workbuddy` keeps it while CodeBuddy is installed. A file an earlier release wrote the blocks to, such as the project `AGENTS.md`, is read by no tool now, so its teamai blocks go and your own text stays. A file teamai created goes with its last block; an instructions file you had before stays, even an empty one. A configured `claudemd` remains a member file even when its basename is `teamai-context.md`. -Shared resources (the env block, docs directory, and `~/.teamai/`) are removed **only when the target itself has teamai resources AND is the last tool still using teamai** — otherwise they are kept for the remaining tools. Targeting a tool with no local resources leaves shared resources in place, even if it is the only tool. Project uninstall still records the exclusion for Pi, Oh My Pi and Hermes, whose instruction channels are global. +Shared resources (the env block, docs directory, and `~/.teamai/`) are removed **only when the target itself has teamai resources AND is the last tool still using teamai** — otherwise they are kept for the remaining tools. Targeting a tool with no local resources leaves shared resources in place, even if it is the only tool. Project uninstall still records the exclusion for Pi, Oh My Pi, Hermes and the Codex family, whose instruction channels are global. An enabled, installed Pi, Oh My Pi, Hermes or project Codex keeps the project state in use through its global delivery channel, even without a project-local tool directory. Uninstalling another tool preserves that state so the remaining tool can still deliver this project's instructions. If removing an OpenCode entry added by teamai fails, uninstall exits with an error and keeps the shared data directory and ownership record, even when OpenCode is the last tool. Repair the config or its permissions, then retry the same uninstall command. -Project uninstall keeps Pi's and Oh My Pi's global extensions and Hermes' global plugin and configuration, which other projects use. A targeted uninstall excludes the tool in this project's config when that config survives. User-scope uninstall removes these global delivery channels. +Project uninstall keeps Pi's and Oh My Pi's global extensions, Hermes' global plugin and configuration, and the Codex family's user-level hooks, which other projects use. Targeted project Codex uninstall keeps the project config to record its exclusion and removes only project-owned resources and legacy hook copies. A targeted uninstall excludes the tool in this project's config when that config survives. User-scope uninstall removes these global delivery channels. The exclusion is durable: `uninstall --agent ` drops the tool from `enabledAgents` and records it in `disabledAgents`, so a later `pull` (or another tool's session-start hook) will not resurrect its skills, rules, agents, team instruction blocks, or hooks. Running `init --agent ` again clears the exclusion and re-enables sync for that tool. diff --git a/docs/usage-guide.zh-CN.md b/docs/usage-guide.zh-CN.md index fb79cf786..d99cb7082 100644 --- a/docs/usage-guide.zh-CN.md +++ b/docs/usage-guide.zh-CN.md @@ -1552,11 +1552,11 @@ CodeBuddy 和 WorkBuddy 的规则文件带有 `alwaysApply: true`,CodeBuddy 在项目中,teamai 安装 Hermes 插件 `$HERMES_HOME/plugins/teamai-instructions/`,并把它加入 `$HERMES_HOME/config.yaml` 的 `plugins.enabled`(列在 `plugins.disabled` 中的名称保持关闭)。同名但并非 teamai 写入的插件保持不变,卸载时也一样,`teamai pull` 和 `teamai doctor` 会指出这一点。根据 Hermes 的文档,它在每个新会话开始时根据会话目录生成该段落,并在压缩和恢复后保留。一个段落最多 4,000 个字符,所有插件段落合计最多 8,000 个字符。当该成员在此项目的指令更长时,Hermes 会跳过它们,`teamai pull` 会给出提示:teamai 不会截断它们,也不会写入 `AGENTS.md`。在项目之外段落为空,Hermes 可能记录它跳过了一个空段落。 -OpenCode 只加载配置中 `instructions` 列出的文件。仅当目标已包含所需的块或更新成功时,teamai 才添加该条目;标记不完整或写入失败时,不会激活旧块。编辑失败会保留已有的 instructions 条目,recall 标记不完整导致切换失败时也一样。teamai 只添加这一项,并保留你的其他条目和键;根目录的 `opencode.json` 以及 OpenCode 自己的 `AGENTS.md` 文件保持不变。当 `~/.config/opencode/AGENTS.md` 不存在时,OpenCode 会改为读取 `~/.claude/CLAUDE.md`;若 Claude Code 的用户块已在那里,OpenCode 已经获得它们,因此 teamai 不会为 OpenCode 再写一份用户副本,并在 pull 输出中说明。被排除的 Claude Code 留在那里的块同样算数,因为 OpenCode 照样读取它们;此时 pull 会警告没有任何工具再更新它们。teamai 无法按 JSON 解析的配置文件(例如带注释的文件)保持不变并给出警告,请手动添加该条目。 +OpenCode 只加载配置中 `instructions` 列出的文件。仅当目标已包含所需的块或更新成功时,teamai 才添加该条目;标记不完整或写入失败时,不会激活旧块。编辑失败会保留已有的 instructions 条目,recall 标记不完整导致切换失败时也一样。TeamAI 在添加配置条目之前保存所有权;状态写入失败时不会激活条目,配置写入失败时可以重试。你已列出的条目仍属于你。teamai 只添加这一项,并保留你的其他条目和键;根目录的 `opencode.json` 以及 OpenCode 自己的 `AGENTS.md` 文件保持不变。当 `~/.config/opencode/AGENTS.md` 不存在时,OpenCode 会改为读取 `~/.claude/CLAUDE.md`;若 Claude Code 的用户块已在那里,OpenCode 已经获得它们,因此 teamai 不会为 OpenCode 再写一份用户副本,并在 pull 输出中说明。被排除的 Claude Code 留在那里的块同样算数,因为 OpenCode 照样读取它们;此时 pull 会警告没有任何工具再更新它们。teamai 无法按 JSON 解析的配置文件(例如带注释的文件)保持不变并给出警告,请手动添加该条目。 Oh My Pi 把 `RULES.md` 作为始终应用的规则读取,与其唯一的用户上下文文件并存。在项目范围内,teamai 的 OMP 扩展在会话开始时向 `teamai` 获取这些块,并加入每轮的系统提示,无论会话从项目根目录还是子目录启动。没有该扩展时(例如移除了 hooks),Oh My Pi 的项目会话不会获得团队块。HTTP local agent 为项目下发的 prompt 也以同样方式,通过扩展或插件到达 Pi、Oh My Pi 和 Hermes,并通过 session-start 和 subagent-start hook 到达 Codex 系列。Pi 和 Oh My Pi 会等待前台 session-start 派发(包括 HTTP prompt 同步)完成,再为首个 prompt 缓存项目指令。Codex 也会在该同步完成后读取 HTTP prompt 缓存,再返回 SessionStart 上下文。 -早期版本的 pull 可能把这些块留在下列文件中。只有某个区块的替代内容已成功解析并投递给曾写入该文件的每个已安装工具后,pull 才移除该旧区块。文化源文件不可读或无效时,即使共享指令和 recall 已成功同步,旧文化区块仍会保留。目标写入失败、文件并非 teamai 所有、扩展缺失或插件被禁用时,旧块保留以便重试。被排除工具的旧指令文件保持不变。 +早期版本的 pull 可能把这些块留在下列文件中。只有某个区块的替代内容已成功解析并投递给曾写入该文件的每个已安装工具后,pull 才移除该旧区块。文化源文件不可读或无效时,即使共享指令和 recall 已成功同步,旧文化区块仍会保留。目标写入失败、文件并非 teamai 所有、扩展缺失或插件被禁用时,旧块保留以便重试。被排除工具的当前和旧指令文件保持不变,并且不纳入 doctor 的旧指令检查。 HTTP prompt 命令会检查所有已安装的旧写入工具的当前目标,包括先前命令的投递结果,确认后才移除旧共享指令区块。目标仍含旧 prompt 时不算投递成功。HTTP 清理保留文化和 recall 区块,因为这些命令不替换它们。 @@ -2677,13 +2677,13 @@ teamai uninstall --agent claude 多个工具共同映射的指令文件按区块清理:只要该文件上仍有剩余工具会写入某个 teamai 区块,该区块就保留。最常见的是 CodeBuddy 与 WorkBuddy 共用的 `.codebuddy/rules/teamai-context.md`:只要 CodeBuddy 仍已安装,`--agent workbuddy` 就会保留它。早期版本写过这些块的文件(例如项目 `AGENTS.md`)现在没有任何工具读取,因此其中的 teamai 区块会被移除,你自己的内容保留。teamai 创建的文件随最后一个区块一起删除;你原有的指令文件(即使是空文件)会保留。配置的 `claudemd` 即使名为 `teamai-context.md`,也仍是成员文件。 -跨工具共享资源(shell profile env 块、docs 目录、`~/.teamai/`)**仅当该工具自身存在 teamai 资源、且它是最后一个仍在使用 teamai 的工具时**才一并移除,否则会为其余工具保留。没有本地资源的工具即使是唯一的工具,定向卸载也会保留共享资源。Pi、Oh My Pi 和 Hermes 的指令通道位于全局,因此项目级卸载仍会记录对它们的排除设置。 +跨工具共享资源(shell profile env 块、docs 目录、`~/.teamai/`)**仅当该工具自身存在 teamai 资源、且它是最后一个仍在使用 teamai 的工具时**才一并移除,否则会为其余工具保留。没有本地资源的工具即使是唯一的工具,定向卸载也会保留共享资源。Pi、Oh My Pi、Hermes 和 Codex 系列的指令通道位于全局,因此项目级卸载仍会记录对它们的排除设置。 已启用且已安装的 Pi、Oh My Pi、Hermes 或项目级 Codex 通过全局投递通道继续使用项目状态,即使项目中没有该工具的本地目录。卸载其他工具时会保留此状态,以便剩余工具继续投递该项目的指令。 若删除 teamai 添加的 OpenCode 条目失败,卸载以错误状态退出,并保留共享数据目录和所有权记录,即使 OpenCode 是最后一个工具。修复配置或权限后,重试同一卸载命令。 -项目级卸载保留 Pi 和 Oh My Pi 的全局扩展,以及 Hermes 的全局插件和配置,其他项目仍会使用它们。单工具卸载在项目配置仍保留时,将该工具加入此项目的排除列表。用户级卸载才移除这些全局投递通道。 +项目级卸载保留 Pi 和 Oh My Pi 的全局扩展、Hermes 的全局插件和配置,以及 Codex 系列的用户级 hooks,其他项目仍会使用它们。定向项目级 Codex 卸载保留项目配置以记录排除设置,仅清理项目拥有的资源和旧 hook 副本。单工具卸载在项目配置仍保留时,将该工具加入此项目的排除列表。用户级卸载才移除这些全局投递通道。 该排除是持久的:`uninstall --agent ` 会把该工具从 `enabledAgents` 移除并记入 `disabledAgents`,因此之后的 `pull`(或其他工具的 session-start hook)不会再把它的 skills、rules、agents、团队指令块或 hooks 重新装回。重新执行 `init --agent ` 会清除该排除、恢复对该工具的同步。 diff --git a/skill-data/core/references/troubleshooting.md b/skill-data/core/references/troubleshooting.md index ecd4c5c17..ea379c394 100644 --- a/skill-data/core/references/troubleshooting.md +++ b/skill-data/core/references/troubleshooting.md @@ -235,7 +235,8 @@ marker, then run `teamai pull` again. Other files can still sync successfully. Pull keeps retired instruction blocks until every installed tool that wrote the file has a working replacement. Repair the named target, extension or plugin -and run `teamai pull` again. Excluded tools' retired files stay unchanged. +and run `teamai pull` again. Excluded tools' current and retired files stay +unchanged and are excluded from doctor's stale-instruction check. If a block's source cannot be resolved, its old block stays even when other blocks sync. Repair the source and pull again to complete its migration. HTTP prompt commands verify earlier deliveries against the current prompt @@ -243,3 +244,9 @@ before cleaning shared instructions. Older destination contents do not count; culture and recall stay because HTTP prompt commands do not replace them. An HTTP prompt sync that cannot clean retired blocks reports a failed ACK and keeps its previous cache and manifest for the server's retry. + +OpenCode registration saves ownership before activating a new config entry. +If the state write fails, repair the state directory's permissions and retry +`teamai pull`; the entry is not activated without its removal ownership. +If the config write fails, ownership stays available for retry. Entries the +member already listed are never claimed. diff --git a/skill-data/setup/references/uninstall.md b/skill-data/setup/references/uninstall.md index eb8165c55..405545d58 100644 --- a/skill-data/setup/references/uninstall.md +++ b/skill-data/setup/references/uninstall.md @@ -66,8 +66,11 @@ and give it your team repo URL."* - If an OpenCode config entry cannot be removed, repair its config or permissions and retry the same uninstall command. Uninstall reports failure and keeps its ownership record and shared data directory, even for the last tool. -- Project uninstall keeps the global Pi and Oh My Pi extensions and Hermes - plugin and config for other projects. User-scope uninstall removes them. +- Project uninstall keeps the global Pi and Oh My Pi extensions, Hermes + plugin and config, and the Codex family's user-level hooks for other projects. + Targeted project Codex uninstall keeps project config and records its + exclusion, even without local resources. Legacy project hook copies go. + User-scope uninstall removes these global channels. - An enabled, installed Pi, Oh My Pi, Hermes or project Codex also keeps the project's shared state in use without a local tool directory. Uninstalling another tool preserves that state and the remaining tool's instructions. diff --git a/src/__tests__/e2e/instruction-targets.test.ts b/src/__tests__/e2e/instruction-targets.test.ts index 7f1c27416..46584327b 100644 --- a/src/__tests__/e2e/instruction-targets.test.ts +++ b/src/__tests__/e2e/instruction-targets.test.ts @@ -635,6 +635,29 @@ describe('instruction block targets on real CLI pull (#945)', () => { console.log('uninstall Pi from project A: global extension unchanged; project B instructions still delivered; project A empty'); }); + it.each([true, false])('preserves global Codex delivery across two projects on project uninstall, targeted: %s', async (targeted) => { + const sandbox = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'teamai-945-codex-uninstall-'))); + sandboxes.push(sandbox); + const fixture = makeTeamAndProject(sandbox); + const developer = makeProjectMember(sandbox, fixture, 'dev', 'developer', []); + const product = { ...makeProjectMember(sandbox, fixture, 'pm', 'product', []), home: developer.home }; + fs.mkdirSync(path.join(developer.home, '.codex'), { recursive: true }); + for (const member of [developer, product]) { + fs.appendFileSync(path.join(member.projectRoot, '.teamai/config.yaml'), '\nenabledAgents: [codex]\n'); + const result = await pullAs(member); + expect(result.code, result.output).toBe(0); + } + const hooks = path.join(developer.home, '.codex/hooks.json'); + const before = fs.readFileSync(hooks, 'utf8'); + const removed = await runCLI(['uninstall', '--force', ...targeted ? ['--agent', 'codex'] : []], { HOME: developer.home }, developer.projectRoot); + expect(removed.code, removed.output).toBe(0); + expect(fs.readFileSync(hooks, 'utf8')).toBe(before); + expect(await sessionInstructions('codex', product.home, product.projectRoot)).toContain('PRODUCT-SENTINEL'); + expect(await sessionInstructions('codex', developer.home, developer.projectRoot)).toBe(''); + if (targeted) expect(fs.readFileSync(memberData(developer).config, 'utf8')).toContain('codex'); + console.log(`Codex project ${targeted ? 'targeted' : 'full'} uninstall: global hooks unchanged; project B delivered; project A empty`); + }); + it('gives Hermes its project blocks through its plugin and frees the project AGENTS.md', async () => { const sandbox = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'teamai-945-e2e-'))); sandboxes.push(sandbox); diff --git a/src/__tests__/instruction-targets.test.ts b/src/__tests__/instruction-targets.test.ts index b06683012..5e02f942a 100644 --- a/src/__tests__/instruction-targets.test.ts +++ b/src/__tests__/instruction-targets.test.ts @@ -553,6 +553,85 @@ describe('a configured claudemd named like teamai\'s file (#945)', () => { }); describe('OpenCode instructions ownership (#945)', () => { + it.each(['state', 'config'])('keeps OpenCode registration retryable after a %s write fails', async (failure) => { + const root = fs.realpathSync.native(fs.mkdtempSync(path.join(os.tmpdir(), 'teamai-945-ocretry-'))); + vi.stubEnv('HOME', path.join(root, 'home')); + try { + const projectRoot = path.join(root, 'project'); + const context = path.join(projectRoot, '.opencode/teamai-context.md'); + fs.mkdirSync(path.dirname(context), { recursive: true }); + fs.writeFileSync(context, `${claudemd('team')}\n`); + const config = path.join(projectRoot, '.opencode/opencode.json'); + fs.writeFileSync(config, JSON.stringify({ instructions: ['docs/style.md'] })); + const localConfig = { + repo: { localPath: path.join(root, 'repo'), remote: 'https://example.invalid/t.git' }, + username: 'u', additionalRoles: [], scope: 'project', projectRoot, dataHome: path.join(root, 'data'), enabledAgents: ['opencode'], + } as unknown as LocalConfig; + const teamConfig = TeamaiConfigSchema.parse({ team: 't', repo: 'https://example.invalid/t.git' }); + const resolved = await resolveInstructionTargets(teamConfig, localConfig); + const files = [{ path: context, status: 'current' as const }]; + const rename = fse.rename.bind(fse); + const failedPath = failure === 'state' ? path.join(root, 'data/state.json') : config; + const write = vi.spyOn(fse, 'rename').mockImplementation(async (from, to) => { + if (String(to) === failedPath) throw new Error(`EACCES ${failure}`); + return rename(from, to); + }); + await expect(registerOpencodeContext(teamConfig, localConfig, resolved, false, files)).rejects.toThrow('EACCES'); + // A failed ownership save must never leave an unowned active entry. + expect(JSON.parse(fs.readFileSync(config, 'utf8')).instructions).toEqual(['docs/style.md']); + write.mockRestore(); + + await registerOpencodeContext(teamConfig, localConfig, resolved, false, files); + const { loadStateForScope } = await import('../config.js'); + expect((await loadStateForScope(localConfig)).opencodeContextEntries).toEqual([{ config, entry: '.opencode/teamai-context.md' }]); + fs.unlinkSync(context); + if (failure === 'state') { + const removalWrite = vi.spyOn(fse, 'rename').mockImplementation(async (from, to) => { + if (String(to) === failedPath) throw new Error('EACCES removal state'); + return rename(from, to); + }); + await expect(registerOpencodeContext(teamConfig, localConfig, { targets: [], stale: resolved.targets }, false, [])) + .rejects.toThrow('EACCES removal state'); + expect(JSON.parse(fs.readFileSync(config, 'utf8')).instructions).toEqual(['docs/style.md']); + expect((await loadStateForScope(localConfig)).opencodeContextEntries).toHaveLength(1); + removalWrite.mockRestore(); + } + await registerOpencodeContext(teamConfig, localConfig, { targets: [], stale: resolved.targets }, false, []); + expect(JSON.parse(fs.readFileSync(config, 'utf8')).instructions).toEqual(['docs/style.md']); + expect((await loadStateForScope(localConfig)).opencodeContextEntries).toEqual([]); + } finally { + vi.restoreAllMocks(); + vi.unstubAllEnvs(); + fs.rmSync(root, { recursive: true, force: true }); + } + }); + + it('does not report excluded Claude legacy blocks as a stale doctor delivery', async () => { + const root = fs.realpathSync.native(fs.mkdtempSync(path.join(os.tmpdir(), 'teamai-945-excluded-file-'))); + vi.stubEnv('HOME', path.join(root, 'home')); + try { + const projectRoot = path.join(root, 'project'); + const legacy = path.join(projectRoot, '.claude/CLAUDE.md'); + fs.mkdirSync(path.dirname(legacy), { recursive: true }); + const original = `${claudemd('excluded prompt')}\n`; + fs.writeFileSync(legacy, original); + const localConfig = { + repo: { localPath: path.join(root, 'repo'), remote: 'https://example.invalid/t.git' }, + username: 'u', additionalRoles: [], scope: 'project', projectRoot, disabledAgents: ['claude'], + } as unknown as LocalConfig; + const teamConfig = TeamaiConfigSchema.parse({ team: 't', repo: 'https://example.invalid/t.git' }); + const { buildInstructionDeliveryChecks } = await import('../doctor-delivery.js'); + const checks = await buildInstructionDeliveryChecks({ teamConfig, localConfig } as never); + const stale = checks.find((check) => check.name === 'No team instruction blocks are left in files no tool loads them from'); + expect(await stale!.check()).toBe(true); + expect((await resolveInstructionTargets(teamConfig, localConfig)).stale.map((target) => target.path)).not.toContain(legacy); + expect(fs.readFileSync(legacy, 'utf8')).toBe(original); + } finally { + vi.unstubAllEnvs(); + fs.rmSync(root, { recursive: true, force: true }); + } + }); + it('records the entry teamai adds, for uninstall, and forgets it once removed', async () => { const root = fs.realpathSync.native(fs.mkdtempSync(path.join(os.tmpdir(), 'teamai-945-ocown-'))); const prevHome = process.env.HOME; @@ -584,6 +663,10 @@ describe('OpenCode instructions ownership (#945)', () => { fs.writeFileSync(config, JSON.stringify({ instructions: ['.opencode/teamai-context.md'] })); await registerOpencodeContext(teamConfig, localConfig, { targets: [], stale: resolved.targets }, false, []); expect(JSON.parse(fs.readFileSync(config, 'utf8')).instructions).toEqual(['.opencode/teamai-context.md']); + // Even a generated file does not prove ownership of a member's entry. + fs.writeFileSync(context, `${claudemd('team')}\n`); + await registerOpencodeContext(teamConfig, localConfig, resolved, false, [{ path: context, status: 'current' }]); + expect((await loadStateForScope(localConfig)).opencodeContextEntries).toEqual([]); } finally { process.env.HOME = prevHome; fs.rmSync(root, { recursive: true, force: true }); diff --git a/src/__tests__/uninstall.test.ts b/src/__tests__/uninstall.test.ts index 50756624f..63f7fbfed 100644 --- a/src/__tests__/uninstall.test.ts +++ b/src/__tests__/uninstall.test.ts @@ -572,6 +572,47 @@ describe('uninstall', () => { expect(await fse.pathExists(repoPath)).toBe(true); }); + it.each(['codex', 'codex-internal', 'tcodex'].flatMap((tool) => [[tool, false], [tool, true]] as const))( + 'preserves global %s hooks while excluding only the targeted project, legacy copy: %s', async (tool, legacy) => { + const homeDir = path.join(tmpDir, 'home'); + const projectRoot = path.join(tmpDir, 'project'); + const repoPath = path.join(projectRoot, '.teamai/team-repo'); + vi.stubEnv('HOME', homeDir); + const actualHooks = await vi.importActual('../hooks.js'); + mockReconcileHooks.mockImplementation(actualHooks.reconcileHooks); + const globalHooks = path.join(homeDir, `.${tool}/hooks.json`); + await actualHooks.reconcileHooks(globalHooks, tool, []); + const before = await fse.readFile(globalHooks, 'utf8'); + const localConfig = makeLocalConfig(homeDir, repoPath, { scope: 'project', projectRoot, enabledAgents: [tool] }); + await fse.outputFile(path.join(projectRoot, '.teamai/config.yaml'), 'scope: project\n'); + const teamConfig = TeamaiConfigSchema.parse({ team: 't', repo: 'https://example.invalid/t.git' }); + mockAutoDetectInit.mockResolvedValue({ localConfig, teamConfig }); + const projectHooks = path.join(projectRoot, `.${tool}/hooks.json`); + if (legacy) await actualHooks.reconcileHooks(projectHooks, tool, [], { manifestPath: path.join(projectRoot, '.teamai/managed-hooks.json') }); + + await uninstall({ force: true, agent: tool }); + + expect(await fse.readFile(globalHooks, 'utf8')).toBe(before); + expect(await fse.pathExists(path.join(projectRoot, '.teamai/config.yaml'))).toBe(true); + expect(mockSaveLocalConfigForScope).toHaveBeenCalledWith(expect.objectContaining({ disabledAgents: [tool], enabledAgents: [] }), 'project', projectRoot); + if (legacy) expect(await actualHooks.hasTeamaiHooks(projectHooks, tool)).toBe(false); + }); + + it('still removes global Codex hooks on a user-scope uninstall', async () => { + const homeDir = path.join(tmpDir, 'home'); + const repoPath = path.join(homeDir, '.teamai/team-repo'); + vi.stubEnv('HOME', homeDir); + const actualHooks = await vi.importActual('../hooks.js'); + mockReconcileHooks.mockImplementation(actualHooks.reconcileHooks); + const globalHooks = path.join(homeDir, '.codex/hooks.json'); + await actualHooks.reconcileHooks(globalHooks, 'codex', []); + const localConfig = makeLocalConfig(homeDir, repoPath, { enabledAgents: ['codex'] }); + const teamConfig = TeamaiConfigSchema.parse({ team: 't', repo: 'https://example.invalid/t.git' }); + mockAutoDetectInit.mockResolvedValue({ localConfig, teamConfig }); + await uninstall({ force: true, agent: 'codex' }); + expect(await actualHooks.hasTeamaiHooks(globalHooks, 'codex')).toBe(false); + }); + it.each(['pi', 'omp', 'hermes', 'codex'])('keeps project state when WorkBuddy is removed and global %s remains', async (tool) => { const homeDir = path.join(tmpDir, 'home'); const projectRoot = path.join(tmpDir, 'project'); diff --git a/src/instruction-targets.ts b/src/instruction-targets.ts index 2831ac030..6cdfd580f 100644 --- a/src/instruction-targets.ts +++ b/src/instruction-targets.ts @@ -426,6 +426,14 @@ export async function resolveInstructionTargets( const toolPaths = scopedToolPaths(teamConfig, localConfig); for (const [tool, paths] of Object.entries(toolPaths)) { const entry = entryFor(tool, localConfig.scope); + const file = instructionTargetPath(tool, paths, localConfig); + if (isAgentExcluded(localConfig, tool)) { + if (file) inUse.add(file); + for (const retired of retiredInstructionFiles(tool, paths, localConfig.scope)) { + inUse.add(path.resolve(resolveToolBaseDir(tool, localConfig), retired)); + } + continue; + } if (entry?.hook) { // Codex's hooks are user-level, so a project without its own `.codex/` // still reaches an installed Codex. @@ -440,19 +448,13 @@ export async function resolveInstructionTargets( probeConfig = { ...localConfig, scope: 'user', toolRoots }; probePaths = scopedToolPaths(teamConfig, probeConfig)[tool] ?? paths; } - if (isAgentExcluded(localConfig, tool)) { - for (const file of retiredInstructionFiles(tool, paths, localConfig.scope)) { - inUse.add(path.resolve(resolveToolBaseDir(tool, localConfig), file)); - } - } else if (await isInstructionToolInstalled(tool, probePaths, probeConfig)) { + if (await isInstructionToolInstalled(tool, probePaths, probeConfig)) { hooks.push({ tool, recall: Boolean(paths.agents), limit: entry.hookLimit }); } continue; } - const file = instructionTargetPath(tool, paths, localConfig); if (!file || !await isInstructionToolInstalled(tool, paths, localConfig)) continue; inUse.add(file); - if (isAgentExcluded(localConfig, tool)) continue; const target = targets.get(file) ?? instructionTargetAt(tool, file, localConfig.scope, paths); // The subagent block only where every tool reading the file has the subagent. target.recall = Boolean(paths.agents) && (target.tools.length === 0 || target.recall); @@ -536,8 +538,16 @@ export async function registerOpencodeContext( if (listed === present) return null; return `Would ${present ? 'add' : 'remove'} "${entry}" ${present ? 'to' : 'from'} the instructions of ${config}`; } + const listed = await readOpencodeInstructionList(config); + if (present && !listed?.includes(entry) && (listed !== null || !await pathExists(config))) { + // Persist ownership before activation. If either write fails, retry can + // safely finish without claiming an entry the member already listed. + await recordOpencodeContextEntry(localConfig, { config, entry }, true); + } const changed = await reconcileOpencodeInstructions(config, entry, present, 'team instructions'); - if (changed) await recordOpencodeContextEntry(localConfig, { config, entry }, present); + if (!present && ((await readOpencodeInstructionList(config))?.includes(entry) === false || !await pathExists(config))) { + await recordOpencodeContextEntry(localConfig, { config, entry }, false); + } return changed ? `${present ? 'Added' : 'Removed'} "${entry}" ${present ? 'to' : 'from'} the instructions of ${config}` : null; } diff --git a/src/uninstall.ts b/src/uninstall.ts index 2a43eeca3..968bdb3f2 100644 --- a/src/uninstall.ts +++ b/src/uninstall.ts @@ -52,6 +52,7 @@ import { skillsGuardBase, } from './builtin-skills.js'; import { getHermesHome } from './hermes-home.js'; +import { CODEX_TOOL_IDS } from './utils/tool-names.js'; import { CODEX_TOOL, SHARED_AGENT_SKILLS_PATH } from './resources/skills.js'; import { clearInstructionFile, instructionTargetFile, retiredInstructionFiles, resolveInstructionTargets } from './instruction-targets.js'; import { @@ -414,6 +415,9 @@ async function discoverToolResources( // except for the legacy copy, written into by a CLI that knew // nothing about a member's relocated root, so it sits at the team path. for (const { baseDir: hookBaseDir, manifestPath } of hookTargets) { + // Other projects use Codex's user hooks as their instruction channel. + if (scope === 'project' && CODEX_TOOL_IDS.some((id) => id === tool) + && path.resolve(hookBaseDir) === path.resolve(getUserHome())) continue; const settingsRel = path.resolve(hookBaseDir) === path.resolve(getUserHome()) ? (hookSettingsPath ?? toolPath.settings) : toolPath.settings; @@ -691,6 +695,8 @@ async function buildRemovalPlan( // the last tool using teamai. Targeting a tool with no teamai resources is a // no-op for shared resources (plan will be empty → "Nothing to uninstall"). includeShared = targetHasResources && !othersHaveResources; + // Keep this project's config so the global hook can read its exclusion. + if (localConfig.scope === 'project' && CODEX_TOOL_IDS.some((id) => id === agentFilter)) includeShared = false; } else { toolsToMerge = [...perTool.keys()]; includeShared = true; @@ -1317,7 +1323,7 @@ export async function uninstall(opts: UninstallOptions): Promise { } if (isPlanEmpty(plan)) { - if (agentKey && localConfig.scope === 'project' && ['pi', 'omp', 'hermes'].includes(agentKey)) { + if (agentKey && localConfig.scope === 'project' && ['pi', 'omp', 'hermes', ...CODEX_TOOL_IDS].includes(agentKey)) { // The global channel belongs to other projects too; exclusion is this // project's removal even when there are no local files to delete. await excludeUninstalledAgent(localConfig, agentKey); From ebc86bb9c3b393dd6facf206cb636449b0ec175c Mon Sep 17 00:00:00 2001 From: Saul Moro Date: Fri, 2 Oct 2026 14:42:28 +0200 Subject: [PATCH 37/41] test(instructions): retry transient hook fixture cleanup (#945) --- src/__tests__/e2e/instruction-targets.test.ts | 6 +++++- src/__tests__/hermes-hooks.test.ts | 1 - 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/src/__tests__/e2e/instruction-targets.test.ts b/src/__tests__/e2e/instruction-targets.test.ts index 46584327b..46c6b4b36 100644 --- a/src/__tests__/e2e/instruction-targets.test.ts +++ b/src/__tests__/e2e/instruction-targets.test.ts @@ -219,7 +219,11 @@ describe('instruction block targets on real CLI pull (#945)', () => { }); afterEach(() => { - for (const dir of sandboxes.splice(0)) fs.rmSync(dir, { recursive: true, force: true }); + // SessionStart's detached pull/plugin workers may finish writing after the + // foreground hook exits. Retry transient ENOTEMPTY, as other hook E2Es do. + for (const dir of sandboxes.splice(0)) { + fs.rmSync(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); + } }); it('writes no ~/AGENTS.md when only Claude is installed', async () => { diff --git a/src/__tests__/hermes-hooks.test.ts b/src/__tests__/hermes-hooks.test.ts index 0cafc71dc..6aaef1d46 100644 --- a/src/__tests__/hermes-hooks.test.ts +++ b/src/__tests__/hermes-hooks.test.ts @@ -87,4 +87,3 @@ describe('the teamai-instructions plugin (#945)', () => { expect(config()).toContain('- teamai-instructions'); }); }); - From e779562db653f7e5fc047b942bd2c50d94e96275 Mon Sep 17 00:00:00 2001 From: Saul Moro Date: Fri, 2 Oct 2026 21:58:28 +0200 Subject: [PATCH 38/41] fix(uninstall): remove global adapters with the last install on the machine (#945) A project uninstall kept the Pi and OMP extensions, the Hermes plugin, Codex's user hooks and server-pushed agent hooks unconditionally, so that other projects keep their delivery channel. On a machine with no user scope and no other project, nothing removed them any more, unlike main: every Pi, OMP, Hermes or Codex session kept running teamai hook-dispatch after teamai was uninstalled. A project uninstall now keeps them only while the user config or another project partition exists, and the last install removes them. --- CHANGELOG.md | 2 +- docs/usage-guide.md | 2 +- docs/usage-guide.zh-CN.md | 2 +- skill-data/setup/references/uninstall.md | 4 +- src/__tests__/uninstall.test.ts | 47 +++++++++++++++++++++++ src/uninstall.ts | 48 +++++++++++++++++++----- 6 files changed, 91 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 21a5c33f1..0cf624b4d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ All notable changes to this project will be documented in this file. See [standa ### 💥 Breaking Changes -- **Upgrade every member together: team instructions leave the shared `AGENTS.md`.** `teamai pull` no longer writes the culture, `claudemd/` and recall blocks into the project's `AGENTS.md`, `~/AGENTS.md`, `~/.agents/AGENTS.md` or another tool's file, since members with different roles resolve different `claudemd/` selections and the shared file ended up holding whoever pulled last. Each installed tool gets them in its own file or session hook: Claude Code in `.claude/rules/teamai-context.md` (project) and `~/.claude/CLAUDE.md` (user); Cursor in `teamai-context.mdc` under `.cursor/rules` and `~/.cursor/rules`; CodeBuddy and WorkBuddy in one `.codebuddy/rules/teamai-context.md`, and WorkBuddy in `~/.workbuddy/rules/teamai-context.md`; OpenCode in `.opencode/teamai-context.md` and `~/.config/opencode/teamai-context.md`, listed in its `instructions`; Oh My Pi in `~/.omp/agent/RULES.md` and, in a project, through teamai's OMP extension; Pi through teamai's Pi extension in a project; Hermes in `$HERMES_HOME/SOUL.md` and, in a project, through a Hermes plugin teamai installs and enables. Copilot keeps `.github/copilot-instructions.md`. The first pull after updating removes the blocks earlier releases left in `AGENTS.md`, `~/AGENTS.md`, `.claude/CLAUDE.md`, `.codebuddy/CODEBUDDY.md`, `.omp/AGENTS.md`, `~/.omp/agent/AGENTS.md` and a moved tool's `claudemd` from the team's `toolPaths`, keeps everything else in those files, and names each file it changes; a block with a missing or repeated marker is left with a warning. A member still on an older release writes the blocks back into the shared files, so have everyone update. Pull writes only for installed tools, rewrites nothing that is current, removes the recall block when recall is disabled, and `--dry-run` lists the files it would change. Hermes skips project instructions over 4,000 characters, which pull and `teamai doctor` report. `teamai doctor` checks that each tool can load its instructions. A team `toolPaths` entry without `rules` keeps its configured `claudemd` for Claude Code, Cursor, CodeBuddy and WorkBuddy, and an entry with only `claudemd` is still delivered to. A team rule named `teamai-context` is not delivered, since it would land on teamai's own file, and a copy an earlier release delivered is removed unless the member changed it; neither a tombstone of that rule nor a namespaced placement of it reaches teamai's file. The HTTP local agent strips the old blocks of the tools a prompt reached, probes each tool as pull does, and its project prompts reach Pi, Oh My Pi and Hermes through their extension or plugin and the Codex family through its session hooks. It acks a prompt as failed, with the reason, when it reaches no tool: a target file teamai did not write, an extension or plugin that is missing, or text over Hermes' 4,000-character section. OpenCode counts `~/.claude/CLAUDE.md` as its fallback while that file holds teamai blocks, also from an excluded Claude Code, and pull warns when nothing keeps them current. A Hermes plugin named `teamai-instructions` or an Oh My Pi `teamai-hooks.ts` that teamai did not write is left alone, and a file CodeBuddy and WorkBuddy share gets the `teamai-recall` subagent block only when both have the subagent. teamai does not change `.gitignore`, `.git/info/exclude` or the index; a team that tracks a generated file such as `.github/copilot-instructions.md` still commits one member's selection (for [#945](https://github.com/Tencent/teamai-cli/issues/945)). +- **Upgrade every member together: team instructions leave the shared `AGENTS.md`.** `teamai pull` no longer writes the culture, `claudemd/` and recall blocks into the project's `AGENTS.md`, `~/AGENTS.md`, `~/.agents/AGENTS.md` or another tool's file, since members with different roles resolve different `claudemd/` selections and the shared file ended up holding whoever pulled last. Each installed tool gets them in its own file or session hook: Claude Code in `.claude/rules/teamai-context.md` (project) and `~/.claude/CLAUDE.md` (user); Cursor in `teamai-context.mdc` under `.cursor/rules` and `~/.cursor/rules`; CodeBuddy and WorkBuddy in one `.codebuddy/rules/teamai-context.md`, and WorkBuddy in `~/.workbuddy/rules/teamai-context.md`; OpenCode in `.opencode/teamai-context.md` and `~/.config/opencode/teamai-context.md`, listed in its `instructions`; Oh My Pi in `~/.omp/agent/RULES.md` and, in a project, through teamai's OMP extension; Pi through teamai's Pi extension in a project; Hermes in `$HERMES_HOME/SOUL.md` and, in a project, through a Hermes plugin teamai installs and enables. Copilot keeps `.github/copilot-instructions.md`. The first pull after updating removes the blocks earlier releases left in `AGENTS.md`, `~/AGENTS.md`, `.claude/CLAUDE.md`, `.codebuddy/CODEBUDDY.md`, `.omp/AGENTS.md`, `~/.omp/agent/AGENTS.md` and a moved tool's `claudemd` from the team's `toolPaths`, keeps everything else in those files, and names each file it changes; a block with a missing or repeated marker is left with a warning. A member still on an older release writes the blocks back into the shared files, so have everyone update. Pull writes only for installed tools, rewrites nothing that is current, removes the recall block when recall is disabled, and `--dry-run` lists the files it would change. Hermes skips project instructions over 4,000 characters, which pull and `teamai doctor` report. `teamai doctor` checks that each tool can load its instructions. A team `toolPaths` entry without `rules` keeps its configured `claudemd` for Claude Code, Cursor, CodeBuddy and WorkBuddy, and an entry with only `claudemd` is still delivered to. A team rule named `teamai-context` is not delivered, since it would land on teamai's own file, and a copy an earlier release delivered is removed unless the member changed it; neither a tombstone of that rule nor a namespaced placement of it reaches teamai's file. The HTTP local agent strips the old blocks of the tools a prompt reached, probes each tool as pull does, and its project prompts reach Pi, Oh My Pi and Hermes through their extension or plugin and the Codex family through its session hooks. It acks a prompt as failed, with the reason, when it reaches no tool: a target file teamai did not write, an extension or plugin that is missing, or text over Hermes' 4,000-character section. OpenCode counts `~/.claude/CLAUDE.md` as its fallback while that file holds teamai blocks, also from an excluded Claude Code, and pull warns when nothing keeps them current. A Hermes plugin named `teamai-instructions` or an Oh My Pi `teamai-hooks.ts` that teamai did not write is left alone, and a file CodeBuddy and WorkBuddy share gets the `teamai-recall` subagent block only when both have the subagent. teamai does not change `.gitignore`, `.git/info/exclude` or the index; a team that tracks a generated file such as `.github/copilot-instructions.md` still commits one member's selection. A project uninstall keeps the global Pi, Oh My Pi and Hermes adapters and Codex's user hooks while the user scope or another project still uses them, and the last install removes them (for [#945](https://github.com/Tencent/teamai-cli/issues/945)). - An env variable, hook or MCP server with a key its schema does not know, such as a misspelled `role:` or a hand-added `notes:`, is no longer delivered to anyone: the key used to be dropped silently, so a misspelled restriction shipped the entry to every member. `teamai pull`, the list commands, `teamai status` and `teamai doctor` name the file, the entry and the key. `teamai env add` also warns when it updates a variable carrying the unknown key; it, `teamai env remove` and `teamai remove mcp` keep the key when they rewrite the file. A key that a later version adds to these entries is unknown to this one too, so an entry that uses it is not delivered to a member still on this version: upgrade every member before the team uses a new entry key, as for a new `resources:` key (for [#822](https://github.com/Tencent/teamai-cli/issues/822)). - `manifest/projects.yaml` and `manifest/roles.yaml` now reject a resource namespace that is not a single path segment, as a project id already had to be (the id keeps its own narrower ASCII rule). A namespace becomes a directory component (`skills//`, `agents//`, `learnings//`), so `../evil`, `a/b`, `C:evil`, a bare `..`, any name with a trailing `.` or space — which Win32 strips, making `.. ` arrive as `..` and `frontend.` as `frontend` — and a Windows device name such as `CON` or `COM1` under `resources:` no longer parse; the error names the offending entry. Nothing else is rejected: a namespace that is a plain directory name still parses, non-ASCII names and names with a space included. A manifest that fails to parse now reports the offending entry on one line (`Invalid projects manifest: projects.0.resources.skills.1: ...`) instead of dumping a raw validation object. Two namespaces of one resource type that differ only by case (`frontend`, `Frontend`) are rejected too, within a manifest and between the two, since they name one directory on Windows and macOS. A manifest that ships any of these — a device name, a trailing `.`, a case-only pair — parsed before and fails every pull now; rename the directory and the entry together. - **Upgrade every member before a team declares a new axis.** `resources:` in `manifest/roles.yaml` and `manifest/projects.yaml` accepts `env`, `hooks`, `mcp`, `models` and `docs`, but teamai 0.25.0 and the 0.26.0 betas reject a `resources:` key they do not know, so a team that declares one breaks pull for every member still on those versions. From this version on, an unknown `resources:` key prints one warning naming the role or project and the key, and the scope syncs as if the key were absent; `teamai roles` and `teamai projects` keep the key when they save the manifest. `teamai roles|projects add/update --namespaces` never write the new keys, so nothing declares them until an admin does by hand (for [#707](https://github.com/Tencent/teamai-cli/issues/707)). diff --git a/docs/usage-guide.md b/docs/usage-guide.md index 8ca1a4e4a..8247db038 100644 --- a/docs/usage-guide.md +++ b/docs/usage-guide.md @@ -2872,7 +2872,7 @@ An enabled, installed Pi, Oh My Pi, Hermes or project Codex keeps the project st If removing an OpenCode entry added by teamai fails, uninstall exits with an error and keeps the shared data directory and ownership record, even when OpenCode is the last tool. Repair the config or its permissions, then retry the same uninstall command. -Project uninstall keeps Pi's and Oh My Pi's global extensions, Hermes' global plugin and configuration, and the Codex family's user-level hooks, which other projects use. Targeted project Codex uninstall keeps the project config to record its exclusion and removes only project-owned resources and legacy hook copies. A targeted uninstall excludes the tool in this project's config when that config survives. User-scope uninstall removes these global delivery channels. +Project uninstall keeps Pi's and Oh My Pi's global extensions, Hermes' global plugin and configuration, and the Codex family's user-level hooks while the user scope or another project on this machine still uses them; uninstalling the last install removes them. Targeted project Codex uninstall keeps the project config to record its exclusion and removes only project-owned resources and legacy hook copies. A targeted uninstall excludes the tool in this project's config when that config survives. User-scope uninstall removes these global delivery channels. The exclusion is durable: `uninstall --agent ` drops the tool from `enabledAgents` and records it in `disabledAgents`, so a later `pull` (or another tool's session-start hook) will not resurrect its skills, rules, agents, team instruction blocks, or hooks. Running `init --agent ` again clears the exclusion and re-enables sync for that tool. diff --git a/docs/usage-guide.zh-CN.md b/docs/usage-guide.zh-CN.md index d99cb7082..b65d2a588 100644 --- a/docs/usage-guide.zh-CN.md +++ b/docs/usage-guide.zh-CN.md @@ -2683,7 +2683,7 @@ teamai uninstall --agent claude 若删除 teamai 添加的 OpenCode 条目失败,卸载以错误状态退出,并保留共享数据目录和所有权记录,即使 OpenCode 是最后一个工具。修复配置或权限后,重试同一卸载命令。 -项目级卸载保留 Pi 和 Oh My Pi 的全局扩展、Hermes 的全局插件和配置,以及 Codex 系列的用户级 hooks,其他项目仍会使用它们。定向项目级 Codex 卸载保留项目配置以记录排除设置,仅清理项目拥有的资源和旧 hook 副本。单工具卸载在项目配置仍保留时,将该工具加入此项目的排除列表。用户级卸载才移除这些全局投递通道。 +只要本机的用户级安装或其他项目仍在使用,项目级卸载就保留 Pi 和 Oh My Pi 的全局扩展、Hermes 的全局插件和配置,以及 Codex 系列的用户级 hooks;卸载最后一个安装时会移除它们。定向项目级 Codex 卸载保留项目配置以记录排除设置,仅清理项目拥有的资源和旧 hook 副本。单工具卸载在项目配置仍保留时,将该工具加入此项目的排除列表。用户级卸载才移除这些全局投递通道。 该排除是持久的:`uninstall --agent ` 会把该工具从 `enabledAgents` 移除并记入 `disabledAgents`,因此之后的 `pull`(或其他工具的 session-start hook)不会再把它的 skills、rules、agents、团队指令块或 hooks 重新装回。重新执行 `init --agent ` 会清除该排除、恢复对该工具的同步。 diff --git a/skill-data/setup/references/uninstall.md b/skill-data/setup/references/uninstall.md index 405545d58..28cc499d5 100644 --- a/skill-data/setup/references/uninstall.md +++ b/skill-data/setup/references/uninstall.md @@ -67,7 +67,9 @@ and give it your team repo URL."* and retry the same uninstall command. Uninstall reports failure and keeps its ownership record and shared data directory, even for the last tool. - Project uninstall keeps the global Pi and Oh My Pi extensions, Hermes - plugin and config, and the Codex family's user-level hooks for other projects. + plugin and config, and the Codex family's user-level hooks while the user + scope or another project on this machine uses them; the last install + removes them. Targeted project Codex uninstall keeps project config and records its exclusion, even without local resources. Legacy project hook copies go. User-scope uninstall removes these global channels. diff --git a/src/__tests__/uninstall.test.ts b/src/__tests__/uninstall.test.ts index 63f7fbfed..1a1d860c6 100644 --- a/src/__tests__/uninstall.test.ts +++ b/src/__tests__/uninstall.test.ts @@ -112,6 +112,11 @@ function makeLocalConfig(homeDir: string, repoPath: string, overrides?: Partial< }; } +/** Another project set up on this machine, which uses the global adapters too. */ +async function addOtherProject(homeDir: string): Promise { + await fse.outputFile(path.join(homeDir, '.teamai', 'projects', 'other-project', 'config.yaml'), 'scope: project\n'); +} + async function setupFixture(tmpDir: string) { const homeDir = path.join(tmpDir, 'home'); const repoPath = path.join(tmpDir, 'team-repo'); @@ -484,6 +489,7 @@ describe('uninstall', () => { await fse.ensureDir(path.dirname(projectPiHook)); await fse.writeFile(globalPiHook, TEAMAI_PI_HOOK); await fse.writeFile(projectPiHook, TEAMAI_PI_HOOK); + await addOtherProject(homeDir); const teamConfig = makeTeamConfig({ toolPaths: { @@ -534,6 +540,7 @@ describe('uninstall', () => { vi.stubEnv('HOME', homeDir); vi.stubEnv('HERMES_HOME', path.join(homeDir, '.hermes')); await fse.ensureDir(projectRoot); + await addOtherProject(homeDir); let globalFile: string; let configBefore: string | undefined; if (tool === 'omp') { @@ -582,6 +589,7 @@ describe('uninstall', () => { mockReconcileHooks.mockImplementation(actualHooks.reconcileHooks); const globalHooks = path.join(homeDir, `.${tool}/hooks.json`); await actualHooks.reconcileHooks(globalHooks, tool, []); + await addOtherProject(homeDir); const before = await fse.readFile(globalHooks, 'utf8'); const localConfig = makeLocalConfig(homeDir, repoPath, { scope: 'project', projectRoot, enabledAgents: [tool] }); await fse.outputFile(path.join(projectRoot, '.teamai/config.yaml'), 'scope: project\n'); @@ -598,6 +606,45 @@ describe('uninstall', () => { if (legacy) expect(await actualHooks.hasTeamaiHooks(projectHooks, tool)).toBe(false); }); + it.each(['pi', 'omp', 'hermes', 'codex'])('removes global %s delivery when no other teamai install on the machine uses it', async (tool) => { + const homeDir = path.join(tmpDir, 'home'); + const projectRoot = path.join(tmpDir, 'only-project'); + const repoPath = path.join(projectRoot, '.teamai', 'team-repo'); + vi.stubEnv('HOME', homeDir); + vi.stubEnv('HERMES_HOME', path.join(homeDir, '.hermes')); + vi.stubEnv('SHELL', '/bin/zsh'); + await fse.ensureDir(repoPath); + const actualHooks = await vi.importActual('../hooks.js'); + mockReconcileHooks.mockImplementation(actualHooks.reconcileHooks); + let removed: () => Promise; + if (tool === 'pi') { + const file = path.join(homeDir, '.pi', 'agent', 'extensions', 'teamai-hooks.ts'); + await fse.outputFile(file, TEAMAI_PI_HOOK); + removed = async () => !await fse.pathExists(file); + } else if (tool === 'omp') { + const { injectOmpHooks, resolveOmpExtensionsDir, OMP_HOOK_FILE } = await import('../omp-hooks.js'); + await injectOmpHooks(); + const file = path.join(resolveOmpExtensionsDir(), OMP_HOOK_FILE); + removed = async () => !await fse.pathExists(file); + } else if (tool === 'hermes') { + const { injectHermesHooks, getInstructionsPluginDir } = await import('../hermes-hooks.js'); + await injectHermesHooks(); + const dir = getInstructionsPluginDir(); + removed = async () => !await fse.pathExists(dir); + } else { + const globalHooks = path.join(homeDir, '.codex/hooks.json'); + await actualHooks.reconcileHooks(globalHooks, 'codex', []); + removed = async () => !await actualHooks.hasTeamaiHooks(globalHooks, 'codex'); + } + const teamConfig = TeamaiConfigSchema.parse({ team: 't', repo: 'https://example.invalid/t.git' }); + const localConfig = makeLocalConfig(homeDir, repoPath, { scope: 'project', projectRoot }); + mockAutoDetectInit.mockResolvedValue({ localConfig, teamConfig }); + + await uninstall({ force: true }); + + expect(await removed()).toBe(true); + }); + it('still removes global Codex hooks on a user-scope uninstall', async () => { const homeDir = path.join(tmpDir, 'home'); const repoPath = path.join(homeDir, '.teamai/team-repo'); diff --git a/src/uninstall.ts b/src/uninstall.ts index 968bdb3f2..960119d0e 100644 --- a/src/uninstall.ts +++ b/src/uninstall.ts @@ -20,6 +20,7 @@ import { TEAMAI_TEAM_RULES_END, getDataHome, getManagedHooksPath, + getUserConfigPath, isAgentExcluded, managedMcpManifestPath, resolveBaseDir, @@ -70,6 +71,7 @@ import { listQueuesIn } from './utils/pending-learnings.js'; import { log } from './utils/logger.js'; import { askConfirmation } from './utils/prompt.js'; import { getUserHome } from './utils/home.js'; +import { projectsRootDir } from './utils/partition.js'; import { detectShellProfile, findEnvBlockFor, @@ -137,6 +139,8 @@ interface RemovalPlan { hermesCleanup: boolean; /** Scope being uninstalled (issue #73: surfaced to the user). */ scope: Scope; + /** Whether this removal takes the machine-wide adapters (`removesGlobalAdapters`). */ + globalAdapters: boolean; } /** Per-tool findings collected during discovery (tool-specific resources only). */ @@ -189,6 +193,25 @@ function hasToolResources(r: ToolResources): boolean { // ─── Helpers ─────────────────────────────────────────── +/** + * Whether this removal takes the machine-wide delivery adapters: the Pi and + * OMP extensions, the Hermes plugin, Codex's user hooks and server-pushed + * agent hooks. A user-scope uninstall always does. A project uninstall keeps + * them while another install on this machine still uses them, the user scope + * or another project's partition, and the last install takes them (#945). + */ +async function removesGlobalAdapters(localConfig: LocalConfig): Promise { + if (localConfig.scope === 'user') return true; + if (await pathExists(getUserConfigPath())) return false; + const root = projectsRootDir(); + const own = path.resolve(getDataHome(localConfig)); + for (const dir of await listDirs(root)) { + const partition = path.resolve(root, dir); + if (partition !== own && await pathExists(path.join(partition, 'config.yaml'))) return false; + } + return true; +} + const CLAUDEMD_MARKER_PAIRS: Array<[string, string]> = [ [TEAMAI_RULES_START, TEAMAI_RULES_END], [TEAMAI_CULTURE_START, TEAMAI_CULTURE_END], @@ -326,6 +349,8 @@ async function discoverToolResources( * HOME forever. */ hookSettingsPath?: string, + /** Whether the machine-wide Pi/OMP extensions and Codex user hooks go too (`removesGlobalAdapters`). */ + globalAdapters = scope === 'user', ): Promise { const res: ToolResources = { hookFiles: [], openclawHookDirs: [], opencodeHookScopes: [], ompHookFile: null, piHookFiles: [], dshHookFile: null, @@ -371,7 +396,7 @@ async function discoverToolResources( // project copy, so there is just the one place to look. const { hasOmpHooks, resolveOmpExtensionsDir, OMP_HOOK_FILE } = await import('./omp-hooks.js'); const extFile = path.join(resolveOmpExtensionsDir(), OMP_HOOK_FILE); - if (scope === 'user' && await hasOmpHooks()) { + if (globalAdapters && await hasOmpHooks()) { res.ompHookFile = extFile; } } else if (tool === 'pi') { @@ -382,9 +407,9 @@ async function discoverToolResources( resolvePiProjectExtensionsDir, PI_HOOK_FILE, } = await import('./pi-hooks.js'); - // Project uninstall owns only legacy project copies; other projects still - // use the single global extension and server-pushed agent hooks. - if (scope === 'user' && await hasPiHooks()) { + // Project uninstall owns only legacy project copies while other installs + // still use the single global extension and server-pushed agent hooks. + if (globalAdapters && await hasPiHooks()) { res.piHookFiles.push(path.join(resolvePiExtensionsDir(), PI_HOOK_FILE)); } // Server-pushed agent hooks (teamai-agent-.ts) always install into @@ -393,7 +418,7 @@ async function discoverToolResources( // leftover-plugin pattern so a Pi-only agent-hook install isn't missed. // Each match is marker-checked by its own slug so a same-named file a // user authored by hand is never swept up. - for (const file of scope === 'user' ? await listFiles(resolvePiExtensionsDir()) : []) { + for (const file of globalAdapters ? await listFiles(resolvePiExtensionsDir()) : []) { const base = path.basename(file); if (!base.startsWith('teamai-agent-') || !base.endsWith('.ts')) continue; const slug = base.slice('teamai-agent-'.length, -'.ts'.length); @@ -415,8 +440,8 @@ async function discoverToolResources( // except for the legacy copy, written into by a CLI that knew // nothing about a member's relocated root, so it sits at the team path. for (const { baseDir: hookBaseDir, manifestPath } of hookTargets) { - // Other projects use Codex's user hooks as their instruction channel. - if (scope === 'project' && CODEX_TOOL_IDS.some((id) => id === tool) + // Other installs use Codex's user hooks as their instruction channel. + if (!globalAdapters && CODEX_TOOL_IDS.some((id) => id === tool) && path.resolve(hookBaseDir) === path.resolve(getUserHome())) continue; const settingsRel = path.resolve(hookBaseDir) === path.resolve(getUserHome()) ? (hookSettingsPath ?? toolPath.settings) @@ -610,6 +635,7 @@ async function buildRemovalPlan( const hookToolPaths = scopedToolPaths(teamConfig, { ...localConfig, scope: primaryHookScope.scope }); const toolPaths = scopedToolPaths(teamConfig, localConfig); const perTool = new Map(); + const globalAdapters = await removesGlobalAdapters(localConfig); for (const [tool, toolPath] of Object.entries(toolPaths)) { perTool.set( tool, @@ -625,6 +651,7 @@ async function buildRemovalPlan( standaloneHookManifestPath, localConfig.scope, hookToolPaths[tool]?.settings, + globalAdapters, ), ); } @@ -696,7 +723,7 @@ async function buildRemovalPlan( // no-op for shared resources (plan will be empty → "Nothing to uninstall"). includeShared = targetHasResources && !othersHaveResources; // Keep this project's config so the global hook can read its exclusion. - if (localConfig.scope === 'project' && CODEX_TOOL_IDS.some((id) => id === agentFilter)) includeShared = false; + if (!globalAdapters && CODEX_TOOL_IDS.some((id) => id === agentFilter)) includeShared = false; } else { toolsToMerge = [...perTool.keys()]; includeShared = true; @@ -724,8 +751,9 @@ async function buildRemovalPlan( teamaiHomeExists: includeShared && await pathExists(teamaiHome), unpublishedQueues: includeShared ? await listQueuesIn(teamaiHome) : [], includeShared, - hermesCleanup: localConfig.scope === 'user' && toolsToMerge.includes('hermes'), + hermesCleanup: globalAdapters && toolsToMerge.includes('hermes'), scope: localConfig.scope, + globalAdapters, }; // A single instruction file can be the target of several agents (for @@ -1112,7 +1140,7 @@ async function executeRemoval(plan: RemovalPlan): Promise Date: Fri, 2 Oct 2026 22:14:56 +0200 Subject: [PATCH 39/41] fix(uninstall): keep global adapters on project uninstall and name them (#945) The previous commit removed the Pi/OMP extensions, the Hermes plugin, Codex's user hooks and pushed agent hooks when no user config or other project partition existed. That misses HTTP-only installs and self or legacy projects, whose config stays inside /.teamai and cannot be enumerated, so one project's uninstall could cut another install off. A project uninstall keeps them again and its summary names each one, with `teamai hooks remove`, which removes them, as the step to run first when no other install uses them. pull --dry-run now previews the retired-file cleanup a real pull does after installing a Pi, OMP, Hermes or Codex adapter, unless a member's same-named file or a disabled Hermes plugin keeps that channel closed. --- CHANGELOG.md | 2 +- docs/usage-guide.md | 2 +- docs/usage-guide.zh-CN.md | 2 +- skill-data/setup/references/uninstall.md | 6 +- src/__tests__/e2e/instruction-targets.test.ts | 22 ++++++ src/__tests__/uninstall.test.ts | 42 +++++------ src/hermes-config.ts | 6 ++ src/instruction-targets.ts | 21 ++++++ src/pull.ts | 7 +- src/uninstall.ts | 73 ++++++++++--------- 10 files changed, 120 insertions(+), 63 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0cf624b4d..fd5fc26f3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ All notable changes to this project will be documented in this file. See [standa ### 💥 Breaking Changes -- **Upgrade every member together: team instructions leave the shared `AGENTS.md`.** `teamai pull` no longer writes the culture, `claudemd/` and recall blocks into the project's `AGENTS.md`, `~/AGENTS.md`, `~/.agents/AGENTS.md` or another tool's file, since members with different roles resolve different `claudemd/` selections and the shared file ended up holding whoever pulled last. Each installed tool gets them in its own file or session hook: Claude Code in `.claude/rules/teamai-context.md` (project) and `~/.claude/CLAUDE.md` (user); Cursor in `teamai-context.mdc` under `.cursor/rules` and `~/.cursor/rules`; CodeBuddy and WorkBuddy in one `.codebuddy/rules/teamai-context.md`, and WorkBuddy in `~/.workbuddy/rules/teamai-context.md`; OpenCode in `.opencode/teamai-context.md` and `~/.config/opencode/teamai-context.md`, listed in its `instructions`; Oh My Pi in `~/.omp/agent/RULES.md` and, in a project, through teamai's OMP extension; Pi through teamai's Pi extension in a project; Hermes in `$HERMES_HOME/SOUL.md` and, in a project, through a Hermes plugin teamai installs and enables. Copilot keeps `.github/copilot-instructions.md`. The first pull after updating removes the blocks earlier releases left in `AGENTS.md`, `~/AGENTS.md`, `.claude/CLAUDE.md`, `.codebuddy/CODEBUDDY.md`, `.omp/AGENTS.md`, `~/.omp/agent/AGENTS.md` and a moved tool's `claudemd` from the team's `toolPaths`, keeps everything else in those files, and names each file it changes; a block with a missing or repeated marker is left with a warning. A member still on an older release writes the blocks back into the shared files, so have everyone update. Pull writes only for installed tools, rewrites nothing that is current, removes the recall block when recall is disabled, and `--dry-run` lists the files it would change. Hermes skips project instructions over 4,000 characters, which pull and `teamai doctor` report. `teamai doctor` checks that each tool can load its instructions. A team `toolPaths` entry without `rules` keeps its configured `claudemd` for Claude Code, Cursor, CodeBuddy and WorkBuddy, and an entry with only `claudemd` is still delivered to. A team rule named `teamai-context` is not delivered, since it would land on teamai's own file, and a copy an earlier release delivered is removed unless the member changed it; neither a tombstone of that rule nor a namespaced placement of it reaches teamai's file. The HTTP local agent strips the old blocks of the tools a prompt reached, probes each tool as pull does, and its project prompts reach Pi, Oh My Pi and Hermes through their extension or plugin and the Codex family through its session hooks. It acks a prompt as failed, with the reason, when it reaches no tool: a target file teamai did not write, an extension or plugin that is missing, or text over Hermes' 4,000-character section. OpenCode counts `~/.claude/CLAUDE.md` as its fallback while that file holds teamai blocks, also from an excluded Claude Code, and pull warns when nothing keeps them current. A Hermes plugin named `teamai-instructions` or an Oh My Pi `teamai-hooks.ts` that teamai did not write is left alone, and a file CodeBuddy and WorkBuddy share gets the `teamai-recall` subagent block only when both have the subagent. teamai does not change `.gitignore`, `.git/info/exclude` or the index; a team that tracks a generated file such as `.github/copilot-instructions.md` still commits one member's selection. A project uninstall keeps the global Pi, Oh My Pi and Hermes adapters and Codex's user hooks while the user scope or another project still uses them, and the last install removes them (for [#945](https://github.com/Tencent/teamai-cli/issues/945)). +- **Upgrade every member together: team instructions leave the shared `AGENTS.md`.** `teamai pull` no longer writes the culture, `claudemd/` and recall blocks into the project's `AGENTS.md`, `~/AGENTS.md`, `~/.agents/AGENTS.md` or another tool's file, since members with different roles resolve different `claudemd/` selections and the shared file ended up holding whoever pulled last. Each installed tool gets them in its own file or session hook: Claude Code in `.claude/rules/teamai-context.md` (project) and `~/.claude/CLAUDE.md` (user); Cursor in `teamai-context.mdc` under `.cursor/rules` and `~/.cursor/rules`; CodeBuddy and WorkBuddy in one `.codebuddy/rules/teamai-context.md`, and WorkBuddy in `~/.workbuddy/rules/teamai-context.md`; OpenCode in `.opencode/teamai-context.md` and `~/.config/opencode/teamai-context.md`, listed in its `instructions`; Oh My Pi in `~/.omp/agent/RULES.md` and, in a project, through teamai's OMP extension; Pi through teamai's Pi extension in a project; Hermes in `$HERMES_HOME/SOUL.md` and, in a project, through a Hermes plugin teamai installs and enables. Copilot keeps `.github/copilot-instructions.md`. The first pull after updating removes the blocks earlier releases left in `AGENTS.md`, `~/AGENTS.md`, `.claude/CLAUDE.md`, `.codebuddy/CODEBUDDY.md`, `.omp/AGENTS.md`, `~/.omp/agent/AGENTS.md` and a moved tool's `claudemd` from the team's `toolPaths`, keeps everything else in those files, and names each file it changes; a block with a missing or repeated marker is left with a warning. A member still on an older release writes the blocks back into the shared files, so have everyone update. Pull writes only for installed tools, rewrites nothing that is current, removes the recall block when recall is disabled, and `--dry-run` lists the files it would change. Hermes skips project instructions over 4,000 characters, which pull and `teamai doctor` report. `teamai doctor` checks that each tool can load its instructions. A team `toolPaths` entry without `rules` keeps its configured `claudemd` for Claude Code, Cursor, CodeBuddy and WorkBuddy, and an entry with only `claudemd` is still delivered to. A team rule named `teamai-context` is not delivered, since it would land on teamai's own file, and a copy an earlier release delivered is removed unless the member changed it; neither a tombstone of that rule nor a namespaced placement of it reaches teamai's file. The HTTP local agent strips the old blocks of the tools a prompt reached, probes each tool as pull does, and its project prompts reach Pi, Oh My Pi and Hermes through their extension or plugin and the Codex family through its session hooks. It acks a prompt as failed, with the reason, when it reaches no tool: a target file teamai did not write, an extension or plugin that is missing, or text over Hermes' 4,000-character section. OpenCode counts `~/.claude/CLAUDE.md` as its fallback while that file holds teamai blocks, also from an excluded Claude Code, and pull warns when nothing keeps them current. A Hermes plugin named `teamai-instructions` or an Oh My Pi `teamai-hooks.ts` that teamai did not write is left alone, and a file CodeBuddy and WorkBuddy share gets the `teamai-recall` subagent block only when both have the subagent. teamai does not change `.gitignore`, `.git/info/exclude` or the index; a team that tracks a generated file such as `.github/copilot-instructions.md` still commits one member's selection. A project uninstall keeps the global Pi, Oh My Pi and Hermes adapters and Codex's user hooks, which other installs on the machine may use, and names them; `teamai hooks remove` removes them (for [#945](https://github.com/Tencent/teamai-cli/issues/945)). - An env variable, hook or MCP server with a key its schema does not know, such as a misspelled `role:` or a hand-added `notes:`, is no longer delivered to anyone: the key used to be dropped silently, so a misspelled restriction shipped the entry to every member. `teamai pull`, the list commands, `teamai status` and `teamai doctor` name the file, the entry and the key. `teamai env add` also warns when it updates a variable carrying the unknown key; it, `teamai env remove` and `teamai remove mcp` keep the key when they rewrite the file. A key that a later version adds to these entries is unknown to this one too, so an entry that uses it is not delivered to a member still on this version: upgrade every member before the team uses a new entry key, as for a new `resources:` key (for [#822](https://github.com/Tencent/teamai-cli/issues/822)). - `manifest/projects.yaml` and `manifest/roles.yaml` now reject a resource namespace that is not a single path segment, as a project id already had to be (the id keeps its own narrower ASCII rule). A namespace becomes a directory component (`skills//`, `agents//`, `learnings//`), so `../evil`, `a/b`, `C:evil`, a bare `..`, any name with a trailing `.` or space — which Win32 strips, making `.. ` arrive as `..` and `frontend.` as `frontend` — and a Windows device name such as `CON` or `COM1` under `resources:` no longer parse; the error names the offending entry. Nothing else is rejected: a namespace that is a plain directory name still parses, non-ASCII names and names with a space included. A manifest that fails to parse now reports the offending entry on one line (`Invalid projects manifest: projects.0.resources.skills.1: ...`) instead of dumping a raw validation object. Two namespaces of one resource type that differ only by case (`frontend`, `Frontend`) are rejected too, within a manifest and between the two, since they name one directory on Windows and macOS. A manifest that ships any of these — a device name, a trailing `.`, a case-only pair — parsed before and fails every pull now; rename the directory and the entry together. - **Upgrade every member before a team declares a new axis.** `resources:` in `manifest/roles.yaml` and `manifest/projects.yaml` accepts `env`, `hooks`, `mcp`, `models` and `docs`, but teamai 0.25.0 and the 0.26.0 betas reject a `resources:` key they do not know, so a team that declares one breaks pull for every member still on those versions. From this version on, an unknown `resources:` key prints one warning naming the role or project and the key, and the scope syncs as if the key were absent; `teamai roles` and `teamai projects` keep the key when they save the manifest. `teamai roles|projects add/update --namespaces` never write the new keys, so nothing declares them until an admin does by hand (for [#707](https://github.com/Tencent/teamai-cli/issues/707)). diff --git a/docs/usage-guide.md b/docs/usage-guide.md index 8247db038..66715afff 100644 --- a/docs/usage-guide.md +++ b/docs/usage-guide.md @@ -2872,7 +2872,7 @@ An enabled, installed Pi, Oh My Pi, Hermes or project Codex keeps the project st If removing an OpenCode entry added by teamai fails, uninstall exits with an error and keeps the shared data directory and ownership record, even when OpenCode is the last tool. Repair the config or its permissions, then retry the same uninstall command. -Project uninstall keeps Pi's and Oh My Pi's global extensions, Hermes' global plugin and configuration, and the Codex family's user-level hooks while the user scope or another project on this machine still uses them; uninstalling the last install removes them. Targeted project Codex uninstall keeps the project config to record its exclusion and removes only project-owned resources and legacy hook copies. A targeted uninstall excludes the tool in this project's config when that config survives. User-scope uninstall removes these global delivery channels. +Project uninstall keeps Pi's and Oh My Pi's global extensions, Hermes' global plugin and configuration, the Codex family's user-level hooks and server-pushed agent hooks, which the user scope, the HTTP agent or another project on this machine may use, and names them in its summary. When none uses them, run `teamai hooks remove` in the project before uninstalling: it removes them. Targeted project Codex uninstall keeps the project config to record its exclusion and removes only project-owned resources and legacy hook copies. A targeted uninstall excludes the tool in this project's config when that config survives. User-scope uninstall removes these global delivery channels. The exclusion is durable: `uninstall --agent ` drops the tool from `enabledAgents` and records it in `disabledAgents`, so a later `pull` (or another tool's session-start hook) will not resurrect its skills, rules, agents, team instruction blocks, or hooks. Running `init --agent ` again clears the exclusion and re-enables sync for that tool. diff --git a/docs/usage-guide.zh-CN.md b/docs/usage-guide.zh-CN.md index b65d2a588..ff6228499 100644 --- a/docs/usage-guide.zh-CN.md +++ b/docs/usage-guide.zh-CN.md @@ -2683,7 +2683,7 @@ teamai uninstall --agent claude 若删除 teamai 添加的 OpenCode 条目失败,卸载以错误状态退出,并保留共享数据目录和所有权记录,即使 OpenCode 是最后一个工具。修复配置或权限后,重试同一卸载命令。 -只要本机的用户级安装或其他项目仍在使用,项目级卸载就保留 Pi 和 Oh My Pi 的全局扩展、Hermes 的全局插件和配置,以及 Codex 系列的用户级 hooks;卸载最后一个安装时会移除它们。定向项目级 Codex 卸载保留项目配置以记录排除设置,仅清理项目拥有的资源和旧 hook 副本。单工具卸载在项目配置仍保留时,将该工具加入此项目的排除列表。用户级卸载才移除这些全局投递通道。 +项目级卸载保留 Pi 和 Oh My Pi 的全局扩展、Hermes 的全局插件和配置、Codex 系列的用户级 hooks 以及服务端下发的 agent hooks,因为本机的用户级安装、HTTP agent 或其他项目可能仍在使用它们,并在摘要中列出。若没有其他安装使用它们,请在卸载前于该项目中运行 `teamai hooks remove`,它会移除这些内容。定向项目级 Codex 卸载保留项目配置以记录排除设置,仅清理项目拥有的资源和旧 hook 副本。单工具卸载在项目配置仍保留时,将该工具加入此项目的排除列表。用户级卸载才移除这些全局投递通道。 该排除是持久的:`uninstall --agent ` 会把该工具从 `enabledAgents` 移除并记入 `disabledAgents`,因此之后的 `pull`(或其他工具的 session-start hook)不会再把它的 skills、rules、agents、团队指令块或 hooks 重新装回。重新执行 `init --agent ` 会清除该排除、恢复对该工具的同步。 diff --git a/skill-data/setup/references/uninstall.md b/skill-data/setup/references/uninstall.md index 28cc499d5..6b63dfc48 100644 --- a/skill-data/setup/references/uninstall.md +++ b/skill-data/setup/references/uninstall.md @@ -67,9 +67,9 @@ and give it your team repo URL."* and retry the same uninstall command. Uninstall reports failure and keeps its ownership record and shared data directory, even for the last tool. - Project uninstall keeps the global Pi and Oh My Pi extensions, Hermes - plugin and config, and the Codex family's user-level hooks while the user - scope or another project on this machine uses them; the last install - removes them. + plugin and config, and the Codex family's user-level hooks, which the user + scope, the HTTP agent or another project may use, and names them. If none + does, run `teamai hooks remove` in the project first: it removes them. Targeted project Codex uninstall keeps project config and records its exclusion, even without local resources. Legacy project hook copies go. User-scope uninstall removes these global channels. diff --git a/src/__tests__/e2e/instruction-targets.test.ts b/src/__tests__/e2e/instruction-targets.test.ts index 46c6b4b36..547a20cd3 100644 --- a/src/__tests__/e2e/instruction-targets.test.ts +++ b/src/__tests__/e2e/instruction-targets.test.ts @@ -587,6 +587,28 @@ describe('instruction block targets on real CLI pull (#945)', () => { console.log('WorkBuddy project uninstall: project state preserved; global-only Pi still receives member instructions'); }); + it('previews the cleanup a real pull does once it installs the Pi extension, and none behind a foreign one', async () => { + const sandbox = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'teamai-945-dryrun-'))); + sandboxes.push(sandbox); + const member = makeProjectMember(sandbox, makeTeamAndProject(sandbox), 'pm', 'product', ['.pi/skills']); + fs.mkdirSync(path.join(member.home, '.pi'), { recursive: true }); + const legacy = path.join(member.projectRoot, 'AGENTS.md'); + fs.writeFileSync(legacy, `${PROJECT_AGENTS_MD}${CLAUDEMD_START}\nworking old prompt\n${CLAUDEMD_END}\n`); + const extension = path.join(member.home, '.pi', 'agent', 'extensions', 'teamai-hooks.ts'); + const removal = `Would remove teamai instruction blocks from ${legacy}`; + + const preview = await pullAs(member, ['--dry-run']); + expect(preview.code, preview.output).toBe(0); + expect(preview.output).toContain(removal); + expect(fs.existsSync(extension)).toBe(false); + + fs.mkdirSync(path.dirname(extension), { recursive: true }); + fs.writeFileSync(extension, '// Foreign extension\n'); + const blocked = await pullAs(member, ['--dry-run']); + expect(blocked.code, blocked.output).toBe(0); + expect(blocked.output).not.toContain(removal); + }); + it('retains Pi legacy instructions until its extension is ready, and protects exclusions', async () => { const sandbox = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'teamai-945-migration-'))); sandboxes.push(sandbox); diff --git a/src/__tests__/uninstall.test.ts b/src/__tests__/uninstall.test.ts index 1a1d860c6..6799c98b2 100644 --- a/src/__tests__/uninstall.test.ts +++ b/src/__tests__/uninstall.test.ts @@ -112,11 +112,6 @@ function makeLocalConfig(homeDir: string, repoPath: string, overrides?: Partial< }; } -/** Another project set up on this machine, which uses the global adapters too. */ -async function addOtherProject(homeDir: string): Promise { - await fse.outputFile(path.join(homeDir, '.teamai', 'projects', 'other-project', 'config.yaml'), 'scope: project\n'); -} - async function setupFixture(tmpDir: string) { const homeDir = path.join(tmpDir, 'home'); const repoPath = path.join(tmpDir, 'team-repo'); @@ -489,7 +484,6 @@ describe('uninstall', () => { await fse.ensureDir(path.dirname(projectPiHook)); await fse.writeFile(globalPiHook, TEAMAI_PI_HOOK); await fse.writeFile(projectPiHook, TEAMAI_PI_HOOK); - await addOtherProject(homeDir); const teamConfig = makeTeamConfig({ toolPaths: { @@ -540,7 +534,6 @@ describe('uninstall', () => { vi.stubEnv('HOME', homeDir); vi.stubEnv('HERMES_HOME', path.join(homeDir, '.hermes')); await fse.ensureDir(projectRoot); - await addOtherProject(homeDir); let globalFile: string; let configBefore: string | undefined; if (tool === 'omp') { @@ -589,7 +582,6 @@ describe('uninstall', () => { mockReconcileHooks.mockImplementation(actualHooks.reconcileHooks); const globalHooks = path.join(homeDir, `.${tool}/hooks.json`); await actualHooks.reconcileHooks(globalHooks, tool, []); - await addOtherProject(homeDir); const before = await fse.readFile(globalHooks, 'utf8'); const localConfig = makeLocalConfig(homeDir, repoPath, { scope: 'project', projectRoot, enabledAgents: [tool] }); await fse.outputFile(path.join(projectRoot, '.teamai/config.yaml'), 'scope: project\n'); @@ -606,7 +598,7 @@ describe('uninstall', () => { if (legacy) expect(await actualHooks.hasTeamaiHooks(projectHooks, tool)).toBe(false); }); - it.each(['pi', 'omp', 'hermes', 'codex'])('removes global %s delivery when no other teamai install on the machine uses it', async (tool) => { + it.each(['pi', 'omp', 'hermes', 'codex'])('keeps global %s delivery and names it with the command that removes it', async (tool) => { const homeDir = path.join(tmpDir, 'home'); const projectRoot = path.join(tmpDir, 'only-project'); const repoPath = path.join(projectRoot, '.teamai', 'team-repo'); @@ -616,33 +608,39 @@ describe('uninstall', () => { await fse.ensureDir(repoPath); const actualHooks = await vi.importActual('../hooks.js'); mockReconcileHooks.mockImplementation(actualHooks.reconcileHooks); - let removed: () => Promise; + let kept: string; if (tool === 'pi') { - const file = path.join(homeDir, '.pi', 'agent', 'extensions', 'teamai-hooks.ts'); - await fse.outputFile(file, TEAMAI_PI_HOOK); - removed = async () => !await fse.pathExists(file); + kept = path.join(homeDir, '.pi', 'agent', 'extensions', 'teamai-hooks.ts'); + await fse.outputFile(kept, TEAMAI_PI_HOOK); } else if (tool === 'omp') { const { injectOmpHooks, resolveOmpExtensionsDir, OMP_HOOK_FILE } = await import('../omp-hooks.js'); await injectOmpHooks(); - const file = path.join(resolveOmpExtensionsDir(), OMP_HOOK_FILE); - removed = async () => !await fse.pathExists(file); + kept = path.join(resolveOmpExtensionsDir(), OMP_HOOK_FILE); } else if (tool === 'hermes') { const { injectHermesHooks, getInstructionsPluginDir } = await import('../hermes-hooks.js'); await injectHermesHooks(); - const dir = getInstructionsPluginDir(); - removed = async () => !await fse.pathExists(dir); + kept = getInstructionsPluginDir(); } else { - const globalHooks = path.join(homeDir, '.codex/hooks.json'); - await actualHooks.reconcileHooks(globalHooks, 'codex', []); - removed = async () => !await actualHooks.hasTeamaiHooks(globalHooks, 'codex'); + kept = path.join(homeDir, '.codex/hooks.json'); + await actualHooks.reconcileHooks(kept, 'codex', []); } const teamConfig = TeamaiConfigSchema.parse({ team: 't', repo: 'https://example.invalid/t.git' }); const localConfig = makeLocalConfig(homeDir, repoPath, { scope: 'project', projectRoot }); mockAutoDetectInit.mockResolvedValue({ localConfig, teamConfig }); - await uninstall({ force: true }); + const printed: string[] = []; + const logSpy = vi.spyOn(console, 'log').mockImplementation((line?: unknown) => { printed.push(String(line ?? '')); }); + try { + await uninstall({ force: true }); + } finally { + logSpy.mockRestore(); + } - expect(await removed()).toBe(true); + expect(await fse.pathExists(kept)).toBe(true); + if (tool === 'codex') expect(await actualHooks.hasTeamaiHooks(kept, 'codex')).toBe(true); + const summary = printed.join('\n'); + expect(summary).toContain(` ${kept}`); + expect(summary).toContain('run `teamai hooks remove` here first'); }); it('still removes global Codex hooks on a user-scope uninstall', async () => { diff --git a/src/hermes-config.ts b/src/hermes-config.ts index ba9e9a6b1..ce37f08d8 100644 --- a/src/hermes-config.ts +++ b/src/hermes-config.ts @@ -328,6 +328,12 @@ export async function disableHermesPlugin(name: string): Promise { await writeConfigDoc(doc); } +/** Whether the member put `name` in `plugins.disabled`, which enabling leaves alone. */ +export async function isHermesPluginDisabled(name: string): Promise { + const disabled = (await readConfigDoc()).getIn(['plugins', 'disabled']); + return YAML.isSeq(disabled) && (disabled.toJSON() as unknown[]).includes(name); +} + /** Whether `name` is in `plugins.enabled` of the Hermes config.yaml. */ export async function isHermesPluginEnabled(name: string): Promise { const enabled = (await readConfigDoc()).getIn(['plugins', 'enabled']); diff --git a/src/instruction-targets.ts b/src/instruction-targets.ts index 6cdfd580f..1ff3ae29f 100644 --- a/src/instruction-targets.ts +++ b/src/instruction-targets.ts @@ -317,6 +317,27 @@ export async function instructionHookChannel( return { ready: true, fix: '' }; } +/** + * Whether reconciling the hooks leaves `tool`'s channel ready. A dry run + * writes nothing, so it previews the cleanup a real pull does once it has + * installed the extension or plugin. A same-named file or plugin of the + * member's, or a plugin the member disabled, keeps the channel closed. + */ +export async function instructionHookChannelInstallable(tool: string): Promise { + if (tool === 'omp' || tool === 'pi') { + const { hasOmpHooks, resolveOmpExtensionsDir, OMP_HOOK_FILE } = await import('./omp-hooks.js'); + const { hasPiHooks, resolvePiExtensionsDir, PI_HOOK_FILE } = await import('./pi-hooks.js'); + const file = tool === 'omp' ? path.join(resolveOmpExtensionsDir(), OMP_HOOK_FILE) : path.join(resolvePiExtensionsDir(), PI_HOOK_FILE); + return !await pathExists(file) || (tool === 'omp' ? await hasOmpHooks() : await hasPiHooks()); + } + if (tool === 'hermes') { + const { HERMES_INSTRUCTIONS_PLUGIN, ownsInstructionsPlugin } = await import('./hermes-hooks.js'); + const { isHermesPluginDisabled } = await import('./hermes-config.js'); + return await ownsInstructionsPlugin() && !await isHermesPluginDisabled(HERMES_INSTRUCTIONS_PLUGIN); + } + return true; +} + /** * What keeps an installed hook tool from getting this member's team * instructions in the scope: its extension or plugin, or the size of the text. diff --git a/src/pull.ts b/src/pull.ts index 231adcf86..d55ab7ecc 100644 --- a/src/pull.ts +++ b/src/pull.ts @@ -15,7 +15,7 @@ import { log, spinner } from './utils/logger.js'; import { pathExists, remove, listFiles, listDirs, listFilesRecursive, readFileSafe, dirContentEqual, hasVcsMetadataRecursive } from './utils/fs.js'; import { reconcilePlacementRecords } from './utils/pending-push.js'; import { - applyInstructionPlan, hookLimitProblem, instructionHookChannel, instructionHookText, planInstructionFiles, + applyInstructionPlan, hookLimitProblem, instructionHookChannel, instructionHookChannelInstallable, instructionHookText, planInstructionFiles, registerOpencodeContext, resolveInstructionTargets, retiredFilesOfReached, type InstructionBlocks, } from './instruction-targets.js'; import { getHandler, RulesHandler, DocsHandler, EnvHandler, AgentsHandler } from './resources/index.js'; @@ -2593,7 +2593,10 @@ async function reconcileHooksAllScopes( const { hooks } = await resolveInstructionTargets(teamConfig, localConfig); for (const hook of hooks) { const channel = await instructionHookChannel(hook.tool, { teamConfig, localConfig }); - if (channel.ready && !hookLimitProblem(hook, instructionHookText(delivery.blocks, hook.recall))) { + // A dry run installed nothing: preview what the real pull's install leaves ready. + const ready = channel.ready + || (Boolean(options.dryRun) && reconciled.ok && await instructionHookChannelInstallable(hook.tool)); + if (ready && !hookLimitProblem(hook, instructionHookText(delivery.blocks, hook.recall))) { delivery.reached.push(hook.tool); } } diff --git a/src/uninstall.ts b/src/uninstall.ts index 960119d0e..a27a7fa08 100644 --- a/src/uninstall.ts +++ b/src/uninstall.ts @@ -20,7 +20,6 @@ import { TEAMAI_TEAM_RULES_END, getDataHome, getManagedHooksPath, - getUserConfigPath, isAgentExcluded, managedMcpManifestPath, resolveBaseDir, @@ -71,7 +70,6 @@ import { listQueuesIn } from './utils/pending-learnings.js'; import { log } from './utils/logger.js'; import { askConfirmation } from './utils/prompt.js'; import { getUserHome } from './utils/home.js'; -import { projectsRootDir } from './utils/partition.js'; import { detectShellProfile, findEnvBlockFor, @@ -139,8 +137,10 @@ interface RemovalPlan { hermesCleanup: boolean; /** Scope being uninstalled (issue #73: surfaced to the user). */ scope: Scope; - /** Whether this removal takes the machine-wide adapters (`removesGlobalAdapters`). */ + /** Whether this removal takes the machine-wide adapters: a user-scope uninstall only. */ globalAdapters: boolean; + /** Machine-wide adapters a project uninstall keeps for other installs; `teamai hooks remove` takes them. */ + keptGlobal: string[]; } /** Per-tool findings collected during discovery (tool-specific resources only). */ @@ -168,6 +168,8 @@ interface ToolResources { retiredInstructionFiles: string[]; /** teamai's entries in OpenCode's `instructions`, whether or not their file still holds blocks (#945). */ opencodeInstructions: OpencodeInstruction[]; + /** Machine-wide adapters this project uninstall keeps (`RemovalPlan.keptGlobal`). */ + keptGlobal: string[]; skillDirs: SkillDirEntry[]; ruleFiles: string[]; keptRuleFiles: string[]; @@ -193,25 +195,6 @@ function hasToolResources(r: ToolResources): boolean { // ─── Helpers ─────────────────────────────────────────── -/** - * Whether this removal takes the machine-wide delivery adapters: the Pi and - * OMP extensions, the Hermes plugin, Codex's user hooks and server-pushed - * agent hooks. A user-scope uninstall always does. A project uninstall keeps - * them while another install on this machine still uses them, the user scope - * or another project's partition, and the last install takes them (#945). - */ -async function removesGlobalAdapters(localConfig: LocalConfig): Promise { - if (localConfig.scope === 'user') return true; - if (await pathExists(getUserConfigPath())) return false; - const root = projectsRootDir(); - const own = path.resolve(getDataHome(localConfig)); - for (const dir of await listDirs(root)) { - const partition = path.resolve(root, dir); - if (partition !== own && await pathExists(path.join(partition, 'config.yaml'))) return false; - } - return true; -} - const CLAUDEMD_MARKER_PAIRS: Array<[string, string]> = [ [TEAMAI_RULES_START, TEAMAI_RULES_END], [TEAMAI_CULTURE_START, TEAMAI_CULTURE_END], @@ -349,12 +332,16 @@ async function discoverToolResources( * HOME forever. */ hookSettingsPath?: string, - /** Whether the machine-wide Pi/OMP extensions and Codex user hooks go too (`removesGlobalAdapters`). */ + /** + * Whether the machine-wide Pi/OMP extensions and Codex user hooks go too. + * Only a user-scope uninstall takes them: a project cannot tell whether an + * HTTP agent, a self-mode project or another checkout still uses them (#945). + */ globalAdapters = scope === 'user', ): Promise { const res: ToolResources = { hookFiles: [], openclawHookDirs: [], opencodeHookScopes: [], ompHookFile: null, piHookFiles: [], dshHookFile: null, - claudeMdFiles: [], retiredInstructionFiles: [], opencodeInstructions: [], skillDirs: [], ruleFiles: [], keptRuleFiles: [], agentFiles: [], + claudeMdFiles: [], retiredInstructionFiles: [], opencodeInstructions: [], keptGlobal: [], skillDirs: [], ruleFiles: [], keptRuleFiles: [], agentFiles: [], }; // (a) Hooks — settings.json / hooks.json @@ -396,8 +383,9 @@ async function discoverToolResources( // project copy, so there is just the one place to look. const { hasOmpHooks, resolveOmpExtensionsDir, OMP_HOOK_FILE } = await import('./omp-hooks.js'); const extFile = path.join(resolveOmpExtensionsDir(), OMP_HOOK_FILE); - if (globalAdapters && await hasOmpHooks()) { - res.ompHookFile = extFile; + if (await hasOmpHooks()) { + if (globalAdapters) res.ompHookFile = extFile; + else res.keptGlobal.push(extFile); } } else if (tool === 'pi') { const { @@ -409,8 +397,9 @@ async function discoverToolResources( } = await import('./pi-hooks.js'); // Project uninstall owns only legacy project copies while other installs // still use the single global extension and server-pushed agent hooks. - if (globalAdapters && await hasPiHooks()) { - res.piHookFiles.push(path.join(resolvePiExtensionsDir(), PI_HOOK_FILE)); + if (await hasPiHooks()) { + if (globalAdapters) res.piHookFiles.push(path.join(resolvePiExtensionsDir(), PI_HOOK_FILE)); + else res.keptGlobal.push(path.join(resolvePiExtensionsDir(), PI_HOOK_FILE)); } // Server-pushed agent hooks (teamai-agent-.ts) always install into // the global extension dir and can exist without the main lifecycle @@ -440,13 +429,16 @@ async function discoverToolResources( // except for the legacy copy, written into by a CLI that knew // nothing about a member's relocated root, so it sits at the team path. for (const { baseDir: hookBaseDir, manifestPath } of hookTargets) { - // Other installs use Codex's user hooks as their instruction channel. - if (!globalAdapters && CODEX_TOOL_IDS.some((id) => id === tool) - && path.resolve(hookBaseDir) === path.resolve(getUserHome())) continue; const settingsRel = path.resolve(hookBaseDir) === path.resolve(getUserHome()) ? (hookSettingsPath ?? toolPath.settings) : toolPath.settings; const settingsPath = path.join(hookBaseDir, settingsRel); + // Other installs use Codex's user hooks as their instruction channel. + if (!globalAdapters && CODEX_TOOL_IDS.some((id) => id === tool) + && path.resolve(hookBaseDir) === path.resolve(getUserHome())) { + if (await pathExists(settingsPath) && await hasTeamaiHooks(settingsPath, tool, manifestPath)) res.keptGlobal.push(settingsPath); + continue; + } if (await pathExists(settingsPath) && (await hasTeamaiHooks(settingsPath, tool, manifestPath) || isEmptyHooksResidue(await readJson>(settingsPath)))) { @@ -635,7 +627,7 @@ async function buildRemovalPlan( const hookToolPaths = scopedToolPaths(teamConfig, { ...localConfig, scope: primaryHookScope.scope }); const toolPaths = scopedToolPaths(teamConfig, localConfig); const perTool = new Map(); - const globalAdapters = await removesGlobalAdapters(localConfig); + const globalAdapters = localConfig.scope === 'user'; for (const [tool, toolPath] of Object.entries(toolPaths)) { perTool.set( tool, @@ -754,6 +746,7 @@ async function buildRemovalPlan( hermesCleanup: globalAdapters && toolsToMerge.includes('hermes'), scope: localConfig.scope, globalAdapters, + keptGlobal: [], }; // A single instruction file can be the target of several agents (for @@ -783,6 +776,7 @@ async function buildRemovalPlan( plan.piHookFiles.push(...res.piHookFiles); if (res.dshHookFile) plan.dshHookFile = res.dshHookFile; plan.opencodeInstructions.push(...res.opencodeInstructions); + plan.keptGlobal.push(...res.keptGlobal); for (const file of res.claudeMdFiles) { if (plan.claudeMdFiles.some((entry) => entry.path === file)) continue; const content = await readFileSafe(file) ?? ''; @@ -809,6 +803,12 @@ async function buildRemovalPlan( plan.agentFiles.push(...res.agentFiles); } + // Hermes' plugin is machine-wide too: a project uninstall names it as kept. + if (!globalAdapters && toolsToMerge.includes('hermes')) { + const { getInstructionsPluginDir, ownsInstructionsPlugin } = await import('./hermes-hooks.js'); + if (await pathExists(getInstructionsPluginDir()) && await ownsInstructionsPlugin()) plan.keptGlobal.push(getInstructionsPluginDir()); + } + if (includeShared) { // (d3) teamai-managed MCP servers, tracked in managed-mcp.json (same // ownership model as hooks). Project scope reads THIS worktree's own @@ -1044,6 +1044,13 @@ function printSummary(plan: RemovalPlan, agentFilter?: string): void { console.log(' Run `teamai pull` to publish them first, or copy them somewhere safe.'); console.log(''); } + + if (plan.keptGlobal.length > 0) { + console.log('ℹ Kept for other teamai installs on this machine (user scope, HTTP agent or other projects):'); + for (const file of plan.keptGlobal) console.log(` ${file}`); + console.log(' If none of them uses these, cancel and run `teamai hooks remove` here first: it removes them.'); + console.log(''); + } } // ─── Execution ───────────────────────────────────────── @@ -1355,7 +1362,7 @@ export async function uninstall(opts: UninstallOptions): Promise { // The global channel belongs to other projects too; exclusion is this // project's removal even when there are no local files to delete. await excludeUninstalledAgent(localConfig, agentKey); - log.success(`Excluded ${agentKey} from this project; its global delivery channel is kept for other projects`); + log.success(`Excluded ${agentKey} from this project; its global delivery channel is kept for other teamai installs on this machine. If none uses it, run \`teamai hooks remove\` to remove it.`); return; } log.info('Nothing to uninstall'); From 300edfbd842f2d31979f3eb412c7022759d8bac9 Mon Sep 17 00:00:00 2001 From: Saul Moro Date: Fri, 2 Oct 2026 23:28:32 +0200 Subject: [PATCH 40/41] fix(uninstall): gate empty-plan exclusion and join E2E workers (#945) --- docs/usage-guide.md | 2 + docs/usage-guide.zh-CN.md | 2 + skill-data/setup/references/uninstall.md | 2 + src/__tests__/e2e/instruction-targets.test.ts | 55 ++++++++++++++++++- src/__tests__/uninstall.test.ts | 31 +++++++++++ src/uninstall.ts | 19 ++++--- 6 files changed, 102 insertions(+), 9 deletions(-) diff --git a/docs/usage-guide.md b/docs/usage-guide.md index 66715afff..011c5fdaa 100644 --- a/docs/usage-guide.md +++ b/docs/usage-guide.md @@ -2836,6 +2836,8 @@ A full user-scope `teamai uninstall` restores managed model settings first and s `teamai uninstall` intelligently cleans up all teamai-managed resources, **preserving anything you created yourself**. +A targeted project exclusion also requires confirmation or `--force`, even when there are no local files to remove. `--dry-run` and a declined confirmation leave the project config unchanged. + ```bash # Preview every managed path that will be removed (no actual changes) teamai uninstall --dry-run diff --git a/docs/usage-guide.zh-CN.md b/docs/usage-guide.zh-CN.md index ff6228499..095d2c734 100644 --- a/docs/usage-guide.zh-CN.md +++ b/docs/usage-guide.zh-CN.md @@ -2647,6 +2647,8 @@ teamai models remove local:my-gateway # Agent 保留当前配置,restore 仍 `teamai uninstall` 会智能清理所有 teamai 管理的资源,**保留用户自建内容**。 +即使没有本地文件需要删除,排除项目中的指定工具也需要确认或 `--force`。`--dry-run` 或拒绝确认不会修改项目配置。 + ```bash # 预览将要移除的每个受管路径(不做实际变更) teamai uninstall --dry-run diff --git a/skill-data/setup/references/uninstall.md b/skill-data/setup/references/uninstall.md index 6b63dfc48..4c2f2fe12 100644 --- a/skill-data/setup/references/uninstall.md +++ b/skill-data/setup/references/uninstall.md @@ -29,6 +29,8 @@ Your team's repo on the website is untouched — you can rejoin any time with ## Step 2 — Run it (you run it) +A targeted project exclusion needs the same confirmation even when there are no local files to remove. `--dry-run` and declining confirmation leave the project config unchanged. + Whole machine: ```bash diff --git a/src/__tests__/e2e/instruction-targets.test.ts b/src/__tests__/e2e/instruction-targets.test.ts index 547a20cd3..2e157c7f6 100644 --- a/src/__tests__/e2e/instruction-targets.test.ts +++ b/src/__tests__/e2e/instruction-targets.test.ts @@ -640,6 +640,35 @@ describe('instruction block targets on real CLI pull (#945)', () => { console.log('pull with Pi enabled: extension installed before old prompt removed'); }); + it('previews and cancels empty-plan Pi exclusion without changing project config on the real CLI', async () => { + const sandbox = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'teamai-945-empty-uninstall-'))); + sandboxes.push(sandbox); + const member = makeProjectMember(sandbox, makeTeamAndProject(sandbox), 'dev', 'developer', []); + fs.mkdirSync(path.join(member.home, '.pi'), { recursive: true }); + fs.appendFileSync(path.join(member.projectRoot, '.teamai/config.yaml'), '\nenabledAgents: [pi]\n'); + const prepared = await pullAs(member); + expect(prepared.code, prepared.output).toBe(0); + const config = memberData(member).config; + const before = fs.readFileSync(config, 'utf8'); + const hooks = path.join(member.home, '.pi/agent/extensions/teamai-hooks.ts'); + const adapter = fs.readFileSync(hooks, 'utf8'); + const preview = await runCLI(['uninstall', '--agent', 'pi', '--dry-run'], { HOME: member.home }, member.projectRoot); + expect(preview.code, preview.output).toBe(0); + expect(preview.output).toContain('Exclude pi from this project'); + expect(preview.output).toContain('Dry run'); + expect(fs.readFileSync(config, 'utf8')).toBe(before); + const cancelled = await runCLI(['uninstall', '--agent', 'pi'], { HOME: member.home }, member.projectRoot); + expect(cancelled.code, cancelled.output).toBe(0); + expect(cancelled.output).toContain('Cancelled'); + expect(fs.readFileSync(config, 'utf8')).toBe(before); + const confirmed = await runCLI(['uninstall', '--agent', 'pi', '--force'], { HOME: member.home }, member.projectRoot); + expect(confirmed.code, confirmed.output).toBe(0); + expect(confirmed.output).toContain('Excluded pi from this project'); + expect(fs.readFileSync(config, 'utf8')).toContain('disabledAgents:'); + expect(fs.readFileSync(hooks, 'utf8')).toBe(adapter); + console.log('real Pi uninstall: dry-run and cancellation keep config unchanged; --force records exclusion and keeps global adapter'); + }); + it('uninstalls Pi from one project while keeping delivery to another project on the same machine', async () => { const sandbox = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'teamai-945-uninstall-'))); sandboxes.push(sandbox); @@ -1030,6 +1059,25 @@ describe('instruction block targets on real CLI pull (#945)', () => { fs.mkdirSync(path.join(member.home, '.codex', 'skills'), { recursive: true }); const prepared = await pullAs(member); expect(prepared.code, prepared.output).toBe(0); + const workers = path.join(sandbox, 'workers'); + fs.mkdirSync(workers); + const preload = path.join(sandbox, 'capture-workers.cjs'); + fs.writeFileSync(preload, [ + "const fs = require('node:fs');", + "const cp = require('node:child_process');", + `const workers = ${JSON.stringify(workers)};`, + 'const spawn = cp.spawn;', + 'cp.spawn = (command, args, options) => {', + // Keep the fixture's output pipes open until detached workers exit, so + // runCLI's close event joins them before server/filesystem teardown. + " if (options?.detached) options = { ...options, stdio: [Array.isArray(options.stdio) ? options.stdio[0] : 'ignore', 'inherit', 'inherit'] };", + ' const child = spawn(command, args, options);', + " if (options?.detached && child.pid) fs.writeFileSync(workers + '/' + child.pid + '.started', '');", + ' return child;', + '};', + "require('node:module').syncBuiltinESMExports();", + "process.on('exit', () => fs.writeFileSync(workers + '/' + process.pid + '.done', ''));", + ].join('\n')); let endpoint = ''; const acks: Array<{ status: string }> = []; const server = createServer((request, response) => { @@ -1057,11 +1105,16 @@ describe('instruction block targets on real CLI pull (#945)', () => { fs.writeFileSync(path.join(agentDir, 'config.json'), JSON.stringify({ endpoint, token: 'fixture-token', localAgentId: 'fixture', createdAt: '2026-01-01T00:00:00.000Z', workspaceBindings: {}, })); - const first = await runCLI(['hook-dispatch', 'session-start', '--tool', 'codex'], { HOME: member.home }, member.projectRoot, + const first = await runCLI(['hook-dispatch', 'session-start', '--tool', 'codex'], { HOME: member.home, NODE_OPTIONS: `--require ${JSON.stringify(preload)}` }, member.projectRoot, JSON.stringify({ cwd: member.projectRoot, session_id: 'first-http', hook_event_name: 'SessionStart', source: 'startup' })); expect(first.code, first.output).toBe(0); expect(first.stdout).toContain('FIRST-HTTP-PROMPT-SENTINEL'); expect(acks).toEqual([expect.objectContaining({ status: 'success' })]); + const started = fs.readdirSync(workers).filter((file) => file.endsWith('.started')); + expect(started.length).toBeGreaterThan(0); + for (const file of started) { + expect(fs.existsSync(path.join(workers, file.replace('.started', '.done'))), `Worker ${file} must exit before fixture cleanup`).toBe(true); + } console.log('real Codex SessionStart: empty HTTP cache → download ACK success → FIRST-HTTP-PROMPT-SENTINEL in first output'); } finally { await new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve())); diff --git a/src/__tests__/uninstall.test.ts b/src/__tests__/uninstall.test.ts index 6799c98b2..6dc49ce6e 100644 --- a/src/__tests__/uninstall.test.ts +++ b/src/__tests__/uninstall.test.ts @@ -472,6 +472,37 @@ describe('uninstall', () => { expect(bashrcAfter).not.toContain(TEAMAI_ENV_START); }); + it.each(['pi', 'omp', 'hermes', 'codex', 'codex-internal', 'tcodex'])('gates empty-plan %s project exclusion on dry-run and confirmation', async (tool) => { + const homeDir = path.join(tmpDir, 'home'); + const projectRoot = path.join(tmpDir, 'empty-project'); + const repoPath = path.join(projectRoot, '.teamai', 'team-repo'); + await fse.ensureDir(repoPath); + vi.stubEnv('HOME', homeDir); + vi.stubEnv('HERMES_HOME', path.join(homeDir, '.hermes')); + const teamConfig = TeamaiConfigSchema.parse({ team: 't', repo: 'https://example.invalid/t.git' }); + const localConfig = makeLocalConfig(homeDir, repoPath, { scope: 'project', projectRoot, enabledAgents: [tool] }); + mockAutoDetectInit.mockResolvedValue({ localConfig, teamConfig }); + const prompts = await import('../utils/prompt.js'); + const confirm = vi.spyOn(prompts, 'askConfirmation').mockResolvedValue(false); + try { + await uninstall({ agent: tool, dryRun: true }); + expect(mockSaveLocalConfigForScope).not.toHaveBeenCalled(); + expect(confirm).not.toHaveBeenCalled(); + expect(localConfig.disabledAgents).toBeUndefined(); + + await uninstall({ agent: tool }); + expect(confirm).toHaveBeenCalledOnce(); + expect(mockSaveLocalConfigForScope).not.toHaveBeenCalled(); + expect(localConfig.enabledAgents).toEqual([tool]); + + confirm.mockResolvedValue(true); + await uninstall({ agent: tool }); + expect(mockSaveLocalConfigForScope).toHaveBeenCalledWith(expect.objectContaining({ disabledAgents: [tool], enabledAgents: [] }), 'project', projectRoot); + } finally { + confirm.mockRestore(); + } + }); + it('project-scope Pi uninstall preserves the global extension and removes a legacy project copy', async () => { const { homeDir, repoPath } = await setupFixture(tmpDir); const projectRoot = path.join(tmpDir, 'business-repo'); diff --git a/src/uninstall.ts b/src/uninstall.ts index a27a7fa08..1f5cb8804 100644 --- a/src/uninstall.ts +++ b/src/uninstall.ts @@ -1357,19 +1357,15 @@ export async function uninstall(opts: UninstallOptions): Promise { ); } - if (isPlanEmpty(plan)) { - if (agentKey && localConfig.scope === 'project' && ['pi', 'omp', 'hermes', ...CODEX_TOOL_IDS].includes(agentKey)) { - // The global channel belongs to other projects too; exclusion is this - // project's removal even when there are no local files to delete. - await excludeUninstalledAgent(localConfig, agentKey); - log.success(`Excluded ${agentKey} from this project; its global delivery channel is kept for other teamai installs on this machine. If none uses it, run \`teamai hooks remove\` to remove it.`); - return; - } + const exclusionOnly = isPlanEmpty(plan) && agentKey && localConfig.scope === 'project' + && ['pi', 'omp', 'hermes', ...CODEX_TOOL_IDS].includes(agentKey); + if (isPlanEmpty(plan) && !exclusionOnly) { log.info('Nothing to uninstall'); return; } printSummary(plan, agentKey); + if (exclusionOnly) log.info(`Exclude ${agentKey} from this project; keep its global delivery channel.`); if (opts.dryRun) { log.info('Dry run — no changes made'); @@ -1384,6 +1380,13 @@ export async function uninstall(opts: UninstallOptions): Promise { } } + if (exclusionOnly) { + // Exclusion is a config write even when there are no local files to delete. + await excludeUninstalledAgent(localConfig, agentKey!); + log.success(`Excluded ${agentKey} from this project; its global delivery channel is kept for other teamai installs on this machine. If none uses it, run \`teamai hooks remove\` to remove it.`); + return; + } + // Model profiles are machine-global, independent of a project's resources. // Only removal of the user-scope TeamAI home may restore them. Run this // gate before MCP cleanup so a model conflict cannot partially uninstall From 45dcd2171efa5204cc72b57a3a1a2d5fa528a5b8 Mon Sep 17 00:00:00 2001 From: Saul Moro Date: Sat, 3 Oct 2026 00:40:28 +0200 Subject: [PATCH 41/41] fix(instructions): honor exclusions and retained native blocks (#945) --- docs/usage-guide.md | 4 +- docs/usage-guide.zh-CN.md | 4 +- skill-data/core/references/troubleshooting.md | 4 ++ skill-data/setup/references/uninstall.md | 3 ++ src/__tests__/codex-hook-rules.test.ts | 12 +++-- src/__tests__/e2e/instruction-targets.test.ts | 43 +++++++++++++++ src/__tests__/hook-handlers.test.ts | 25 +++++++++ src/__tests__/instruction-targets.test.ts | 53 +++++++++++++++++++ src/__tests__/local-agent.test.ts | 3 +- src/doctor-delivery.ts | 7 ++- src/hook-handlers.ts | 12 +++-- src/instruction-targets.ts | 16 ++++++ src/local-agent.ts | 6 ++- 13 files changed, 177 insertions(+), 15 deletions(-) diff --git a/docs/usage-guide.md b/docs/usage-guide.md index 011c5fdaa..2ca3e5edf 100644 --- a/docs/usage-guide.md +++ b/docs/usage-guide.md @@ -1682,6 +1682,8 @@ A pull from an earlier release may have left these blocks in a file listed below HTTP prompt commands verify the current destinations of all installed former writers, including delivery from previous commands, before removing the retired shared-instructions block. A destination holding an older prompt does not count as delivered. HTTP cleanup preserves culture and recall blocks, which those commands do not replace. +While a native project instruction file still contains a TeamAI block, the session hook skips that block, including a cached HTTP prompt, to avoid adding a second member selection. Other blocks still reach the hook. Delivery resumes after the retained block is cleaned. Codex respects `AGENTS.override.md` precedence, and Oh My Pi respects `.omp/AGENTS.md`. Doctor reports incomplete or repeated markers in retired files; repair those markers before retrying pull. + The pull names each file it changes: - Claude Code, project scope: `.claude/CLAUDE.md` @@ -2876,7 +2878,7 @@ If removing an OpenCode entry added by teamai fails, uninstall exits with an err Project uninstall keeps Pi's and Oh My Pi's global extensions, Hermes' global plugin and configuration, the Codex family's user-level hooks and server-pushed agent hooks, which the user scope, the HTTP agent or another project on this machine may use, and names them in its summary. When none uses them, run `teamai hooks remove` in the project before uninstalling: it removes them. Targeted project Codex uninstall keeps the project config to record its exclusion and removes only project-owned resources and legacy hook copies. A targeted uninstall excludes the tool in this project's config when that config survives. User-scope uninstall removes these global delivery channels. -The exclusion is durable: `uninstall --agent ` drops the tool from `enabledAgents` and records it in `disabledAgents`, so a later `pull` (or another tool's session-start hook) will not resurrect its skills, rules, agents, team instruction blocks, or hooks. Running `init --agent ` again clears the exclusion and re-enables sync for that tool. +The exclusion is durable: `uninstall --agent ` drops the tool from `enabledAgents` and records it in `disabledAgents`, so a later `pull` (or another tool's session-start hook) will not resurrect its skills, rules, agents, team instruction blocks, or hooks. Retained global adapters also skip HTTP sync and cached HTTP prompt injection for that excluded tool. Running `init --agent ` again clears the exclusion and re-enables sync for that tool. The same `enabledAgents` whitelist (from `init --agent`) also gates CLI built-in skills/rules/agents and team instruction blocks: an already-installed tool outside the list is neither written to nor deleted from, even if its root directory already exists. `teamai remove` respects the same whitelist for agents, rules, and skills, `teamai push` reads no rules or agents from a tool outside it, and `teamai pull` / `teamai mcp inject` respect it for MCP servers. Editing `enabledAgents` without `init` still invalidates the last-pull skip cache for newly added tools. diff --git a/docs/usage-guide.zh-CN.md b/docs/usage-guide.zh-CN.md index 095d2c734..829a9e48a 100644 --- a/docs/usage-guide.zh-CN.md +++ b/docs/usage-guide.zh-CN.md @@ -1560,6 +1560,8 @@ Oh My Pi 把 `RULES.md` 作为始终应用的规则读取,与其唯一的用 HTTP prompt 命令会检查所有已安装的旧写入工具的当前目标,包括先前命令的投递结果,确认后才移除旧共享指令区块。目标仍含旧 prompt 时不算投递成功。HTTP 清理保留文化和 recall 区块,因为这些命令不替换它们。 +当原生项目指令文件仍含 TeamAI 区块时,会话 hook 跳过该区块,包括缓存的 HTTP prompt,避免同时加入另一个成员的选择。其他区块仍通过 hook 投递,保留的区块清理后恢复投递。Codex 遵循 `AGENTS.override.md` 的优先级,Oh My Pi 遵循 `.omp/AGENTS.md` 的优先级。Doctor 会报告旧文件中不完整或重复的标记;修复标记后再运行 pull。 + pull 会列出所修改的每个文件: - Claude Code,项目范围:`.claude/CLAUDE.md` @@ -2687,7 +2689,7 @@ teamai uninstall --agent claude 项目级卸载保留 Pi 和 Oh My Pi 的全局扩展、Hermes 的全局插件和配置、Codex 系列的用户级 hooks 以及服务端下发的 agent hooks,因为本机的用户级安装、HTTP agent 或其他项目可能仍在使用它们,并在摘要中列出。若没有其他安装使用它们,请在卸载前于该项目中运行 `teamai hooks remove`,它会移除这些内容。定向项目级 Codex 卸载保留项目配置以记录排除设置,仅清理项目拥有的资源和旧 hook 副本。单工具卸载在项目配置仍保留时,将该工具加入此项目的排除列表。用户级卸载才移除这些全局投递通道。 -该排除是持久的:`uninstall --agent ` 会把该工具从 `enabledAgents` 移除并记入 `disabledAgents`,因此之后的 `pull`(或其他工具的 session-start hook)不会再把它的 skills、rules、agents、团队指令块或 hooks 重新装回。重新执行 `init --agent ` 会清除该排除、恢复对该工具的同步。 +该排除是持久的:`uninstall --agent ` 会把该工具从 `enabledAgents` 移除并记入 `disabledAgents`,因此之后的 `pull`(或其他工具的 session-start hook)不会再把它的 skills、rules、agents、团队指令块或 hooks 重新装回。保留的全局适配器也会跳过被排除工具的 HTTP 同步和缓存 HTTP prompt 注入。重新执行 `init --agent ` 会清除该排除、恢复对该工具的同步。 同一套 `enabledAgents` 白名单(来自 `init --agent`)也约束 CLI 内置 skills/rules/agents 以及团队指令块:即使工具根目录已经存在,白名单外的已安装工具也不会被写入或删除。`teamai remove` 对 agents、rules 和 skills 同样遵守该白名单,`teamai push` 也不会从白名单外的工具读取 rules 和 agents,`teamai pull` / `teamai mcp inject` 对 MCP servers 也遵守该白名单。不经过 `init` 直接把工具加进 `enabledAgents` 时,last-pull 跳过缓存会对新加入的工具失效。 diff --git a/skill-data/core/references/troubleshooting.md b/skill-data/core/references/troubleshooting.md index ea379c394..a1a37513d 100644 --- a/skill-data/core/references/troubleshooting.md +++ b/skill-data/core/references/troubleshooting.md @@ -237,6 +237,10 @@ Pull keeps retired instruction blocks until every installed tool that wrote the file has a working replacement. Repair the named target, extension or plugin and run `teamai pull` again. Excluded tools' current and retired files stay unchanged and are excluded from doctor's stale-instruction check. +When a native project file retains a TeamAI block, the session hook skips that +block, including cached HTTP prompts, until cleanup succeeds. Other blocks +still reach the hook. Doctor reports malformed markers in retired files; +repair them before retrying pull. If a block's source cannot be resolved, its old block stays even when other blocks sync. Repair the source and pull again to complete its migration. HTTP prompt commands verify earlier deliveries against the current prompt diff --git a/skill-data/setup/references/uninstall.md b/skill-data/setup/references/uninstall.md index 4c2f2fe12..08f2f4a23 100644 --- a/skill-data/setup/references/uninstall.md +++ b/skill-data/setup/references/uninstall.md @@ -75,6 +75,9 @@ and give it your team repo URL."* Targeted project Codex uninstall keeps project config and records its exclusion, even without local resources. Legacy project hook copies go. User-scope uninstall removes these global channels. + The retained adapters respect project exclusions, including cached HTTP + prompt injection and HTTP sync. An excluded tool does not download its + resources again on the next session start. - An enabled, installed Pi, Oh My Pi, Hermes or project Codex also keeps the project's shared state in use without a local tool directory. Uninstalling another tool preserves that state and the remaining tool's instructions. diff --git a/src/__tests__/codex-hook-rules.test.ts b/src/__tests__/codex-hook-rules.test.ts index 02d31a005..254c5914f 100644 --- a/src/__tests__/codex-hook-rules.test.ts +++ b/src/__tests__/codex-hook-rules.test.ts @@ -127,7 +127,7 @@ projects: expect(text).not.toContain('Billing instructions.'); }); - it('adds the member\'s own blocks even where an earlier release left another member\'s in the project AGENTS.md (#945)', async () => { + it('skips shared instructions still read natively from a retained project AGENTS.md (#945)', async () => { await fse.outputFile(path.join(repoPath, 'culture.md'), '---\ncompany:\n name: Acme\n---\n\nBe kind to teammates.\n'); await fse.outputFile(path.join(repoPath, 'claudemd', 'shared.md'), 'Shared team instructions.\n'); await fse.outputFile( @@ -138,9 +138,11 @@ projects: const text = (await context({ hook_event_name: 'SessionStart', source: 'startup' }))!; expect(text).toContain('Be kind to teammates.'); - expect(text).toContain('Shared team instructions.'); + expect(text).not.toContain('Shared team instructions.'); expect(text).not.toContain('Another member'); expect(text).not.toContain('[teamai:'); + await fse.writeFile(path.join(tmpDir, 'project', 'AGENTS.md'), '# Notes\n'); + expect(await context({ hook_event_name: 'SessionStart', source: 'startup' })).toContain('Shared team instructions.'); }); it.each(['# Owners\n', ''])('adds blocks from a shadowed AGENTS.md when AGENTS.override.md contains %j', async (override) => { @@ -164,15 +166,17 @@ projects: expect(await fse.readFile(path.join(tmpDir, 'project', 'AGENTS.override.md'), 'utf8')).toBe(override); }); - it('adds the member\'s own blocks even where AGENTS.override.md holds a teamai block (#945)', async () => { + it('skips culture still read natively from AGENTS.override.md until cleanup (#945)', async () => { await fse.outputFile(path.join(repoPath, 'culture.md'), '---\ncompany:\n name: Acme\n---\n\nBe kind to teammates.\n'); await fse.outputFile(path.join(tmpDir, 'project', 'AGENTS.override.md'), '\nAnother member\'s culture.\n\n'); const text = await context({ hook_event_name: 'SessionStart', source: 'startup' }); - expect(text).toContain('Be kind to teammates.'); + expect(text).not.toContain('Be kind to teammates.'); expect(text).not.toContain('Another member'); + await fse.writeFile(path.join(tmpDir, 'project', 'AGENTS.override.md'), '# Notes\n'); + expect(await context({ hook_event_name: 'SessionStart', source: 'startup' })).toContain('Be kind to teammates.'); }); it('adds no rules from a scope that does not enable the tool', async () => { diff --git a/src/__tests__/e2e/instruction-targets.test.ts b/src/__tests__/e2e/instruction-targets.test.ts index 2e157c7f6..ff8880ce2 100644 --- a/src/__tests__/e2e/instruction-targets.test.ts +++ b/src/__tests__/e2e/instruction-targets.test.ts @@ -517,6 +517,36 @@ describe('instruction block targets on real CLI pull (#945)', () => { expect(context).not.toContain('teamai-recall'); }); + it('suppresses Pi hook blocks while a blocked WorkBuddy replacement retains shared native instructions', async () => { + const sandbox = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'teamai-945-held-native-'))); + sandboxes.push(sandbox); + const member = makeProjectMember(sandbox, makeTeamAndProject(sandbox), 'dev', 'developer', ['.workbuddy/skills']); + fs.mkdirSync(path.join(member.home, '.pi'), { recursive: true }); + const legacy = path.join(member.projectRoot, 'AGENTS.md'); + const original = `${PROJECT_AGENTS_MD}${CLAUDEMD_START}\nPRODUCT-STALE\n${CLAUDEMD_END}\n`; + fs.writeFileSync(legacy, original); + const replacement = path.join(member.projectRoot, '.codebuddy/rules/teamai-context.md'); + fs.mkdirSync(path.dirname(replacement), { recursive: true }); + fs.writeFileSync(replacement, '# Foreign file\n'); + + const blocked = await pullAs(member); + expect(blocked.code, blocked.output).toBe(0); + expect(fs.readFileSync(legacy, 'utf8')).toBe(original); + const held = await sessionInstructions('pi', member.home, member.projectRoot); + expect(held).not.toContain('DEVELOPMENT-SENTINEL'); + expect(held).not.toContain('PRODUCT-STALE'); + expect(held).toContain('Acme'); + console.log('blocked WorkBuddy replacement: native prompt retained; Pi hook omits that block and still delivers culture'); + + fs.unlinkSync(replacement); + const retry = await runCLI(['pull'], { HOME: member.home }, member.projectRoot); + expect(retry.code, retry.output).toBe(0); + expect(fs.readFileSync(replacement, 'utf8')).toContain('DEVELOPMENT-SENTINEL'); + expect(fs.readFileSync(legacy, 'utf8')).toBe(PROJECT_AGENTS_MD); + expect(await sessionInstructions('pi', member.home, member.projectRoot)).toContain('DEVELOPMENT-SENTINEL'); + console.log('WorkBuddy repair: legacy block removed; Pi hook resumes the developer prompt'); + }); + it('retains Claude legacy instructions until a foreign replacement is repaired', async () => { const sandbox = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'teamai-945-migration-'))); sandboxes.push(sandbox); @@ -1079,11 +1109,13 @@ describe('instruction block targets on real CLI pull (#945)', () => { "process.on('exit', () => fs.writeFileSync(workers + '/' + process.pid + '.done', ''));", ].join('\n')); let endpoint = ''; + let syncRequests = 0; const acks: Array<{ status: string }> = []; const server = createServer((request, response) => { let body = ''; request.on('data', (chunk: Buffer) => { body += chunk.toString(); }); request.on('end', () => { + if (request.url?.endsWith('/local-agent/sync')) syncRequests++; if (request.url?.endsWith('/first.md')) { setTimeout(() => response.end('FIRST-HTTP-PROMPT-SENTINEL'), 100); return; @@ -1116,6 +1148,17 @@ describe('instruction block targets on real CLI pull (#945)', () => { expect(fs.existsSync(path.join(workers, file.replace('.started', '.done'))), `Worker ${file} must exit before fixture cleanup`).toBe(true); } console.log('real Codex SessionStart: empty HTTP cache → download ACK success → FIRST-HTTP-PROMPT-SENTINEL in first output'); + const removed = await runCLI(['uninstall', '--agent', 'codex', '--force'], { HOME: member.home }, member.projectRoot); + expect(removed.code, removed.output).toBe(0); + const before = syncRequests; + for (const [event, hook_event_name] of [['session-start', 'SessionStart'], ['subagent-start', 'SubagentStart']] as const) { + const excluded = await runCLI(['hook-dispatch', event, '--tool', 'codex'], { HOME: member.home, NODE_OPTIONS: `--require ${JSON.stringify(preload)}` }, member.projectRoot, + JSON.stringify({ cwd: member.projectRoot, session_id: 'excluded-http', hook_event_name, source: 'startup' })); + expect(excluded.code, excluded.output).toBe(0); + expect(excluded.stdout).not.toContain('FIRST-HTTP-PROMPT-SENTINEL'); + } + expect(syncRequests).toBe(before); + console.log('after project Codex uninstall: SessionStart/SubagentStart inject no cached HTTP prompt and send no HTTP sync'); } finally { await new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve())); } diff --git a/src/__tests__/hook-handlers.test.ts b/src/__tests__/hook-handlers.test.ts index 81d2879da..7ed2fd0a5 100644 --- a/src/__tests__/hook-handlers.test.ts +++ b/src/__tests__/hook-handlers.test.ts @@ -24,6 +24,7 @@ const mockIncrementUpvoted = vi.fn().mockImplementation(async (_p: string, docId const mockSyncVotesToTeam = vi.fn().mockResolvedValue(false); const mockDoUpdate = vi.fn().mockResolvedValue(undefined); const mockReportAndSyncFromHook = vi.fn().mockResolvedValue(null); +const mockLocalAgentInstructionText = vi.fn().mockResolvedValue(''); const mockPackageManifestHash = vi.fn().mockResolvedValue('before-hash'); const mockStashPackageHint = vi.fn().mockResolvedValue(undefined); const mockClaimPackageHint = vi.fn().mockResolvedValue(null); @@ -114,6 +115,7 @@ vi.mock('../utils/logger.js', () => ({ vi.mock('../local-agent.js', () => ({ reportAndSyncFromHook: mockReportAndSyncFromHook, + localAgentInstructionText: mockLocalAgentInstructionText, })); vi.mock('../pkg/pkg-hint.js', () => ({ @@ -177,6 +179,7 @@ const scope: LocalConfig = { repo: { localPath: '/tmp', remote: '' }, username: describe('hook-handlers registry', () => { beforeEach(() => { vi.clearAllMocks(); + mockLocalAgentInstructionText.mockResolvedValue(''); mockParseTranscriptForVotes.mockResolvedValue({ recalledDocIds: [], finalAssistantText: '', recalledDocPaths: {}, recalledDocScopes: {} }); mockIncrementUpvoted.mockImplementation(async (_p: string, docIds: string[]) => docIds); mockCreditAdoptedDocs.mockResolvedValue({ credited: [], recalled: 0 }); @@ -231,6 +234,28 @@ describe('hook-handlers registry', () => { expect(sessionStartHandlers).toContain('dashboard-report'); }); + it.each(['pi', 'omp', 'hermes', 'codex', 'codex-internal', 'tcodex'])('does not read or sync HTTP prompts for excluded %s', async (tool) => { + const registry = buildHandlerRegistry(); + const cached = registry.find((r) => r.handler.name === 'http-prompt-instructions')!.handler; + const sync = registry.find((r) => r.handler.name === 'local-agent-sync')!.handler; + const config = { scope: 'project', disabledAgents: [tool] } as never; + const input = { cwd: '/project', hook_event_name: tool.includes('codex') ? 'SubagentStart' : 'instructions' }; + expect(await cached.execute(input, tool, config)).toBeNull(); + expect(await sync.execute({ cwd: '/project', hook_event_name: 'SessionStart' }, tool, config)).toBeNull(); + expect(mockLocalAgentInstructionText).not.toHaveBeenCalled(); + expect(mockReportAndSyncFromHook).not.toHaveBeenCalled(); + }); + + it('still delivers cached HTTP prompts without a git team configuration', async () => { + mockLocalAgentInstructionText.mockResolvedValue('HTTP-PROMPT'); + const registry = buildHandlerRegistry(); + const cached = registry.find((r) => r.handler.name === 'http-prompt-instructions')!.handler; + const sync = registry.find((r) => r.handler.name === 'local-agent-sync')!.handler; + expect(await cached.execute({ cwd: '/project' }, 'pi', null)).toContain('HTTP-PROMPT'); + expect(await sync.execute({ cwd: '/project', hook_event_name: 'SessionStart' }, 'codex', null)).toContain('HTTP-PROMPT'); + expect(mockReportAndSyncFromHook).toHaveBeenCalledOnce(); + }); + it('session-start pull seeds the hook tool root before pulling', async () => { const registry = buildHandlerRegistry(); const handler = registry.find( diff --git a/src/__tests__/instruction-targets.test.ts b/src/__tests__/instruction-targets.test.ts index 5e02f942a..1e3705920 100644 --- a/src/__tests__/instruction-targets.test.ts +++ b/src/__tests__/instruction-targets.test.ts @@ -8,6 +8,7 @@ import { applyInstructionPlan, clearInstructionFile, instructionChannelProblems, + instructionHookTextFor, planInstructionFiles, registerOpencodeContext, retiredFilesOfReached, @@ -229,6 +230,36 @@ describe('instruction file planning (#945)', () => { }); describe('instruction channel problems (#945)', () => { + it.each(['pi', 'omp', 'hermes', 'codex', 'codex-internal', 'tcodex'])('suppresses only native legacy blocks for %s until cleanup succeeds', async (tool) => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'teamai-945-native-')); + try { + const projectRoot = path.join(root, 'project'); + const repo = path.join(root, 'repo'); + const legacy = path.join(projectRoot, tool === 'omp' ? '.omp/AGENTS.md' : 'AGENTS.md'); + fs.mkdirSync(path.dirname(legacy), { recursive: true }); + fs.mkdirSync(path.join(repo, 'claudemd'), { recursive: true }); + fs.writeFileSync(path.join(repo, 'claudemd/shared.md'), 'FRESH-PROMPT'); + fs.writeFileSync(path.join(repo, 'culture.md'), 'FRESH-CULTURE'); + fs.writeFileSync(legacy, `${claudemd('OLD-MEMBER-PROMPT')}\n`); + const localConfig = { repo: { localPath: repo, remote: '' }, username: 'u', additionalRoles: [], scope: 'project', projectRoot, recallEnabled: false } as LocalConfig; + const teamConfig = TeamaiConfigSchema.parse({ team: 't', repo: 'https://example.invalid/t.git' }); + const held = await instructionHookTextFor(teamConfig, localConfig, tool); + expect(held).not.toContain('FRESH-PROMPT'); + expect(held).toContain('FRESH-CULTURE'); + fs.writeFileSync(legacy, '# Authored instructions\n'); + expect(await instructionHookTextFor(teamConfig, localConfig, tool)).toContain('FRESH-PROMPT'); + if (tool.includes('codex')) { + fs.writeFileSync(legacy, claudemd('SHADOWED-PROMPT')); + fs.writeFileSync(path.join(projectRoot, 'AGENTS.override.md'), '# Native override\n'); + expect(await instructionHookTextFor(teamConfig, localConfig, tool)).toContain('FRESH-PROMPT'); + fs.writeFileSync(path.join(projectRoot, 'AGENTS.override.md'), `${TEAMAI_CLAUDEMD_START}\nMALFORMED-LEGACY`); + expect(await instructionHookTextFor(teamConfig, localConfig, tool)).not.toContain('FRESH-PROMPT'); + } + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }); + it('names a missing Pi extension in a project, and nothing once it is installed', async () => { const root = fs.realpathSync.native(fs.mkdtempSync(path.join(os.tmpdir(), 'teamai-945-channel-'))); const prevHome = process.env.HOME; @@ -606,6 +637,28 @@ describe('OpenCode instructions ownership (#945)', () => { } }); + it.each(['incomplete', 'repeated'])('reports %s markers left in a retired file through doctor', async (kind) => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'teamai-945-malformed-doctor-')); + vi.stubEnv('HOME', path.join(root, 'home')); + try { + const projectRoot = path.join(root, 'project'); + const legacy = path.join(projectRoot, '.claude/CLAUDE.md'); + fs.mkdirSync(path.dirname(legacy), { recursive: true }); + fs.writeFileSync(legacy, kind === 'incomplete' ? `${TEAMAI_CLAUDEMD_START}\nold` : `${claudemd('old')}\n${claudemd('repeated')}`); + const localConfig = { repo: { localPath: path.join(root, 'repo'), remote: '' }, username: 'u', additionalRoles: [], scope: 'project', projectRoot } as LocalConfig; + const teamConfig = TeamaiConfigSchema.parse({ team: 't', repo: 'https://example.invalid/t.git' }); + const { buildInstructionDeliveryChecks } = await import('../doctor-delivery.js'); + const checks = await buildInstructionDeliveryChecks({ teamConfig, localConfig } as never); + const stale = checks.find((check) => check.name === 'No team instruction blocks are left in files no tool loads them from'); + expect(await stale!.check()).toBe(false); + expect(stale!.fix).toContain(legacy); + expect(stale!.fix).toMatch(/marker/i); + } finally { + vi.unstubAllEnvs(); + fs.rmSync(root, { recursive: true, force: true }); + } + }); + it('does not report excluded Claude legacy blocks as a stale doctor delivery', async () => { const root = fs.realpathSync.native(fs.mkdtempSync(path.join(os.tmpdir(), 'teamai-945-excluded-file-'))); vi.stubEnv('HOME', path.join(root, 'home')); diff --git a/src/__tests__/local-agent.test.ts b/src/__tests__/local-agent.test.ts index 1fc8f83bc..44e4e7b8c 100644 --- a/src/__tests__/local-agent.test.ts +++ b/src/__tests__/local-agent.test.ts @@ -2210,6 +2210,8 @@ describe('local-agent: project prompts reach every installed tool (#945)', () => const { repo, ack } = await installProjectPrompt(first, ['.workbuddy/skills'], { files: { 'AGENTS.md': legacy } }); expect(ack?.status).toBe('success'); expect(await fse.readFile(path.join(repo, 'AGENTS.md'), 'utf8')).toContain('old member prompt'); + const { localAgentInstructionText } = await import('../local-agent.js'); + expect(await localAgentInstructionText(repo, 'pi')).toBe(''); await injectPiHooks(); const next = await installProjectPrompt(second, []); @@ -2218,7 +2220,6 @@ describe('local-agent: project prompts reach every installed tool (#945)', () => expect(retired).not.toContain('old member prompt'); expect(retired).toContain('working culture'); expect(await fse.readFile(path.join(repo, '.codebuddy/rules/teamai-context.md'), 'utf8')).toContain('PROJECT-PROMPT'); - const { localAgentInstructionText } = await import('../local-agent.js'); expect(await localAgentInstructionText(repo)).toContain('PROJECT-PROMPT'); }); diff --git a/src/doctor-delivery.ts b/src/doctor-delivery.ts index bfd408660..aade871d3 100644 --- a/src/doctor-delivery.ts +++ b/src/doctor-delivery.ts @@ -1211,14 +1211,17 @@ export async function buildInstructionDeliveryChecks(ctx: DoctorContext): Promis } const leftovers: string[] = []; + const warnings: string[] = []; for (const file of stale) { - if ((await planInstructionFiles([], {}, [file])).changes.length > 0) leftovers.push(file.path); + const plan = await planInstructionFiles([], {}, [file]); + if (plan.changes.length > 0 || plan.warnings.length > 0) leftovers.push(file.path); + warnings.push(...plan.warnings); } checks.push({ name: 'No team instruction blocks are left in files no tool loads them from', source: 'local', check: async () => leftovers.length === 0, - fix: `Earlier teamai releases left team instruction blocks in ${nameList(leftovers)}, which can carry another member's selection. ${pullNow}`, + fix: [...warnings, `Earlier teamai releases left team instruction blocks in ${nameList(leftovers)}, which can carry another member's selection. ${pullNow}`].join(' '), }); return checks; } diff --git a/src/hook-handlers.ts b/src/hook-handlers.ts index d1f0ba063..220dbdcf1 100644 --- a/src/hook-handlers.ts +++ b/src/hook-handlers.ts @@ -779,7 +779,9 @@ const instructionsHandler: HookHandler = { */ const localAgentInstructionsHandler: HookHandler = { name: 'http-prompt-instructions', - async execute(stdin, tool) { + async execute(stdin, tool, config) { + const { isAgentExcluded } = await import('./types.js'); + if (config && isAgentExcluded(config, tool)) return null; const { deliversInstructionsByHook } = await import('./instruction-targets.js'); const { getsRulesFromSessionHook } = await import('./resources/rule-format.js'); if (!deliversInstructionsByHook(tool, 'project')) return null; @@ -787,7 +789,7 @@ const localAgentInstructionsHandler: HookHandler = { // Each tool through its own channel only; a resumed Codex session holds them already. if (getsRulesFromSessionHook(tool) !== sessionEvent || stdin.source === 'resume') return null; const { localAgentInstructionText } = await import('./local-agent.js'); - const text = await localAgentInstructionText(resolveHookCwd(stdin) ?? process.cwd()); + const text = await localAgentInstructionText(resolveHookCwd(stdin) ?? process.cwd(), tool); if (!text) return null; const hookEventName = stdin.hook_event_name === 'SubagentStart' ? 'SubagentStart' : 'SessionStart'; return JSON.stringify({ hookSpecificOutput: { hookEventName, additionalContext: text } }); @@ -797,13 +799,15 @@ const localAgentInstructionsHandler: HookHandler = { /** HTTP local-agent report/sync + workspace binding prompts. */ const localAgentHandler: HookHandler = { name: 'local-agent-sync', - async execute(stdin, tool) { + async execute(stdin, tool, config) { + const { isAgentExcluded } = await import('./types.js'); + if (config && isAgentExcluded(config, tool)) return null; const { reportAndSyncFromHook } = await import('./local-agent.js'); const output = await reportAndSyncFromHook(stdin, tool); // Codex's first prompt must read the cache after this sync, rather than // racing it in another handler. SubagentStart reads the parent's cache. if (stdin.hook_event_name === 'SessionStart') { - return await localAgentInstructionsHandler.execute(stdin, tool, null) ?? output; + return await localAgentInstructionsHandler.execute(stdin, tool, config) ?? output; } return output; }, diff --git a/src/instruction-targets.ts b/src/instruction-targets.ts index 1ff3ae29f..83892970d 100644 --- a/src/instruction-targets.ts +++ b/src/instruction-targets.ts @@ -249,11 +249,27 @@ export function instructionHookText(blocks: InstructionBlocks, recall: boolean): .join('\n\n'); } +/** The native project context, including legacy blocks that migration could not yet retire. */ +export async function nativeProjectInstructions(tool: string, projectRoot: string): Promise { + const preferred = CODEX_TOOL_IDS.some((id) => id === tool) ? 'AGENTS.override.md' + : tool === 'omp' ? '.omp/AGENTS.md' : undefined; + return (preferred ? await readFileSafe(path.join(projectRoot, preferred)) : null) + ?? await readFileSafe(path.join(projectRoot, 'AGENTS.md')) ?? ''; +} + /** The text a session hook adds for `tool`, resolved for the member, project and scope in `localConfig`. */ export async function instructionHookTextFor(teamConfig: TeamaiConfig, localConfig: LocalConfig, tool: string): Promise { const { buildRolePullContext } = await import('./resources/desired.js'); const { resolveInstructionBlocks } = await import('./pull.js'); const { blocks } = await resolveInstructionBlocks(teamConfig, localConfig, await buildRolePullContext(localConfig)); + if (localConfig.scope === 'project' && localConfig.projectRoot) { + const native = await nativeProjectInstructions(tool, localConfig.projectRoot); + // Native context still supplies each retained block. Do not add a second + // member selection while another writer's replacement holds cleanup back. + if (native.includes(CULTURE[0]) || native.includes(CULTURE[1])) blocks.culture = null; + if (native.includes(CLAUDEMD[0]) || native.includes(CLAUDEMD[1])) blocks.claudemd = null; + if (native.includes(RECALL[0]) || native.includes(RECALL[1])) blocks.recall = blocks.directRecall = null; + } return instructionHookText(blocks, Boolean(scopedToolPaths(teamConfig, localConfig)[tool]?.agents)); } diff --git a/src/local-agent.ts b/src/local-agent.ts index 06aed6737..b01f8f390 100644 --- a/src/local-agent.ts +++ b/src/local-agent.ts @@ -54,7 +54,7 @@ import { logHttpRequest, logHttpResponse } from './utils/http-log.js'; import { applyInstructionPlan, deliversInstructionsByHook, instructionHookChannel, instructionHookText, instructionHookTextFor, instructionTargetAt, instructionTargetFile, isInstructionToolInstalled, planInstructionFiles, registerOpencodeContext, resolveInstructionTargets, - retiredFilesOfReached, + retiredFilesOfReached, nativeProjectInstructions, } from './instruction-targets.js'; import { opencodeClaudeFallback } from './resources/opencode-config.js'; import { reconcilePlugins, teardownAllPlugins, parseGetConfig, substituteVars, unresolvedPlaceholders, type ReconcileDeps, type PluginState } from './plugin-lifecycle.js'; @@ -2100,9 +2100,11 @@ async function cachedClaudemdBlock(repoPath: string): Promise<{ files: string[]; * session hook adds (#945): Pi, OMP and Hermes have no project file of their * own. Empty outside a project the agent delivered to. */ -export async function localAgentInstructionText(cwd: string): Promise { +export async function localAgentInstructionText(cwd: string, tool = ''): Promise { const workspacePath = await resolveWorkspacePath(cwd); if (!workspacePath) return ''; + const native = await nativeProjectInstructions(tool, workspacePath); + if (native.includes(TEAMAI_CLAUDEMD_START) || native.includes(TEAMAI_CLAUDEMD_END)) return ''; const { block } = await cachedClaudemdBlock(await getResourceRepoPath('project', workspacePath)); return block ? instructionHookText({ claudemd: block }, false) : ''; }