From 1d20fe5d1b59879fda027e24e5ee4543005b049e Mon Sep 17 00:00:00 2001 From: Dongwoo Jeong Date: Tue, 29 Sep 2026 22:17:39 +0900 Subject: [PATCH 1/2] =?UTF-8?q?fix(recall):=20=EB=A7=88=EC=A7=80=EB=A7=89?= =?UTF-8?q?=20=EC=9D=91=EB=8B=B5=20marker=20=EC=95=8C=EB=A6=BC=EA=B3=BC=20?= =?UTF-8?q?self-exemption=20=EC=A4=91=EB=B3=B5=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task-claim: orch-teamai-cli badef4ec 2026-09-29T12:52:07Z --- .../e2e/enabled-agents-whitelist.test.ts | 9 ++- src/__tests__/hook-handlers.test.ts | 58 +++++++++++++++++ src/__tests__/pull-skip-sync.test.ts | 2 +- src/__tests__/recall-rules.test.ts | 63 ++++++++++++++++++- src/__tests__/recall-toggle.test.ts | 3 +- src/builtin-rules.ts | 8 ++- src/hook-handlers.ts | 24 ++++++- src/pull.ts | 32 ++++++---- src/recall-toggle.ts | 28 +-------- 9 files changed, 181 insertions(+), 46 deletions(-) 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__/hook-handlers.test.ts b/src/__tests__/hook-handlers.test.ts index 55622efde..c391f452a 100644 --- a/src/__tests__/hook-handlers.test.ts +++ b/src/__tests__/hook-handlers.test.ts @@ -160,6 +160,64 @@ const scope: LocalConfig = { repo: { localPath: '/tmp', remote: '' }, username: // ── Tests ──────────────────────────────────────────────── +describe('final-recall-marker', () => { + const handler = () => buildHandlerRegistry().find( + (r) => r.event === 'stop' && r.matcher === '*' && r.handler.name === 'final-recall-marker', + )!.handler; + const marker = ''; + + beforeEach(() => { + vi.clearAllMocks(); + vi.stubEnv('TEAMAI_RECALL_DISABLED', ''); + }); + afterEach(() => vi.unstubAllEnvs()); + + it.each([ + undefined, + null, + 42, + '', + 'Task completed.', + `${marker}\nMore text.`, + `Example: ${marker}`, + `\`${marker}\``, + `\`\`\`\n${marker}\n\`\`\``, + '', + ])('reminds without recall or a transcript when the final marker is missing: %s', async (message) => { + const output = await handler().execute({ last_assistant_message: message }, 'claude', scope); + expect(JSON.parse(output!)).toEqual({ + hookSpecificOutput: { + hookEventName: 'Stop', + additionalContext: expect.stringContaining(marker), + }, + }); + expect(mockParseTranscriptForVotes).not.toHaveBeenCalled(); + expect(mockAutoDetectInit).not.toHaveBeenCalled(); + }); + + it.each([ + marker, + `Done.\n${marker}`, + `Done.\r\n${marker}\t \r\n`, + 'Done.\n', + ])('stays silent when the latest message ends with a marker: %s', async (message) => { + expect(await handler().execute({ last_assistant_message: message }, 'claude', scope)).toBeNull(); + }); + + it('does not re-enter an active Stop hook', async () => { + expect(await handler().execute({ stop_hook_active: true }, 'claude', scope)).toBeNull(); + }); + + it.each(['codex', 'codebuddy', 'cursor', 'opencode'])('does not affect %s', async (tool) => { + expect(await handler().execute({}, tool, scope)).toBeNull(); + }); + + it('honors TEAMAI_RECALL_DISABLED', async () => { + vi.stubEnv('TEAMAI_RECALL_DISABLED', '1'); + expect(await handler().execute({}, 'claude', scope)).toBeNull(); + }); +}); + describe('hook-handlers registry', () => { beforeEach(() => { vi.clearAllMocks(); 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/hook-handlers.ts b/src/hook-handlers.ts index e8f46e5f5..931c8038c 100644 --- a/src/hook-handlers.ts +++ b/src/hook-handlers.ts @@ -387,6 +387,25 @@ const packagePendingHintHandler: HookHandler = { }, }; +function hasFinalRecallMarker(message: unknown): boolean { + return typeof message === 'string' + && /(?:^|\n)[ \t\r\n]*$/.test(message); +} + +const finalRecallMarkerHandler: HookHandler = { + name: 'final-recall-marker', + async execute(stdin, tool) { + if (tool !== 'claude' || process.env.TEAMAI_RECALL_DISABLED === '1' || stdin.stop_hook_active === true) return null; + if (hasFinalRecallMarker(stdin.last_assistant_message)) return null; + + const { formatStopHookOutput } = await import('./utils/hook-output.js'); + return formatStopHookOutput( + '[teamai] End your final reply with a standalone comment listing only knowledge entries you actually used. If none were used or recall was skipped, append . Do not put the comment in a code block.', + tool, + ); + }, +}; + const votesSyncHandler: HookHandler = { name: 'votes-sync', async execute(stdin, tool, localConfig) { @@ -822,13 +841,14 @@ export function buildHandlerRegistry(): HandlerRegistration[] { { event: 'session-end', matcher: '*', handler: webhookHandler, timeoutMs: FOREGROUND_HOOK_TIMEOUT_MS, background: true, requiresConfig: true }, // ─── Stop ───────────────────────────────────────── - // votes-sync and contribute-check may return a hint the host injects back - // into the session, so they run inline (capped at FOREGROUND_HOOK_TIMEOUT_MS). + // Reminder handlers run inline so the host receives their output, capped at + // FOREGROUND_HOOK_TIMEOUT_MS. // The rest are pure side effects — the update check in particular shells out // to the npm registry — so they run detached to avoid pushing the Stop hook // past the host's hook timeout (CodeBuddy kills hooks at ~10s regardless of // the declared timeout). { event: 'stop', matcher: '*', handler: updateHandler, timeoutMs: UPDATE_TIMEOUT_MS, background: true }, + { event: 'stop', matcher: '*', handler: finalRecallMarkerHandler, timeoutMs: FOREGROUND_HOOK_TIMEOUT_MS, requiresConfig: true }, { event: 'stop', matcher: '*', handler: votesSyncHandler, timeoutMs: FOREGROUND_HOOK_TIMEOUT_MS, gitOnly: true, requiresConfig: true }, // Optional background LLM-judge (issue #723). Opt-in via TEAMAI_UPVOTE_JUDGE=1; // detached so it never delays the Stop. gitOnly (HTTP teams skip upvotes). 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.'); } From 3ccf0b6ebac1f9608476736ebbb5814e35bc0307 Mon Sep 17 00:00:00 2001 From: Dongwoo Jeong Date: Wed, 30 Sep 2026 07:23:16 +0900 Subject: [PATCH 2/2] =?UTF-8?q?fix(recall):=20self-exemption=20=EC=A4=91?= =?UTF-8?q?=EB=B3=B5=20=EC=A0=9C=EA=B1=B0=EB=A7=8C=20=EC=9C=A0=EC=A7=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/__tests__/hook-handlers.test.ts | 58 ----------------------------- src/hook-handlers.ts | 24 +----------- 2 files changed, 2 insertions(+), 80 deletions(-) diff --git a/src/__tests__/hook-handlers.test.ts b/src/__tests__/hook-handlers.test.ts index c391f452a..55622efde 100644 --- a/src/__tests__/hook-handlers.test.ts +++ b/src/__tests__/hook-handlers.test.ts @@ -160,64 +160,6 @@ const scope: LocalConfig = { repo: { localPath: '/tmp', remote: '' }, username: // ── Tests ──────────────────────────────────────────────── -describe('final-recall-marker', () => { - const handler = () => buildHandlerRegistry().find( - (r) => r.event === 'stop' && r.matcher === '*' && r.handler.name === 'final-recall-marker', - )!.handler; - const marker = ''; - - beforeEach(() => { - vi.clearAllMocks(); - vi.stubEnv('TEAMAI_RECALL_DISABLED', ''); - }); - afterEach(() => vi.unstubAllEnvs()); - - it.each([ - undefined, - null, - 42, - '', - 'Task completed.', - `${marker}\nMore text.`, - `Example: ${marker}`, - `\`${marker}\``, - `\`\`\`\n${marker}\n\`\`\``, - '', - ])('reminds without recall or a transcript when the final marker is missing: %s', async (message) => { - const output = await handler().execute({ last_assistant_message: message }, 'claude', scope); - expect(JSON.parse(output!)).toEqual({ - hookSpecificOutput: { - hookEventName: 'Stop', - additionalContext: expect.stringContaining(marker), - }, - }); - expect(mockParseTranscriptForVotes).not.toHaveBeenCalled(); - expect(mockAutoDetectInit).not.toHaveBeenCalled(); - }); - - it.each([ - marker, - `Done.\n${marker}`, - `Done.\r\n${marker}\t \r\n`, - 'Done.\n', - ])('stays silent when the latest message ends with a marker: %s', async (message) => { - expect(await handler().execute({ last_assistant_message: message }, 'claude', scope)).toBeNull(); - }); - - it('does not re-enter an active Stop hook', async () => { - expect(await handler().execute({ stop_hook_active: true }, 'claude', scope)).toBeNull(); - }); - - it.each(['codex', 'codebuddy', 'cursor', 'opencode'])('does not affect %s', async (tool) => { - expect(await handler().execute({}, tool, scope)).toBeNull(); - }); - - it('honors TEAMAI_RECALL_DISABLED', async () => { - vi.stubEnv('TEAMAI_RECALL_DISABLED', '1'); - expect(await handler().execute({}, 'claude', scope)).toBeNull(); - }); -}); - describe('hook-handlers registry', () => { beforeEach(() => { vi.clearAllMocks(); diff --git a/src/hook-handlers.ts b/src/hook-handlers.ts index 931c8038c..e8f46e5f5 100644 --- a/src/hook-handlers.ts +++ b/src/hook-handlers.ts @@ -387,25 +387,6 @@ const packagePendingHintHandler: HookHandler = { }, }; -function hasFinalRecallMarker(message: unknown): boolean { - return typeof message === 'string' - && /(?:^|\n)[ \t\r\n]*$/.test(message); -} - -const finalRecallMarkerHandler: HookHandler = { - name: 'final-recall-marker', - async execute(stdin, tool) { - if (tool !== 'claude' || process.env.TEAMAI_RECALL_DISABLED === '1' || stdin.stop_hook_active === true) return null; - if (hasFinalRecallMarker(stdin.last_assistant_message)) return null; - - const { formatStopHookOutput } = await import('./utils/hook-output.js'); - return formatStopHookOutput( - '[teamai] End your final reply with a standalone comment listing only knowledge entries you actually used. If none were used or recall was skipped, append . Do not put the comment in a code block.', - tool, - ); - }, -}; - const votesSyncHandler: HookHandler = { name: 'votes-sync', async execute(stdin, tool, localConfig) { @@ -841,14 +822,13 @@ export function buildHandlerRegistry(): HandlerRegistration[] { { event: 'session-end', matcher: '*', handler: webhookHandler, timeoutMs: FOREGROUND_HOOK_TIMEOUT_MS, background: true, requiresConfig: true }, // ─── Stop ───────────────────────────────────────── - // Reminder handlers run inline so the host receives their output, capped at - // FOREGROUND_HOOK_TIMEOUT_MS. + // votes-sync and contribute-check may return a hint the host injects back + // into the session, so they run inline (capped at FOREGROUND_HOOK_TIMEOUT_MS). // The rest are pure side effects — the update check in particular shells out // to the npm registry — so they run detached to avoid pushing the Stop hook // past the host's hook timeout (CodeBuddy kills hooks at ~10s regardless of // the declared timeout). { event: 'stop', matcher: '*', handler: updateHandler, timeoutMs: UPDATE_TIMEOUT_MS, background: true }, - { event: 'stop', matcher: '*', handler: finalRecallMarkerHandler, timeoutMs: FOREGROUND_HOOK_TIMEOUT_MS, requiresConfig: true }, { event: 'stop', matcher: '*', handler: votesSyncHandler, timeoutMs: FOREGROUND_HOOK_TIMEOUT_MS, gitOnly: true, requiresConfig: true }, // Optional background LLM-judge (issue #723). Opt-in via TEAMAI_UPVOTE_JUDGE=1; // detached so it never delays the Stop. gitOnly (HTTP teams skip upvotes).