Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion src/__tests__/e2e/enabled-agents-whitelist.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');

Expand Down Expand Up @@ -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);
});
2 changes: 1 addition & 1 deletion src/__tests__/pull-skip-sync.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
63 changes: 62 additions & 1 deletion src/__tests__/recall-rules.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -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;
Expand Down
3 changes: 2 additions & 1 deletion src/__tests__/recall-toggle.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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',
Expand Down
8 changes: 5 additions & 3 deletions src/builtin-rules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}

## 规则:任务开始前检索团队知识库(推荐)

Expand Down
32 changes: 20 additions & 12 deletions src/pull.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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.
Expand All @@ -1720,7 +1721,6 @@ export async function injectRecallBlockIntoTools(
): Promise<void> {
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;
Expand All @@ -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,
Expand Down Expand Up @@ -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,
'<!-- DO NOT EDIT: This section is auto-managed by teamai -->',
'',
'## 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`',
Expand Down
28 changes: 3 additions & 25 deletions src/recall-toggle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<void> {
Expand All @@ -138,7 +116,7 @@ export async function recallEnable(_opts: GlobalOptions): Promise<void> {
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.');
}

Expand Down
Loading