diff --git a/CHANGELOG.md b/CHANGELOG.md index 4b69b9b3..a0705a34 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,9 +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 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 5b08dd68..6bed9187 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. 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: + 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..2b143d37 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..62100c2a 100644 --- a/src/__tests__/dashboard-collector.test.ts +++ b/src/__tests__/dashboard-collector.test.ts @@ -106,6 +106,71 @@ 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('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 }); + 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 +808,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..9370aa51 100644 --- a/src/dashboard-collector.ts +++ b/src/dashboard-collector.ts @@ -810,11 +810,34 @@ 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, 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(`(? lower.includes(k)); + return [...CORRECTION_KEYWORDS, ...extraKeywords].some((k) => containsKeyword(lower, k)); } /** @@ -841,6 +864,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 +877,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 +932,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 +1372,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..eb41f481 100644 --- a/src/hook-handlers.ts +++ b/src/hook-handlers.ts @@ -114,12 +114,31 @@ const updateHandler: HookHandler = { }, }; +/** + * 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 { 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',