diff --git a/src/__tests__/e2e/enabled-agents-whitelist.test.ts b/src/__tests__/e2e/enabled-agents-whitelist.test.ts index 652b6f27b..676bd522a 100644 --- a/src/__tests__/e2e/enabled-agents-whitelist.test.ts +++ b/src/__tests__/e2e/enabled-agents-whitelist.test.ts @@ -101,7 +101,7 @@ describe('enabledAgents whitelist on real CLI pull (#510)', () => { fs.mkdirSync(path.join(homeDir, '.workbuddy'), { recursive: true }); fs.mkdirSync(path.join(homeDir, '.hermes'), { recursive: true }); - fs.mkdirSync(path.join(homeDir, '.claude'), { recursive: true }); + fs.mkdirSync(path.join(homeDir, '.claude', 'rules'), { recursive: true }); fs.mkdirSync(path.join(homeDir, '.codebuddy'), { recursive: true }); fs.writeFileSync(path.join(homeDir, '.codebuddy', 'CODEBUDDY.md'), '# User notes\n'); @@ -172,9 +172,16 @@ describe('enabledAgents whitelist on real CLI pull (#510)', () => { expect(JSON.parse( fs.readFileSync(path.join(homeDir, '.teamai', 'state.json'), 'utf8'), ).lastPullTargets).toEqual(['claude', 'workbuddy']); + const claudeMdPath = path.join(homeDir, '.claude', 'CLAUDE.md'); + const claudeMd = fs.readFileSync(claudeMdPath, 'utf8'); + expect(claudeMd).toContain('Follow the self-exemption in [Team Knowledge Recall](rules/teamai-recall.md) before applying this section.'); + expect(claudeMd).not.toContain('If you ARE the `teamai-recall` subagent yourself'); + expect(fs.readFileSync(path.join(homeDir, '.claude', 'rules', 'teamai-recall.md'), 'utf8')) + .toContain('If you ARE the `teamai-recall` subagent yourself'); const third = await runCLI(['pull'], env, sandbox); expect(third.code, third.output).toBe(0); expect(third.output).toContain('Already synced'); + expect(fs.readFileSync(claudeMdPath, 'utf8')).toBe(claudeMd); }, 60_000); }); diff --git a/src/__tests__/pull-skip-sync.test.ts b/src/__tests__/pull-skip-sync.test.ts index f2ae089d4..641d0ad5d 100644 --- a/src/__tests__/pull-skip-sync.test.ts +++ b/src/__tests__/pull-skip-sync.test.ts @@ -683,7 +683,7 @@ describe('pull skip-sync refreshes CLAUDE.md recall block (CLI upgrade)', () => const updated = await fse.readFile(claudeMdPath, 'utf8'); // Stale block gone, current block present — verbatim from the CLI source. expect(updated).not.toContain('you **MUST** first invoke the `teamai-recall` subagent'); - expect(updated).toContain(compileRecallRulesBlock()); + expect(updated).toContain(compileRecallRulesBlock('rules/teamai-recall.md')); // Exactly one managed block (replace, not append). expect(updated.split(TEAMAI_RECALL_RULES_START).length - 1).toBe(1); expect(updated.split(TEAMAI_RECALL_RULES_END).length - 1).toBe(1); diff --git a/src/__tests__/recall-rules.test.ts b/src/__tests__/recall-rules.test.ts index 3d9c30eb2..589d9303e 100644 --- a/src/__tests__/recall-rules.test.ts +++ b/src/__tests__/recall-rules.test.ts @@ -19,7 +19,9 @@ vi.mock('../utils/logger.js', () => ({ })), })); -import { compileRecallRulesBlock } from '../pull.js'; +import { compileRecallRulesBlock, injectRecallBlockIntoTools } from '../pull.js'; +import { deployBuiltinRules, TEAMAI_RECALL_SELF_EXEMPTION } from '../builtin-rules.js'; +import type { LocalConfig, TeamaiConfig } from '../types.js'; import { injectClaudeMdSection } from '../utils/claudemd.js'; import { TEAMAI_RECALL_RULES_START, TEAMAI_RECALL_RULES_END } from '../types.js'; @@ -33,11 +35,70 @@ describe('compileRecallRulesBlock', () => { expect(block).toMatch(/Before/i); }); + it('replaces only self-exemption with a rule pointer and preserves every other policy', () => { + const pointer = '> **Self-exemption (must read first):** Follow the self-exemption in [Team Knowledge Recall](rules/teamai-recall.md) before applying this section.'; + const fallback = compileRecallRulesBlock(); + expect(fallback).toContain(TEAMAI_RECALL_SELF_EXEMPTION); + expect(fallback).toContain('If you ARE the `teamai-recall` subagent yourself'); + expect(compileRecallRulesBlock('rules/teamai-recall.md')) + .toBe(fallback.replace(TEAMAI_RECALL_SELF_EXEMPTION, pointer)); + }); + it('is idempotent (same input produces same output)', () => { expect(compileRecallRulesBlock()).toBe(compileRecallRulesBlock()); }); }); +describe('injectRecallBlockIntoTools self-exemption', () => { + let tmpDir: string; + let localConfig: LocalConfig; + + beforeEach(async () => { + tmpDir = await fse.mkdtemp(path.join(os.tmpdir(), 'teamai-recall-pointer-')); + vi.stubEnv('HOME', tmpDir); + vi.stubEnv('TEAMAI_RECALL_DISABLED', ''); + localConfig = { + repo: { localPath: path.join(tmpDir, 'repo'), remote: '' }, + username: 'test', additionalRoles: [], updatePolicy: 'auto', scope: 'user', recallEnabled: true, + }; + }); + afterEach(async () => { + vi.unstubAllEnvs(); + await fse.remove(tmpDir); + }); + + it.each([ + ['claude', '.md'], + ['cursor', '.mdc'], + ])('links the deployed %s rule relative to instructions and is idempotent', async (tool, extension) => { + const toolPath = { agents: `.${tool}/agents`, rules: `.${tool}/rules`, claudemd: `.${tool}/instructions/CLAUDE.md` }; + const config = { toolPaths: { [tool]: toolPath } } as TeamaiConfig; + await fse.ensureDir(path.join(tmpDir, toolPath.agents)); + await fse.ensureDir(path.join(tmpDir, toolPath.rules)); + const target = path.join(tmpDir, toolPath.claudemd); + const old = `# Personal instructions\n\n${compileRecallRulesBlock()}\n`; + await fse.outputFile(target, old); + await deployBuiltinRules(config, localConfig); + + await injectRecallBlockIntoTools(config, localConfig, 'user'); + const first = await fse.readFile(target, 'utf8'); + expect(first).toBe(old.replace(compileRecallRulesBlock(), compileRecallRulesBlock(`../rules/teamai-recall${extension}`))); + expect(first).not.toContain(TEAMAI_RECALL_SELF_EXEMPTION); + expect(await fse.readFile(path.join(tmpDir, toolPath.rules, `teamai-recall${extension}`), 'utf8')) + .toContain(TEAMAI_RECALL_SELF_EXEMPTION); + await injectRecallBlockIntoTools(config, localConfig, 'user'); + expect(await fse.readFile(target, 'utf8')).toBe(first); + }); + + it.each([undefined, '.claude/rules'])('keeps inline fallback when the rule is not deployed (rules=%s)', async (rules) => { + const config = { toolPaths: { claude: { agents: '.claude/agents', claudemd: '.claude/CLAUDE.md', rules } } } as unknown as TeamaiConfig; + await fse.ensureDir(path.join(tmpDir, '.claude/agents')); + await injectRecallBlockIntoTools(config, localConfig, 'user'); + expect(await fse.readFile(path.join(tmpDir, '.claude/CLAUDE.md'), 'utf8')) + .toContain(compileRecallRulesBlock()); + }); +}); + describe('injectClaudeMdSection — recall rules block lifecycle', () => { let tmpDir: string; let claudeMdPath: string; diff --git a/src/__tests__/recall-toggle.test.ts b/src/__tests__/recall-toggle.test.ts index 93f2c5020..1114eda62 100644 --- a/src/__tests__/recall-toggle.test.ts +++ b/src/__tests__/recall-toggle.test.ts @@ -141,7 +141,7 @@ describe('recall toggle native agent cleanup', () => { additionalRoles: [], scope: 'user', enabledAgents: ['copilot'], - recallEnabled: true, + recallEnabled: false, }; const teamConfig = TeamaiConfigSchema.parse({ team: 'test', @@ -153,6 +153,7 @@ describe('recall toggle native agent cleanup', () => { const enabled = await fse.readFile(instructionPath, 'utf8'); expect(enabled).toContain(userInstructions.trim()); expect(enabled).toContain(TEAMAI_RECALL_RULES_START); + expect(enabled).toContain('[Team Knowledge Recall](instructions/teamai-recall.instructions.md)'); await expect(fse.pathExists(path.join( copilotHome, 'instructions', diff --git a/src/builtin-rules.ts b/src/builtin-rules.ts index 7f7d912b3..145b74279 100644 --- a/src/builtin-rules.ts +++ b/src/builtin-rules.ts @@ -124,11 +124,13 @@ export async function deployBuiltinRules( // ─── Rule content ────────────────────────────────────── +export const TEAMAI_RECALL_SELF_EXEMPTION = `> **Self-exemption (must read first):** If you ARE the \`teamai-recall\` subagent yourself, this rule does NOT apply to you — do not invoke \`teamai-recall\` (or any recall) again. Proceed directly to performing the knowledge search that is your task. This prevents infinite subagent recursion in tools (e.g. Cursor) whose always-apply rules leak into subagent sessions. +> +> **自豁免(务必先读):** 如果你自己就是 \`teamai-recall\` subagent,本规则对你不适用——不要再调用 \`teamai-recall\`(或任何 recall),直接执行你本职的知识检索任务。此举防止在(如 Cursor 等)会把 always-apply 规则泄漏进 subagent 会话的工具中发生无限递归。`; + const TEAMAI_RECALL_RULE_CONTENT = `# Team Knowledge Recall (teamai) -> **Self-exemption (must read first):** If you ARE the \`teamai-recall\` subagent yourself, this rule does NOT apply to you — do not invoke \`teamai-recall\` (or any recall) again. Proceed directly to performing the knowledge search that is your task. This prevents infinite subagent recursion in tools (e.g. Cursor) whose always-apply rules leak into subagent sessions. -> -> **自豁免(务必先读):** 如果你自己就是 \`teamai-recall\` subagent,本规则对你不适用——不要再调用 \`teamai-recall\`(或任何 recall),直接执行你本职的知识检索任务。此举防止在(如 Cursor 等)会把 always-apply 规则泄漏进 subagent 会话的工具中发生无限递归。 +${TEAMAI_RECALL_SELF_EXEMPTION} ## 规则:任务开始前检索团队知识库(推荐) diff --git a/src/pull.ts b/src/pull.ts index 6604a59f4..e8a6c457f 100644 --- a/src/pull.ts +++ b/src/pull.ts @@ -25,6 +25,7 @@ import { forgetDelivered, judgeCopy, openLedger, removedCopyChanged, reportKept, type DeliveredHashes, type DeliveryLedger, } from './resources/delivered-copies.js'; import { BUILTIN_SKILL_NAMES } from './builtin-skills.js'; +import { TEAMAI_RECALL_SELF_EXEMPTION } from './builtin-rules.js'; import type { GlobalOptions, ResourceType, ResourceItem, TeamaiConfig, LocalConfig, State } from './types.js'; import { getUserLearningsDir, @@ -1355,11 +1356,6 @@ async function pullForScope( 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); - } - // Step 4: Deploy CLI built-in skills if (!options.dryRun) { try { @@ -1387,6 +1383,11 @@ async function pullForScope( } } + // Inject after rule deployment so the self-exemption link works on the first pull. + if (!options.dryRun) { + await injectRecallBlockIntoTools(freshConfig, localConfig, scopeLabel); + } + // Step 4.6: Deploy CLI built-in agents (e.g. teamai-recall subagent) if (!options.dryRun) { try { @@ -1708,7 +1709,7 @@ async function syncManagedInstructions( * workbuddy) 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" + * Extracted so both the full-sync path 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. @@ -1720,7 +1721,6 @@ export async function injectRecallBlockIntoTools( ): Promise { if (!isRecallEnabled(localConfig, config)) return; try { - const recallBlock = compileRecallRulesBlock(); let injected = 0; for (const [tool, toolPath] of Object.entries(scopedToolPaths(config, localConfig))) { if (isAgentExcluded(localConfig, tool)) continue; @@ -1729,6 +1729,14 @@ export async function injectRecallBlockIntoTools( const baseDir = resolveToolBaseDir(tool, localConfig); const claudeMdPath = path.join(baseDir, toolPath.claudemd); + const recallRulePath = toolPath.rules + ? path.join(baseDir, toolPath.rules, `teamai-recall${ruleFileExtensionForTool(tool)}`) + : undefined; + const recallBlock = compileRecallRulesBlock( + recallRulePath && await pathExists(recallRulePath) + ? path.relative(path.dirname(claudeMdPath), recallRulePath).split(path.sep).join('/') + : undefined, + ); try { await injectClaudeMdSection( claudeMdPath, @@ -1757,18 +1765,18 @@ export async function injectRecallBlockIntoTools( * 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. + * paths configured). Without a deployed recall rule, keep self-exemption inline. */ -export function compileRecallRulesBlock(): string { +export function compileRecallRulesBlock(recallRulePath?: string): string { const lines = [ TEAMAI_RECALL_RULES_START, '', '', '## Team Knowledge Recall (teamai)', '', - '> **Self-exemption (must read first):** If you ARE the `teamai-recall` subagent yourself, this rule does NOT apply to you — do not invoke `teamai-recall` (or any recall) again. Proceed directly to performing the knowledge search that is your task. This prevents infinite subagent recursion in tools (e.g. Cursor) whose always-apply rules leak into subagent sessions.', - '>', - '> **自豁免(务必先读):** 如果你自己就是 `teamai-recall` subagent,本规则对你不适用——不要再调用 `teamai-recall`(或任何 recall),直接执行你本职的知识检索任务。此举防止在(如 Cursor 等)会把 always-apply 规则泄漏进 subagent 会话的工具中发生无限递归。', + recallRulePath + ? `> **Self-exemption (must read first):** Follow the self-exemption in [Team Knowledge Recall](${recallRulePath}) before applying this section.` + : TEAMAI_RECALL_SELF_EXEMPTION, '', '**Before** starting a task that involves code changes, debugging,', 'or design decisions, you **SHOULD** invoke the `teamai-recall`', diff --git a/src/recall-toggle.ts b/src/recall-toggle.ts index 341e1c9f9..beb250e7f 100644 --- a/src/recall-toggle.ts +++ b/src/recall-toggle.ts @@ -2,7 +2,6 @@ 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 { isToolInstalledForConfig } from './resources/base.js'; import { ALL_SUPPORTED_TOOLS, agentFileExtensionForTool, @@ -97,29 +96,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(); - - for (const [tool, toolPath] of Object.entries(scopedToolPaths(teamConfig, localConfig))) { - if (isAgentExcluded(localConfig, tool)) continue; - if (!toolPath.claudemd || !toolPath.agents) continue; - if (!await isToolInstalledForConfig(tool, toolPath.agents, localConfig)) continue; - - const baseDir = resolveToolBaseDir(tool, localConfig); - const claudeMdPath = path.join(baseDir, toolPath.claudemd); - try { - await injectClaudeMdSection( - claudeMdPath, - TEAMAI_RECALL_RULES_START, - TEAMAI_RECALL_RULES_END, - recallBlock, - ); - } catch { - // best-effort - } - } + const { injectRecallBlockIntoTools } = await import('./pull.js'); + await injectRecallBlockIntoTools(teamConfig, localConfig, localConfig.scope); } export async function recallDisable(_opts: GlobalOptions): Promise { @@ -138,7 +116,7 @@ export async function recallEnable(_opts: GlobalOptions): Promise { const updated = { ...localConfig, recallEnabled: true }; await saveLocalConfigForScope(updated, localConfig.scope, localConfig.projectRoot); - await deployRecallArtifacts(teamConfig, localConfig); + await deployRecallArtifacts(teamConfig, updated); log.success('Recall enabled. AI tools will auto-search the knowledge base before tasks.'); }