From 58e27db6636828d312d12cc1969ff18daabb8f16 Mon Sep 17 00:00:00 2001 From: Saul Moro Date: Tue, 15 Sep 2026 11:19:09 +0200 Subject: [PATCH 1/3] fix(dashboard): match correction keywords as whole words, add team keywords `isCorrectionPrompt` used raw substring matching, so the built-in `undo` and `redo` fired on ordinary Spanish and Portuguese words ("segundo", "mundo", "redondo"). One false correction scores 20, which is the share-learnings nudge threshold on its own. Keywords in a space-separated script now match as whole words (Unicode-aware, so accented letters count as letters). Keywords containing Han, Hiragana, Katakana or Hangul keep substring matching. Teams can add their own words via `sharing.intervention.correctionKeywords` in teamai.yaml. The prompt_submit hook resolves them and stores a `correction` flag on the event, because the machine-level events file mixes sessions from every team. Events without the flag fall back to the built-in list. For #564 --- CHANGELOG.md | 1 + docs/usage-guide.md | 14 +++- docs/usage-guide.zh-CN.md | 14 +++- src/__tests__/dashboard-collector.test.ts | 93 +++++++++++++++++++++++ src/__tests__/hook-handlers.test.ts | 32 ++++++++ src/__tests__/types.test.ts | 19 +++++ src/dashboard-collector.ts | 48 +++++++++++- src/hook-handlers.ts | 18 ++++- src/types.ts | 34 ++++++++- 9 files changed, 264 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4b69b9b3..65d0d72e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ All notable changes to this project will be documented in this file. See [standa ### 🐛 Bug Fixes +- Course-correction detection matches Latin-script keywords as whole words, so Spanish "segundo" no longer counts as `undo`; teams add their own words via `sharing.intervention.correctionKeywords` in `teamai.yaml` (for [#564](https://github.com/Tencent/teamai-cli/issues/564)). - MCP `requires` is resolved from `PATH` (including Windows `PATHEXT`), so `teamai mcp inject` no longer skips servers such as `uvx` on Windows ([#540](https://github.com/Tencent/teamai-cli/pull/540), for [#539](https://github.com/Tencent/teamai-cli/issues/539)). - The GitHub and CNB providers resolve their CLI to a launchable absolute path and start it through cross-spawn, so on Windows they no longer answer "installed" while every call fails silently ([#520](https://github.com/Tencent/teamai-cli/pull/520)). - `enabledAgents` now also gates CLI builtin deploy, CLAUDE.md-class injects, and last-pull skip-sync targets, so an already-installed tool outside the whitelist is not written to ([#510](https://github.com/Tencent/teamai-cli/issues/510)). diff --git a/docs/usage-guide.md b/docs/usage-guide.md index 5b08dd68..686036c3 100644 --- a/docs/usage-guide.md +++ b/docs/usage-guide.md @@ -1240,10 +1240,20 @@ Each session card shows a `⚠ N` badge, counting the **number of human interven |------|------|----------| | `interrupt` | User pressed ESC to interrupt the agent mid-execution | An interrupted turn in the transcript | | `toolReject` | User rejected a tool call (permission deny) | A tool_result marked as rejected in the transcript | -| `correction` | Within 60s after the agent stops, the user submits a follow-up prompt containing a correction keyword ("not right" / "redo" / "wrong" / 「違う」 / 「やり直し」 / etc. — Chinese, English and Japanese) | The stop → prompt_submit event pattern | +| `correction` | Within 60s after the agent stops, the user submits a follow-up prompt containing a correction keyword ("not right" / "redo" / "wrong" / 「違う」 / 「やり直し」 / etc. — Chinese, English and Japanese built in, plus any team keywords) | The stop → prompt_submit event pattern | > Privacy: only counts are tracked — no prompt or transcript text is ever stored. +Keywords in a space-separated script (English, Spanish, ...) must appear as a whole word, so Spanish "segundo" does not count as `undo`. Chinese and Japanese keywords match as substrings. Teams whose members correct the agent in another language add their own words in `teamai.yaml`; they are merged with the built-in list and matched case-insensitively under the same rules: + +```yaml +sharing: + intervention: + correctionKeywords: [rehazlo, deshaz, "no era eso", "otra vez"] +``` + +The prompt is checked when the `UserPromptSubmit` hook captures it, so a change to the team keywords applies to new prompts after the next `teamai pull`; sessions recorded earlier are not re-evaluated. + Intervention data is automatically aggregated and reported to the team's `stats/.yaml` during `teamai pull`, and shown in the "Session Autonomy" leaderboard of `teamai digest`, with team averages and per-person intervention rate rankings — useful for verifying whether a skill/rule reduces intervention rates after rollout. Tools without a transcript (e.g. Cursor) degrade gracefully, tracking only `correction`. #### Conversation Volume & Token Usage @@ -1569,6 +1579,8 @@ sharing: enabled: false # optional; strip AI-tool commit trailers team-wide contributeHint: enabled: true # optional; false = no /teamai-share-learnings nudge after high-friction sessions + intervention: + correctionKeywords: [] # optional; extra course-correction words merged with the built-in zh/en/ja list ``` ### config.yaml (local config) diff --git a/docs/usage-guide.zh-CN.md b/docs/usage-guide.zh-CN.md index 23909485..37f98223 100644 --- a/docs/usage-guide.zh-CN.md +++ b/docs/usage-guide.zh-CN.md @@ -1207,10 +1207,20 @@ teamai dashboard --port 8080 |------|------|----------| | `interrupt` | 用户在 agent 执行中途按 ESC 打断 | transcript 中被中断的 turn | | `toolReject` | 用户拒绝某个工具调用(permission deny) | transcript 中标记拒绝的 tool_result | -| `correction` | agent stop 后 60s 内用户追加含「不对 / 重来 / 错了 / wrong / redo / 違う / やり直し」等纠偏词(中、英、日)的 prompt | stop → prompt_submit 事件模式 | +| `correction` | agent stop 后 60s 内用户追加含「不对 / 重来 / 错了 / wrong / redo / 違う / やり直し」等纠偏词(内置中、英、日,外加团队自定义词)的 prompt | stop → prompt_submit 事件模式 | > 隐私:只统计**次数**,不落地任何 prompt 或 transcript 原文。 +以空格分词的文字(英语、西班牙语等)中的纠偏词必须整词匹配,因此西班牙语 "segundo" 不会被算作 `undo`;中文、日文纠偏词仍按子串匹配。成员用其他语言纠偏的团队可在 `teamai.yaml` 添加自己的词,与内置列表合并,忽略大小写,遵循同样的匹配规则: + +```yaml +sharing: + intervention: + correctionKeywords: [rehazlo, deshaz, "no era eso", "otra vez"] +``` + +匹配在 `UserPromptSubmit` hook 捕获 prompt 时完成,因此修改团队纠偏词后,下一次 `teamai pull` 之后的新 prompt 才会生效;之前记录的会话不会重新评估。 + 干预数据会随 `teamai pull` 自动聚合上报到团队 `stats/.yaml`,并在 `teamai digest` 的「会话自主性」榜单中给出团队均值与人均干预率排行,可用于验证某个 skill / rule 上线后干预率是否下降。无 transcript 的工具(如 Cursor)会优雅降级,只统计 `correction`。 #### 对话量与 Token 用量 @@ -1528,6 +1538,8 @@ sharing: enabled: false # 可选,为全团队去除 AI 工具提交尾注 contributeHint: enabled: true # 可选,false = 高摩擦 session 结束后不再提示 /teamai-share-learnings + intervention: + correctionKeywords: [] # 可选,额外的纠偏词,与内置中/英/日列表合并 ``` ### config.yaml(本地配置) diff --git a/src/__tests__/dashboard-collector.test.ts b/src/__tests__/dashboard-collector.test.ts index 1069d638..a15df14a 100644 --- a/src/__tests__/dashboard-collector.test.ts +++ b/src/__tests__/dashboard-collector.test.ts @@ -106,6 +106,62 @@ describe('parseHookEvent', () => { expect(event!.tool).toBe('claude-internal'); }); + it('flags a correction prompt on UserPromptSubmit', async () => { + const raw = JSON.stringify({ hook_event_name: 'UserPromptSubmit', session_id: 's', prompt: 'wrong, redo it' }); + const event = await parseHookEvent(raw, 'claude'); + expect(event!.correction).toBe(true); + }); + + it('does not flag a Latin keyword inside a longer word (issue #564)', async () => { + // "undo" / "redo" are common Spanish and Portuguese word endings. + for (const word of ['segundo', 'mundo', 'profundo', 'rotundo', 'redondo', 'enredo', 'oriundo']) { + const raw = JSON.stringify({ hook_event_name: 'UserPromptSubmit', session_id: 's', prompt: `dame el ${word} fichero` }); + const event = await parseHookEvent(raw, 'claude'); + expect(event!.correction, word).toBe(false); + } + }); + + it('matches Latin keywords as whole words, including multi-word ones', async () => { + for (const prompt of ['undo that', 'Undo.', "that's not it", "don't do that", 'wrong!']) { + const raw = JSON.stringify({ hook_event_name: 'UserPromptSubmit', session_id: 's', prompt }); + const event = await parseHookEvent(raw, 'claude'); + expect(event!.correction, prompt).toBe(true); + } + }); + + it('keeps substring matching for Chinese and Japanese keywords', async () => { + for (const prompt of ['这不对', '違うよ']) { + const raw = JSON.stringify({ hook_event_name: 'UserPromptSubmit', session_id: 's', prompt }); + const event = await parseHookEvent(raw, 'claude'); + expect(event!.correction, prompt).toBe(true); + } + }); + + it('merges team correctionKeywords with the built-in list', async () => { + const options = { correctionKeywords: ['rehazlo', 'no era eso', 'mal', '重做'] }; + const cases: Array<[string, boolean]> = [ + ['esto está mal, rehazlo', true], + ['No era eso', true], + ['请重做一遍', true], + // Team Latin keywords also need a whole word: accented letters count as letters. + ['no está malísimo', false], + ['continúa con el siguiente paso', false], + ]; + for (const [prompt, expected] of cases) { + const raw = JSON.stringify({ hook_event_name: 'UserPromptSubmit', session_id: 's', prompt }); + const event = await parseHookEvent(raw, 'claude', options); + expect(event!.correction, prompt).toBe(expected); + } + }); + + it('checks the full prompt, not only the 200-char summary', async () => { + const prompt = `${'x '.repeat(150)}wrong`; + const raw = JSON.stringify({ hook_event_name: 'UserPromptSubmit', session_id: 's', prompt }); + const event = await parseHookEvent(raw, 'claude'); + expect(event!.promptSummary!.length).toBe(200); + expect(event!.correction).toBe(true); + }); + it('truncates long prompts to 200 chars', async () => { const longPrompt = 'x'.repeat(500); const raw = JSON.stringify({ @@ -743,6 +799,43 @@ describe('rebuildSessions interventions', () => { expect(sessions[0].interventions.correction).toBe(0); }); + it('honors the correction flag written by the hook over the summary text', () => { + const t0 = new Date(); + const later = new Date(t0.getTime() + 5_000).toISOString(); + const flagged = rebuildSessions([ + { type: 'session_start', timestamp: t0.toISOString(), sessionId: 's1', tool: 'claude', cwd: '/p' }, + { type: 'stop', timestamp: t0.toISOString(), sessionId: 's1', tool: 'claude' }, + // Team keyword matched at capture time; the summary alone would not match. + { type: 'prompt_submit', timestamp: later, sessionId: 's1', tool: 'claude', promptSummary: 'esto está mal, rehazlo', correction: true }, + ]); + expect(flagged[0].interventions.correction).toBe(1); + + const unflagged = rebuildSessions([ + { type: 'session_start', timestamp: t0.toISOString(), sessionId: 's1', tool: 'claude', cwd: '/p' }, + { type: 'stop', timestamp: t0.toISOString(), sessionId: 's1', tool: 'claude' }, + { type: 'prompt_submit', timestamp: later, sessionId: 's1', tool: 'claude', promptSummary: 'wrong, redo it', correction: false }, + ]); + expect(unflagged[0].interventions.correction).toBe(0); + }); + + it('falls back to whole-word matching on legacy events without the flag (issue #564)', () => { + const t0 = new Date(); + const later = new Date(t0.getTime() + 5_000).toISOString(); + const spanish = rebuildSessions([ + { type: 'session_start', timestamp: t0.toISOString(), sessionId: 's1', tool: 'claude', cwd: '/p' }, + { type: 'stop', timestamp: t0.toISOString(), sessionId: 's1', tool: 'claude' }, + { type: 'prompt_submit', timestamp: later, sessionId: 's1', tool: 'claude', promptSummary: 'dame el segundo fichero' }, + ]); + expect(spanish[0].interventions.correction).toBe(0); + + const english = rebuildSessions([ + { type: 'session_start', timestamp: t0.toISOString(), sessionId: 's1', tool: 'claude', cwd: '/p' }, + { type: 'stop', timestamp: t0.toISOString(), sessionId: 's1', tool: 'claude' }, + { type: 'prompt_submit', timestamp: later, sessionId: 's1', tool: 'claude', promptSummary: 'undo that' }, + ]); + expect(english[0].interventions.correction).toBe(1); + }); + it('aggregates all three intervention types together', () => { const t0 = new Date(); const sessions = rebuildSessions([ diff --git a/src/__tests__/hook-handlers.test.ts b/src/__tests__/hook-handlers.test.ts index 01d3fae8..95406cbb 100644 --- a/src/__tests__/hook-handlers.test.ts +++ b/src/__tests__/hook-handlers.test.ts @@ -873,3 +873,35 @@ describe('post-tool-use dispatch — local-agent runs detached, never blocks hos expect(mockParseHookEvent).toHaveBeenCalled(); }); }); + +describe('dashboard-report team correction keywords', () => { + const handler = () => buildHandlerRegistry().find( + (r) => r.event === 'prompt-submit' && r.handler.name === 'dashboard-report', + )!.handler; + + beforeEach(() => { + mockParseHookEvent.mockClear(); + mockAutoDetectInit.mockClear(); + }); + + it('passes sharing.intervention.correctionKeywords to parseHookEvent on prompt hooks', async () => { + mockAutoDetectInit.mockResolvedValueOnce({ + localConfig: { repo: { localPath: '/tmp', remote: '' }, username: 'test', scope: 'user' }, + teamConfig: { team: 'test', repo: '', toolPaths: {}, sharing: { intervention: { correctionKeywords: ['rehazlo'] } } }, + }); + await handler().execute({ hook_event_name: 'UserPromptSubmit', session_id: 's', prompt: 'rehazlo' }, 'claude'); + expect(mockParseHookEvent).toHaveBeenCalledWith(expect.any(String), 'claude', { correctionKeywords: ['rehazlo'] }); + }); + + it('falls back to built-in keywords only when team config cannot be read', async () => { + mockAutoDetectInit.mockRejectedValueOnce(new Error('not initialized')); + await handler().execute({ hook_event_name: 'UserPromptSubmit', session_id: 's', prompt: 'wrong' }, 'claude'); + expect(mockParseHookEvent).toHaveBeenCalledWith(expect.any(String), 'claude', { correctionKeywords: [] }); + }); + + it('does not read team config for hooks without a prompt', async () => { + await handler().execute({ hook_event_name: 'Stop', session_id: 's' }, 'claude'); + expect(mockAutoDetectInit).not.toHaveBeenCalled(); + expect(mockParseHookEvent).toHaveBeenCalledWith(expect.any(String), 'claude', { correctionKeywords: [] }); + }); +}); diff --git a/src/__tests__/types.test.ts b/src/__tests__/types.test.ts index 275e15e3..9f73f8f0 100644 --- a/src/__tests__/types.test.ts +++ b/src/__tests__/types.test.ts @@ -5,6 +5,7 @@ import { MemberConfigSchema, TeamaiConfigSchema, SharingConfigSchema, + getInterventionSharing, StateSchema, LocalConfigSchema, resolveLegacyProjectHookScope, @@ -181,6 +182,24 @@ describe('SharingConfigSchema env', () => { }); }); +describe('SharingConfigSchema intervention', () => { + it('leaves intervention undefined when absent and defaults keywords to []', () => { + const result = SharingConfigSchema.parse({}); + expect(result.intervention).toBeUndefined(); + expect(getInterventionSharing({ sharing: result })).toEqual({ correctionKeywords: [] }); + expect(getInterventionSharing({})).toEqual({ correctionKeywords: [] }); + }); + + it('accepts team correctionKeywords', () => { + const result = SharingConfigSchema.parse({ intervention: { correctionKeywords: ['rehazlo', 'no era eso'] } }); + expect(getInterventionSharing({ sharing: result }).correctionKeywords).toEqual(['rehazlo', 'no era eso']); + }); + + it('rejects non-string keywords', () => { + expect(() => SharingConfigSchema.parse({ intervention: { correctionKeywords: [1] } })).toThrow(); + }); +}); + describe('StateSchema pushedEnvVars', () => { it('should default pushedEnvVars to empty array', () => { const result = StateSchema.parse({}); diff --git a/src/dashboard-collector.ts b/src/dashboard-collector.ts index 25823dfa..651ff39e 100644 --- a/src/dashboard-collector.ts +++ b/src/dashboard-collector.ts @@ -810,11 +810,39 @@ export async function countInterventions( return { interrupt, toolReject, toolError }; } -/** True when a prompt looks like a course-correction (vs. a fresh task). */ -function isCorrectionPrompt(text?: string): boolean { +/** Scripts that do not separate words with spaces: substring matching is correct there. */ +const UNSPACED_SCRIPT_RE = /[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Hangul}]/u; + +/** Match a keyword as a whole word: no letter or digit may touch either end. */ +function wordBoundaryPattern(keyword: string): RegExp { + const escaped = keyword.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + return new RegExp(`(?(); + +/** True when `lower` contains `keyword`, whole-word for spaced scripts, substring otherwise. */ +function containsKeyword(lower: string, keyword: string): boolean { + const k = keyword.trim().toLowerCase(); + if (!k) return false; + if (UNSPACED_SCRIPT_RE.test(k)) return lower.includes(k); + let re = wordBoundaryCache.get(k); + if (!re) { + re = wordBoundaryPattern(k); + wordBoundaryCache.set(k, re); + } + return re.test(lower); +} + +/** + * True when a prompt looks like a course-correction (vs. a fresh task). + * `extraKeywords` are the team's `sharing.intervention.correctionKeywords`. + */ +function isCorrectionPrompt(text?: string, extraKeywords: readonly string[] = []): boolean { if (!text) return false; const lower = text.toLowerCase(); - return CORRECTION_KEYWORDS.some((k) => lower.includes(k)); + return CORRECTION_KEYWORDS.some((k) => containsKeyword(lower, k)) + || extraKeywords.some((k) => containsKeyword(lower, k)); } /** @@ -841,6 +869,11 @@ function mapEventType(hookEventName: string): DashboardEventType | null { } } +export interface ParseHookEventOptions { + /** Team keywords (`sharing.intervention.correctionKeywords`) merged with the built-in list. */ + correctionKeywords?: readonly string[]; +} + /** * Parse a hook STDIN JSON payload into a DashboardEvent. * Returns null if the payload is invalid or irrelevant. @@ -849,6 +882,7 @@ function mapEventType(hookEventName: string): DashboardEventType | null { export async function parseHookEvent( raw: string, tool: string, + options?: ParseHookEventOptions, ): Promise { if (!raw.trim()) return null; @@ -903,6 +937,10 @@ export async function parseHookEvent( if (eventType === 'prompt_submit' && typeof hookData.prompt === 'string') { // Keep first 200 chars of the prompt as summary event.promptSummary = hookData.prompt.slice(0, 200); + // Decide "correction" here, over the full prompt, because only the hook knows + // which team (and so which extra keywords) the prompt belongs to. The + // machine-level events file mixes sessions from every team. + event.correction = isCorrectionPrompt(hookData.prompt, options?.correctionKeywords); } // Extract transcript path, AI output and intervention counts from Stop event @@ -1339,7 +1377,9 @@ export function aggregateSessionMetrics( const stopAt = lastStopAt.get(event.sessionId); if (stopAt !== undefined) { const gap = new Date(event.timestamp).getTime() - stopAt; - if (gap >= 0 && gap <= CORRECTION_WINDOW_MS && isCorrectionPrompt(event.promptSummary)) { + // Legacy events (no `correction` flag) fall back to the built-in list. + const isCorrection = event.correction ?? isCorrectionPrompt(event.promptSummary); + if (gap >= 0 && gap <= CORRECTION_WINDOW_MS && isCorrection) { m.correction++; } // Each stop is consumed once — a later prompt is a new task, not a correction. diff --git a/src/hook-handlers.ts b/src/hook-handlers.ts index 46271557..84a2fffd 100644 --- a/src/hook-handlers.ts +++ b/src/hook-handlers.ts @@ -114,12 +114,28 @@ const updateHandler: HookHandler = { }, }; +/** + * Team course-correction keywords for the current project. Only prompt hooks pay + * for the config read; an unreadable config means "built-in keywords only". + */ +async function teamCorrectionKeywords(stdin: Record): Promise { + if (typeof stdin.prompt !== 'string') return []; + try { + const { autoDetectInit } = await import('./config.js'); + const { getInterventionSharing } = await import('./types.js'); + const { teamConfig } = await autoDetectInit(); + return getInterventionSharing(teamConfig).correctionKeywords; + } catch { + return []; + } +} + const dashboardReportHandler: HookHandler = { name: 'dashboard-report', async execute(stdin, tool) { const { parseHookEvent, appendEvent, compactEvents } = await import('./dashboard-collector.js'); const raw = JSON.stringify(stdin); - const event = await parseHookEvent(raw, tool); + const event = await parseHookEvent(raw, tool, { correctionKeywords: await teamCorrectionKeywords(stdin) }); if (event) { await appendEvent(event); // Non-blocking compaction diff --git a/src/types.ts b/src/types.ts index 1a67ef93..66366b85 100644 --- a/src/types.ts +++ b/src/types.ts @@ -98,8 +98,24 @@ export const SharingConfigSchema = z.object({ /** Allowed http/sse hosts (supports a leading `*.` wildcard). Empty = no restriction. */ allowedHosts: z.array(z.string()).default([]), }).optional(), + // Optional (not .default) so existing TeamaiConfig literals stay valid; use + // getInterventionSharing() for the defaulted view. + intervention: z.object({ + /** Extra course-correction keywords, merged with the built-in list + * (CORRECTION_KEYWORDS). Teams add the words their members actually type, + * e.g. Spanish "rehazlo" or "no era eso". Matched case-insensitively; a + * keyword in a space-separated script must appear as a whole word. */ + correctionKeywords: z.array(z.string()).default([]), + }).optional(), }); +/** Defaulted view of the optional `sharing.intervention` config. */ +export function getInterventionSharing(config: { + sharing?: { intervention?: { correctionKeywords?: string[] } }; +}): { correctionKeywords: string[] } { + return { correctionKeywords: config.sharing?.intervention?.correctionKeywords ?? [] }; +} + /** Defaulted view of the optional `sharing.hooks` config. */ export function getHooksSharing(config: { sharing?: { hooks?: { autoApply?: boolean; requireTeamScripts?: boolean } } }): { autoApply: boolean; @@ -938,6 +954,14 @@ export interface DashboardEvent { cwd?: string; /** First user prompt (captured from UserPromptSubmit) */ promptSummary?: string; + /** + * Whether the full prompt matched a course-correction keyword (built-in list plus + * the team's `sharing.intervention.correctionKeywords`) when the prompt_submit + * hook captured it. Absent on events written before this field existed; + * rebuildSessions then falls back to matching `promptSummary` against the + * built-in list only. + */ + correction?: boolean; /** Tool name from PostToolUse (e.g. "Edit", "Bash", "Read") */ toolName?: string; /** Inferred session status at event time */ @@ -1054,12 +1078,18 @@ export const DASHBOARD_PID_CHECK_INTERVAL_MS = 15_000; // // A `correction` is counted when the user submits a new prompt within // CORRECTION_WINDOW_MS after the agent stopped AND the prompt looks like a -// course-correction (contains one of CORRECTION_KEYWORDS) rather than a new task. +// course-correction (contains one of CORRECTION_KEYWORDS or a team keyword from +// `sharing.intervention.correctionKeywords`) rather than a new task. +// +// Keywords in a space-separated script (Latin, Cyrillic, ...) must match a whole +// word: `undo` must not fire on Spanish "segundo" (issue #564). Keywords that +// contain Han, Hiragana, Katakana or Hangul stay substring matches because those +// scripts do not separate words with spaces. // /** Max time (ms) between a stop and the next prompt for it to count as a correction. */ export const CORRECTION_WINDOW_MS = 60 * 1000; -/** Substrings (lowercased) that mark a prompt as a course-correction, not a new task. */ +/** Built-in keywords (lowercased) that mark a prompt as a course-correction, not a new task. */ export const CORRECTION_KEYWORDS = [ '不对', '不是', '错了', '错误', '重来', '重新', '撤销', '回退', '别这样', '不要', 'wrong', 'redo', 'undo', 'revert', 'mistake', 'instead', "don't", "that's not", 'not what', From 0264628d1f8029e4f3e91901da07de5567f88099 Mon Sep 17 00:00:00 2001 From: Saul Moro Date: Tue, 15 Sep 2026 11:35:08 +0200 Subject: [PATCH 2/3] fix(dashboard): resolve team keywords from the hook cwd, treat _ as a word char Cursor runs hooks from ~/.cursor and sends the project in workspace_roots, so resolving the team via autoDetectInit() (process.cwd()) silently dropped the team's correctionKeywords there. Resolve the project from resolveHookCwd(stdin) and fall back to the user-scope config. Underscore joins the word boundary so identifiers such as "test_undo" do not count as `undo`. Drop the regex cache and fold both keyword lists into one `.some`. CHANGELOG separates the whole-word fix from the team-keywords feature and notes the new `correction` event field; both usage guides say the built-in list still covers only zh/en/ja. --- CHANGELOG.md | 3 +- docs/usage-guide.md | 2 +- docs/usage-guide.zh-CN.md | 2 +- src/__tests__/dashboard-collector.test.ts | 9 +++++ src/__tests__/hook-handlers.test.ts | 41 ++++++++++++++++++----- src/dashboard-collector.ts | 19 ++++------- src/hook-handlers.ts | 15 ++++++--- 7 files changed, 62 insertions(+), 29 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 65d0d72e..a0705a34 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,10 +9,11 @@ All notable changes to this project will be documented in this file. See [standa - First-class Kiro support: skills, steering rules, JSON subagents with CLI `agentSpawn` session-start hooks, and MCP sync to `.kiro/` (for [#500](https://github.com/Tencent/teamai-cli/issues/500)). - Multi-project management: `role` and `project` together resolve resource namespaces, and project-private learnings are isolated ([#426](https://github.com/Tencent/teamai-cli/pull/426), for [#375](https://github.com/Tencent/teamai-cli/issues/375)). - Data partitions auto-migrate a legacy `.teamai`, resume interrupted migrations, smoke-check the clone, and keep a git-ignored backup ([#439](https://github.com/Tencent/teamai-cli/pull/439), for [#374](https://github.com/Tencent/teamai-cli/issues/374)). +- Teams add their own course-correction words via `sharing.intervention.correctionKeywords` in `teamai.yaml`. The built-in list still covers only Chinese, English and Japanese, so corrections typed in other languages count only once the team configures them. The `UserPromptSubmit` hook now stores a `correction` flag on each dashboard prompt event (for [#564](https://github.com/Tencent/teamai-cli/issues/564)). ### 🐛 Bug Fixes -- Course-correction detection matches Latin-script keywords as whole words, so Spanish "segundo" no longer counts as `undo`; teams add their own words via `sharing.intervention.correctionKeywords` in `teamai.yaml` (for [#564](https://github.com/Tencent/teamai-cli/issues/564)). +- Course-correction detection matches keywords in space-separated scripts as whole words, so Spanish "segundo" no longer counts as `undo` (for [#564](https://github.com/Tencent/teamai-cli/issues/564)). - MCP `requires` is resolved from `PATH` (including Windows `PATHEXT`), so `teamai mcp inject` no longer skips servers such as `uvx` on Windows ([#540](https://github.com/Tencent/teamai-cli/pull/540), for [#539](https://github.com/Tencent/teamai-cli/issues/539)). - The GitHub and CNB providers resolve their CLI to a launchable absolute path and start it through cross-spawn, so on Windows they no longer answer "installed" while every call fails silently ([#520](https://github.com/Tencent/teamai-cli/pull/520)). - `enabledAgents` now also gates CLI builtin deploy, CLAUDE.md-class injects, and last-pull skip-sync targets, so an already-installed tool outside the whitelist is not written to ([#510](https://github.com/Tencent/teamai-cli/issues/510)). diff --git a/docs/usage-guide.md b/docs/usage-guide.md index 686036c3..6bed9187 100644 --- a/docs/usage-guide.md +++ b/docs/usage-guide.md @@ -1244,7 +1244,7 @@ Each session card shows a `⚠ N` badge, counting the **number of human interven > Privacy: only counts are tracked — no prompt or transcript text is ever stored. -Keywords in a space-separated script (English, Spanish, ...) must appear as a whole word, so Spanish "segundo" does not count as `undo`. Chinese and Japanese keywords match as substrings. Teams whose members correct the agent in another language add their own words in `teamai.yaml`; they are merged with the built-in list and matched case-insensitively under the same rules: +Keywords in a space-separated script (English, Spanish, ...) must appear as a whole word, so Spanish "segundo" does not count as `undo`. Chinese and Japanese keywords match as substrings. The built-in list covers only Chinese, English and Japanese; a correction typed in any other language is not detected until the team adds its own words in `teamai.yaml`. Team words are merged with the built-in list and matched case-insensitively under the same rules: ```yaml sharing: diff --git a/docs/usage-guide.zh-CN.md b/docs/usage-guide.zh-CN.md index 37f98223..2b143d37 100644 --- a/docs/usage-guide.zh-CN.md +++ b/docs/usage-guide.zh-CN.md @@ -1211,7 +1211,7 @@ teamai dashboard --port 8080 > 隐私:只统计**次数**,不落地任何 prompt 或 transcript 原文。 -以空格分词的文字(英语、西班牙语等)中的纠偏词必须整词匹配,因此西班牙语 "segundo" 不会被算作 `undo`;中文、日文纠偏词仍按子串匹配。成员用其他语言纠偏的团队可在 `teamai.yaml` 添加自己的词,与内置列表合并,忽略大小写,遵循同样的匹配规则: +以空格分词的文字(英语、西班牙语等)中的纠偏词必须整词匹配,因此西班牙语 "segundo" 不会被算作 `undo`;中文、日文纠偏词仍按子串匹配。内置列表只覆盖中、英、日三种语言,其他语言的纠偏在团队于 `teamai.yaml` 添加自己的词之前不会被识别。团队词与内置列表合并,忽略大小写,遵循同样的匹配规则: ```yaml sharing: diff --git a/src/__tests__/dashboard-collector.test.ts b/src/__tests__/dashboard-collector.test.ts index a15df14a..62100c2a 100644 --- a/src/__tests__/dashboard-collector.test.ts +++ b/src/__tests__/dashboard-collector.test.ts @@ -129,6 +129,15 @@ describe('parseHookEvent', () => { } }); + it('treats underscore as part of a word, so identifiers do not match', async () => { + const cases: Array<[string, boolean]> = [['run test_undo again', false], ['undo_it', false], ['undo it', true]]; + for (const [prompt, expected] of cases) { + const raw = JSON.stringify({ hook_event_name: 'UserPromptSubmit', session_id: 's', prompt }); + const event = await parseHookEvent(raw, 'claude'); + expect(event!.correction, prompt).toBe(expected); + } + }); + it('keeps substring matching for Chinese and Japanese keywords', async () => { for (const prompt of ['这不对', '違うよ']) { const raw = JSON.stringify({ hook_event_name: 'UserPromptSubmit', session_id: 's', prompt }); diff --git a/src/__tests__/hook-handlers.test.ts b/src/__tests__/hook-handlers.test.ts index 95406cbb..c0b15ca2 100644 --- a/src/__tests__/hook-handlers.test.ts +++ b/src/__tests__/hook-handlers.test.ts @@ -63,8 +63,18 @@ const mockAutoDetectInit = vi.fn().mockResolvedValue({ teamConfig: { team: 'test', repo: '', toolPaths: {} }, }); +const mockDetectProjectConfig = vi.fn().mockResolvedValue(null); +const mockLoadTeamConfig = vi.fn().mockResolvedValue(null); +const mockRequireInit = vi.fn().mockResolvedValue({ + localConfig: { repo: { localPath: '/tmp', remote: '' }, username: 'test', scope: 'user' }, + teamConfig: { team: 'test', repo: '', toolPaths: {} }, +}); + vi.mock('../config.js', () => ({ autoDetectInit: mockAutoDetectInit, + detectProjectConfig: mockDetectProjectConfig, + loadTeamConfig: mockLoadTeamConfig, + requireInit: mockRequireInit, })); vi.mock('../utils/logger.js', () => ({ @@ -879,29 +889,42 @@ describe('dashboard-report team correction keywords', () => { (r) => r.event === 'prompt-submit' && r.handler.name === 'dashboard-report', )!.handler; + const teamWithKeywords = { team: 'test', repo: '', toolPaths: {}, sharing: { intervention: { correctionKeywords: ['rehazlo'] } } }; + beforeEach(() => { mockParseHookEvent.mockClear(); - mockAutoDetectInit.mockClear(); + mockDetectProjectConfig.mockClear(); + mockLoadTeamConfig.mockClear(); + mockRequireInit.mockClear(); }); - it('passes sharing.intervention.correctionKeywords to parseHookEvent on prompt hooks', async () => { - mockAutoDetectInit.mockResolvedValueOnce({ - localConfig: { repo: { localPath: '/tmp', remote: '' }, username: 'test', scope: 'user' }, - teamConfig: { team: 'test', repo: '', toolPaths: {}, sharing: { intervention: { correctionKeywords: ['rehazlo'] } } }, - }); - await handler().execute({ hook_event_name: 'UserPromptSubmit', session_id: 's', prompt: 'rehazlo' }, 'claude'); + it('resolves the project from the hook payload cwd, not process.cwd() (Cursor runs hooks from ~/.cursor)', async () => { + mockDetectProjectConfig.mockResolvedValueOnce({ repo: { localPath: '/tmp/proj/.teamai', remote: '' }, username: 'test', scope: 'project' }); + mockLoadTeamConfig.mockResolvedValueOnce(teamWithKeywords); + await handler().execute({ hook_event_name: 'UserPromptSubmit', session_id: 's', cwd: '', workspace_roots: ['/tmp/proj'], prompt: 'rehazlo' }, 'cursor'); + expect(mockDetectProjectConfig).toHaveBeenCalledWith('/tmp/proj'); + expect(mockLoadTeamConfig).toHaveBeenCalledWith('/tmp/proj/.teamai'); + expect(mockParseHookEvent).toHaveBeenCalledWith(expect.any(String), 'cursor', { correctionKeywords: ['rehazlo'] }); + }); + + it('uses the user-scope team config when the cwd has no project config', async () => { + mockRequireInit.mockResolvedValueOnce({ localConfig: {}, teamConfig: teamWithKeywords }); + await handler().execute({ hook_event_name: 'UserPromptSubmit', session_id: 's', cwd: '/tmp/elsewhere', prompt: 'rehazlo' }, 'claude'); + expect(mockDetectProjectConfig).toHaveBeenCalledWith('/tmp/elsewhere'); + expect(mockRequireInit).toHaveBeenCalledOnce(); expect(mockParseHookEvent).toHaveBeenCalledWith(expect.any(String), 'claude', { correctionKeywords: ['rehazlo'] }); }); it('falls back to built-in keywords only when team config cannot be read', async () => { - mockAutoDetectInit.mockRejectedValueOnce(new Error('not initialized')); + mockRequireInit.mockRejectedValueOnce(new Error('not initialized')); await handler().execute({ hook_event_name: 'UserPromptSubmit', session_id: 's', prompt: 'wrong' }, 'claude'); expect(mockParseHookEvent).toHaveBeenCalledWith(expect.any(String), 'claude', { correctionKeywords: [] }); }); it('does not read team config for hooks without a prompt', async () => { await handler().execute({ hook_event_name: 'Stop', session_id: 's' }, 'claude'); - expect(mockAutoDetectInit).not.toHaveBeenCalled(); + expect(mockDetectProjectConfig).not.toHaveBeenCalled(); + expect(mockRequireInit).not.toHaveBeenCalled(); expect(mockParseHookEvent).toHaveBeenCalledWith(expect.any(String), 'claude', { correctionKeywords: [] }); }); }); diff --git a/src/dashboard-collector.ts b/src/dashboard-collector.ts index 651ff39e..9370aa51 100644 --- a/src/dashboard-collector.ts +++ b/src/dashboard-collector.ts @@ -813,25 +813,21 @@ export async function countInterventions( /** Scripts that do not separate words with spaces: substring matching is correct there. */ const UNSPACED_SCRIPT_RE = /[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Hangul}]/u; -/** Match a keyword as a whole word: no letter or digit may touch either end. */ +/** + * Match a keyword as a whole word: no letter, digit or underscore may touch either + * end, so `undo` does not fire on "segundo" or on the identifier "test_undo". + */ function wordBoundaryPattern(keyword: string): RegExp { const escaped = keyword.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); - return new RegExp(`(?(); - /** True when `lower` contains `keyword`, whole-word for spaced scripts, substring otherwise. */ function containsKeyword(lower: string, keyword: string): boolean { const k = keyword.trim().toLowerCase(); if (!k) return false; if (UNSPACED_SCRIPT_RE.test(k)) return lower.includes(k); - let re = wordBoundaryCache.get(k); - if (!re) { - re = wordBoundaryPattern(k); - wordBoundaryCache.set(k, re); - } - return re.test(lower); + return wordBoundaryPattern(k).test(lower); } /** @@ -841,8 +837,7 @@ function containsKeyword(lower: string, keyword: string): boolean { function isCorrectionPrompt(text?: string, extraKeywords: readonly string[] = []): boolean { if (!text) return false; const lower = text.toLowerCase(); - return CORRECTION_KEYWORDS.some((k) => containsKeyword(lower, k)) - || extraKeywords.some((k) => containsKeyword(lower, k)); + return [...CORRECTION_KEYWORDS, ...extraKeywords].some((k) => containsKeyword(lower, k)); } /** diff --git a/src/hook-handlers.ts b/src/hook-handlers.ts index 84a2fffd..669bec39 100644 --- a/src/hook-handlers.ts +++ b/src/hook-handlers.ts @@ -115,16 +115,21 @@ const updateHandler: HookHandler = { }; /** - * Team course-correction keywords for the current project. Only prompt hooks pay - * for the config read; an unreadable config means "built-in keywords only". + * Team course-correction keywords for the project the prompt belongs to. The + * project is resolved from the hook payload's cwd, not process.cwd(): Cursor runs + * hooks from ~/.cursor (see resolveHookCwd). Only prompt hooks pay for the config + * read; an unreadable config means "built-in keywords only". */ async function teamCorrectionKeywords(stdin: Record): Promise { if (typeof stdin.prompt !== 'string') return []; try { - const { autoDetectInit } = await import('./config.js'); + const { detectProjectConfig, loadTeamConfig, requireInit } = await import('./config.js'); const { getInterventionSharing } = await import('./types.js'); - const { teamConfig } = await autoDetectInit(); - return getInterventionSharing(teamConfig).correctionKeywords; + const projectConfig = await detectProjectConfig(resolveHookCwd(stdin)); + const teamConfig = projectConfig + ? await loadTeamConfig(projectConfig.repo.localPath) + : (await requireInit()).teamConfig; + return teamConfig ? getInterventionSharing(teamConfig).correctionKeywords : []; } catch { return []; } From b3639bcfa9f995991b2ff71663a8103be3fdeccf Mon Sep 17 00:00:00 2001 From: Saul Moro Date: Tue, 15 Sep 2026 12:00:40 +0200 Subject: [PATCH 3/3] fix(dashboard): keep autoDetectInit for team keywords in the prompt hook hook-dispatch-cli already chdir's to the hook payload's cwd before running handlers, so autoDetectInit() resolves the right project on Cursor too. The explicit detectProjectConfig(resolveHookCwd(stdin)) path added in the previous commit was redundant; drop it and its tests. --- src/__tests__/hook-handlers.test.ts | 41 +++++++---------------------- src/hook-handlers.ts | 18 ++++++------- 2 files changed, 17 insertions(+), 42 deletions(-) diff --git a/src/__tests__/hook-handlers.test.ts b/src/__tests__/hook-handlers.test.ts index c0b15ca2..95406cbb 100644 --- a/src/__tests__/hook-handlers.test.ts +++ b/src/__tests__/hook-handlers.test.ts @@ -63,18 +63,8 @@ const mockAutoDetectInit = vi.fn().mockResolvedValue({ teamConfig: { team: 'test', repo: '', toolPaths: {} }, }); -const mockDetectProjectConfig = vi.fn().mockResolvedValue(null); -const mockLoadTeamConfig = vi.fn().mockResolvedValue(null); -const mockRequireInit = vi.fn().mockResolvedValue({ - localConfig: { repo: { localPath: '/tmp', remote: '' }, username: 'test', scope: 'user' }, - teamConfig: { team: 'test', repo: '', toolPaths: {} }, -}); - vi.mock('../config.js', () => ({ autoDetectInit: mockAutoDetectInit, - detectProjectConfig: mockDetectProjectConfig, - loadTeamConfig: mockLoadTeamConfig, - requireInit: mockRequireInit, })); vi.mock('../utils/logger.js', () => ({ @@ -889,42 +879,29 @@ describe('dashboard-report team correction keywords', () => { (r) => r.event === 'prompt-submit' && r.handler.name === 'dashboard-report', )!.handler; - const teamWithKeywords = { team: 'test', repo: '', toolPaths: {}, sharing: { intervention: { correctionKeywords: ['rehazlo'] } } }; - beforeEach(() => { mockParseHookEvent.mockClear(); - mockDetectProjectConfig.mockClear(); - mockLoadTeamConfig.mockClear(); - mockRequireInit.mockClear(); + mockAutoDetectInit.mockClear(); }); - it('resolves the project from the hook payload cwd, not process.cwd() (Cursor runs hooks from ~/.cursor)', async () => { - mockDetectProjectConfig.mockResolvedValueOnce({ repo: { localPath: '/tmp/proj/.teamai', remote: '' }, username: 'test', scope: 'project' }); - mockLoadTeamConfig.mockResolvedValueOnce(teamWithKeywords); - await handler().execute({ hook_event_name: 'UserPromptSubmit', session_id: 's', cwd: '', workspace_roots: ['/tmp/proj'], prompt: 'rehazlo' }, 'cursor'); - expect(mockDetectProjectConfig).toHaveBeenCalledWith('/tmp/proj'); - expect(mockLoadTeamConfig).toHaveBeenCalledWith('/tmp/proj/.teamai'); - expect(mockParseHookEvent).toHaveBeenCalledWith(expect.any(String), 'cursor', { correctionKeywords: ['rehazlo'] }); - }); - - it('uses the user-scope team config when the cwd has no project config', async () => { - mockRequireInit.mockResolvedValueOnce({ localConfig: {}, teamConfig: teamWithKeywords }); - await handler().execute({ hook_event_name: 'UserPromptSubmit', session_id: 's', cwd: '/tmp/elsewhere', prompt: 'rehazlo' }, 'claude'); - expect(mockDetectProjectConfig).toHaveBeenCalledWith('/tmp/elsewhere'); - expect(mockRequireInit).toHaveBeenCalledOnce(); + it('passes sharing.intervention.correctionKeywords to parseHookEvent on prompt hooks', async () => { + mockAutoDetectInit.mockResolvedValueOnce({ + localConfig: { repo: { localPath: '/tmp', remote: '' }, username: 'test', scope: 'user' }, + teamConfig: { team: 'test', repo: '', toolPaths: {}, sharing: { intervention: { correctionKeywords: ['rehazlo'] } } }, + }); + await handler().execute({ hook_event_name: 'UserPromptSubmit', session_id: 's', prompt: 'rehazlo' }, 'claude'); expect(mockParseHookEvent).toHaveBeenCalledWith(expect.any(String), 'claude', { correctionKeywords: ['rehazlo'] }); }); it('falls back to built-in keywords only when team config cannot be read', async () => { - mockRequireInit.mockRejectedValueOnce(new Error('not initialized')); + mockAutoDetectInit.mockRejectedValueOnce(new Error('not initialized')); await handler().execute({ hook_event_name: 'UserPromptSubmit', session_id: 's', prompt: 'wrong' }, 'claude'); expect(mockParseHookEvent).toHaveBeenCalledWith(expect.any(String), 'claude', { correctionKeywords: [] }); }); it('does not read team config for hooks without a prompt', async () => { await handler().execute({ hook_event_name: 'Stop', session_id: 's' }, 'claude'); - expect(mockDetectProjectConfig).not.toHaveBeenCalled(); - expect(mockRequireInit).not.toHaveBeenCalled(); + expect(mockAutoDetectInit).not.toHaveBeenCalled(); expect(mockParseHookEvent).toHaveBeenCalledWith(expect.any(String), 'claude', { correctionKeywords: [] }); }); }); diff --git a/src/hook-handlers.ts b/src/hook-handlers.ts index 669bec39..eb41f481 100644 --- a/src/hook-handlers.ts +++ b/src/hook-handlers.ts @@ -115,21 +115,19 @@ const updateHandler: HookHandler = { }; /** - * Team course-correction keywords for the project the prompt belongs to. The - * project is resolved from the hook payload's cwd, not process.cwd(): Cursor runs - * hooks from ~/.cursor (see resolveHookCwd). Only prompt hooks pay for the config - * read; an unreadable config means "built-in keywords only". + * Team course-correction keywords for the current project. The dispatcher has + * already chdir'd to the hook payload's cwd (hook-dispatch-cli), so + * autoDetectInit() resolves the right project, as it does for + * contributeHintAllowed. Only prompt hooks pay for the config read; an + * unreadable config means "built-in keywords only". */ async function teamCorrectionKeywords(stdin: Record): Promise { if (typeof stdin.prompt !== 'string') return []; try { - const { detectProjectConfig, loadTeamConfig, requireInit } = await import('./config.js'); + const { autoDetectInit } = await import('./config.js'); const { getInterventionSharing } = await import('./types.js'); - const projectConfig = await detectProjectConfig(resolveHookCwd(stdin)); - const teamConfig = projectConfig - ? await loadTeamConfig(projectConfig.repo.localPath) - : (await requireInit()).teamConfig; - return teamConfig ? getInterventionSharing(teamConfig).correctionKeywords : []; + const { teamConfig } = await autoDetectInit(); + return getInterventionSharing(teamConfig).correctionKeywords; } catch { return []; }