docs(09-24): plan five confirmed hotkey fixes and the test rewrite

This commit is contained in:
unanmed 2026-10-23 12:58:52 +08:00
parent 3fd339ffcb
commit cfdca2f50e

View File

@ -0,0 +1,460 @@
---
phase: 09-minimum-browser-runnable
plan: 24
type: execute
wave: 1
depends_on: []
files_modified:
- packages/system/src/hotkey/system.ts
- packages/system/src/hotkey/command.ts
- packages/system/src/hotkey/__test__/system.test.ts
- packages/system/src/hotkey/__test__/command.test.ts
- .planning/phases/09-minimum-browser-runnable/09-24-SUMMARY.md
autonomous: false
requirements: []
estimate:
# 未校准估算(confidence: low),SUMMARY 须声明这一点
tokens: 70000
raw_tokens: 70000
tasks: 4
confidence: low
must_haves:
truths:
- "**本计划第一个 block 点是 Task 0 的 `blocking-human` 汇报关卡(AGENTS.md)**:先汇报「修正用户重写后按键系统的已确认缺陷 + 按重写后的设计重写适配测试」的要做什么 / 为什么 / 大致怎么做 / 影响范围,逐条列明 5 处已确认修正的确切落点、测试重写的覆盖清单与预期口径、验证方式、已知边界、以及待批准命名(OI-1 / OI-2)与疑似问题(OI-3,只提问不判定、不修复),然后停下等待「可以执行」。"
- "**修正 1 —— `matchKeyUp` 解构写反(`system.ts`)**:`matchArray(keys, v => isAuxiliaryKey(v))` 返回 `[匹配, 未匹配]`,而 `isAuxiliaryKey` 的谓词为真者是**辅助键**(`packages/common/src/utils/func.ts:30` 的 jsDoc 与实现均如此)。当前写作 `const [main, auxiliary] = ...` 导致 `main` 实际是辅助键、`auxiliary` 实际是常规键;改为 `const [auxiliary, main] = ...`,`matchKeyUp` 的其余逻辑(`keys.length === 0` 早退、`main.length > 1` 非法、`main.length === 0` 单辅助键分支、`main[0] !== key`、`auxiliary.every(held)`)**逐字不变**。修正后 KeyUp 四条规则才成立:`[Ctrl, KeyS]` 在 Ctrl 仍按住时抬 S 触发、Ctrl 先松则永不触发、`[Ctrl]` 单独抬起触发、`[Ctrl, Shift]` 永不触发、`[KeyA, KeyB]` 由 203 拦下且永不触发。"
- "**修正 2 —— `when` 条件生效(`system.ts`)**:`scope.condition` 是唯一条件来源,当前只写不读,导致条件完全不参与触发。新增私有方法 `isActive()`(OI-1 待批准):取栈顶作用域,无作用域返回 `false`,`condition` 为 `null` 返回 `true`,否则返回 `condition()` 的本次求值结果。判定落点三处:① `down` 在 `holding` 入栈后、遍历指令之前包一层 `if (this.isActive())`,条件不满足则不建立任何待触发项、不占用 block;② `up` 在既有清理循环之后、`toEmit.sort(...)` 与优先级 / block 循环之前,条件不满足则直接返回当前的待触发项布尔值(不派发、不按 block 计算、不把 KeyUp 项转投 `waitTimeout`);③ `emit` 在取到作用域之后、取实现之前再判一次 `isActive()`,这是**延时触发**(`excited` 的 `waitTimeout` 循环与 `holdingCmds` 循环)唯一可靠的闸口。语义口径:条件只在**派发 / 触发时刻**求值,不追溯已建立的待触发项;`waitTimeout` 中的项到期时若条件不满足,本次触发被吞掉并按既有代码从 `waitTimeout` 出队(**不补发**);`holdingCmds` 中的项被抑制时保留,条件恢复后按自身规则继续。"
- "**修正 3 —— `leaveScope()` 无参分支缺 `return`(`system.ts`)**:`if (!sym)` 分支在 `this.scopes.pop()` 与两次 `clear()` 之后必须立即 `return;`,否则会继续执行 `findIndex(v => v.symbol === undefined)`(符号永不相等)→ 判定 `index < 0` → **误发 warn 205**。修正后:无参且栈非空 → 只弹一级、不告警;无参且空栈 → 仍发 205 并返回;有参且找不到 → 仍发 205 并保持栈不变;有参且命中 → `splice(index + 1)` 保留命中作用域为栈顶(既有行为不变)。"
- "**修正 4 —— `excited` 首次触发判定写反(`system.ts`)**:`if (pending.emitted === 0 && dt < command.timeout)` 改为 `dt >= command.timeout`。修正后:`timeout > 0` 的按下类指令在 `payload - start` 达到 `timeout` 的那一次激励才首次触发,未到期不触发。只改这一处比较符,`waitTimeout` 循环里既有的 `dt >= pending.command.timeout` 与 `interval` / `dt` 分支**不动**。"
- "**修正 5 —— `setKeys` 允许 0(`command.ts`)**:`count === 0` 合法(不告警、缩容到空、`length = 0`);仅**非整数**(`!Number.isInteger(count)`,沿用现有 `'float'`)与**负数**(`count < 0`,沿用现有 `'negative integer'`)按 207 告警并保持原状。即把 `if (count <= 0)` 改为 `if (count < 0)`。`logger.json` 的 207 文案**一字不改**(用户裁定沿用现有措辞,尽管其文本写的是 positive integer)。附带的实际效果是:构造器对 `defaults: []` 调用 `setKeys(0)` 不再产生 207 噪声。"
- "**按用户裁定保持现状的六项(逐项不得改动)**:① `down` / `up` 的返回值仍为 `holdingCmds.size > 0 || waitTimeout.size > 0`(反映调用后仍存在的待触发项,而非「是否真的触发过实现」);② KeyUp 的 `context` 仍是**匹配时刻快照**(`addPending` 时 `createContext()`,`down` 重新匹配时刷新),因此 KeyUp 触发时 `holding` **包含**被抬起的主键(与 `type.ts` 的 jsDoc 口径不同,以用户裁定为准);③ `priority` / `block` 仅对抬起触发(KeyUp)批次生效;④ `waitTimeout` 的到期判定基于 `pending.start`(`addPending` 时刻),不是抬起时刻;⑤ 未实现的指令仍会建立待触发项(`block` 在匹配层计算);⑥ `excited` 的三段时序结构(先 `waitTimeout`、再 `holdingCmds`、`Continuous` 内 `emitted === 1` 走 `interval`、否则走 `dt`)不改。"
- "**测试按重写后的设计整体重写(两个文件)**:删除对已不存在的 `HotkeyEmitType` / `HotkeyEmitMode.Throttle` / `command.throttle(...)` / `context.type` / `context.key` / `info.command` 为字符串的断言;`info.command` 为 `IHotkeyCommand` **对象**(含 `command` id 与 `mode`),`info.mode` 为 `HotkeyEmitMode`;`continuous(interval, dt, timeout)` 三参;`setKeys` / `setKeyCombination` 的 206 / 207 校验按现有实现断言;每个 `it` 前一行 `//` 注释说明该用例覆盖什么。"
- "**测试只从实现模块导入**:`command.test.ts` 用 `import { HotkeyCommand, isAuxiliaryKey } from '../command'`,`system.test.ts` 用 `import { HotkeySystem } from '../system'`;**禁止 `from '../index'`**(`hotkey.ts` 顶层执行 `document.addEventListener`,Node 下导入即抛错)。Node 环境无 `HTMLElement`,`listen` 用最小 fake 元素(`addEventListener` / `removeEventListener` 记录处理器、可手动派发伪事件),激励源用最小 fake(实现 `IExcitation<number>`:`payload` / `excite` / `add` 返回带 `revoke` 的控制器 / `remove` / `destroy`)。"
- "**system.test.ts 的三段交付边界(避免半成品红灯)**:Task 1 只重写「注册表 / 作用域 / KeyUp 四条规则 / holding 与子集匹配 / 优先级与 block / `listen` / `down`·`up` 返回值」这些**不依赖修正 2、4** 的用例(该文件在 Task 1 结束时必须全绿);Task 3 再追加「`when` 条件生效 / `excited` 驱动的 timeout·interval·dt / `excitedBy` 单源切换与 null / 延时触发的 context 快照」用例。**不得**在 Task 1 提前写入依赖未修正代码的断言。"
- "**范围(只动 5 个文件)**:`packages/system/src/hotkey/system.ts`、`command.ts`、`__test__/system.test.ts`、`__test__/command.test.ts`、`09-24-SUMMARY.md`。`type.ts`(权威设计)、`hotkey.ts` / `keyboard.ts`(旧实现)、`keyCodes.ts`、`index.ts`、`packages/common/src/logger.json`(201–207 已存在)、`package.json` / lockfile、`packages-user/**`、`src/**`、`public/**`、`script/**`、`vite.config.ts`、`tsconfig.json`、`eslint.config.js` 一律零改动。"
- "**验证可运行且范围诚实(Nyquist)**:`cmd /c \"pnpm exec vitest run packages/system/src/hotkey/__test__/command.test.ts packages/system/src/hotkey/__test__/system.test.ts\"`;`cmd /c \"pnpm exec eslint <四个改动 TS 文件>\"`;结构闸门(5 处修正的代码形态、测试卫生、`it` 前注释、导入路径);按文件过滤的类型闸门(`vue-tsc --noEmit`,先校验 `spawnSync` 的 `out.error` / `out.status` / `out.signal`,本计划文件 0 条诊断,`hotkey.ts` / `keyboard.ts` 恰 2 条已知 legacy 诊断作为「输出完整」的基线证据);受保护文件 `git status --porcelain` 零改动闸门;范围闸门(白名单由 `files_modified` 派生)。不跑 `build:game` / `build:packages`,不做全仓类型修复。"
artifacts:
- path: packages/system/src/hotkey/system.ts
provides: "修正 1(`matchKeyUp` 解构改为 `const [auxiliary, main]`)、修正 2(新增私有 `isActive()` 并在 `down` / `up` / `emit` 三处设闸)、修正 3(无参 `leaveScope` 在 `pop` + `clear` 后 `return;`)、修正 4(`dt >= command.timeout`);除此之外不改任何既有逻辑、注释与 jsDoc"
- path: packages/system/src/hotkey/command.ts
provides: "修正 5:`setKeys` 的 `if (count <= 0)` 改为 `if (count < 0)`,使 `count === 0` 合法(`length = 0`、无 207);非整数 / 负数的 207 分支与文案不变;其余方法零改动"
- path: packages/system/src/hotkey/__test__/system.test.ts
provides: "按重写后设计重写的系统单测:注册表(204 / 重复返回同一对象 / `getCommand` / `deleteCommand` 清理待触发项 / `iterateCommands`)、作用域(`enterScope` 返回 symbol、无参 `leaveScope` 只弹一级且**不再误发 205**、空栈 205、`leaveScope(sym)` 保留命中作用域、未知 sym 205、无作用域时 201、未知指令 202)、`when` 条件生效与回退、KeyUp 四条规则(含 203 与空组合)、holding 顺序与去重、KeyDown 子集匹配、优先级降序 / 严格更低阻断 / 同优先级全触发、`down`·`up` 返回值(按当前实现)、`listen` 的映射(65→`KeyA`、91·92·224→`Meta`、无映射跳过)、重复按下不重复触发、`preventDefault`、`listen(null)` 解绑、同元素不重复注册;每个 `it` 前一行注释"
- path: packages/system/src/hotkey/__test__/command.test.ts
provides: "按新设计重写的指令单测:构造缺省与 `defaults` 拷贝、`isAuxiliaryKey`、`setKeys` 的 0 / 负数 / 非整数、扩缩容、`setKeyCombination` 的 206 三态与写入副本、KeyUp 模式的 203 与「1 常规 + 辅助」「2 辅助」「空组合」不告警、非 KeyUp 模式不校验 203、`getKeyCombination` 越界空数组与副本、`iterateKeyCombinations` 顺序与副本、`keyUp` / `once` / `continuous` 的参数与 mode、`command` id;每个 `it` 前一行注释"
- path: .planning/phases/09-minimum-browser-runnable/09-24-SUMMARY.md
provides: "执行摘要:5 处修正的最终落点、按用户裁定保持现状的六项、测试重写与旧用例的对应关系、疑似问题 OI-3 的裁定结果(未确认为问题则只记录提问与用户回复)、门禁结果、边界(未做浏览器 E2E、未跑全量构建、旧实现与 `type.ts` 零改动)、`estimate.tokens=70000` 为未校准估算(`confidence: low`)的声明"
key_links:
- "`matchArray` 的 `[match, unmatch]` 元组(`packages/common/src/utils/func.ts`)↔ `matchKeyUp` 的 `const [auxiliary, main]` —— 辅助键集合只由 `isAuxiliaryKey` 定义一次,解构顺序写反会让整套 KeyUp 规则失效"
- "`scope.condition`(`when` 写入)↔ `isActive()` ↔ `down` / `up` / `emit` —— 条件必须在「建 pending」「算 block」「调实现」三个位置都成立,`emit` 是延时触发的唯一兜底闸口"
- "`leaveScope` 的作用域栈 ↔ 205 —— 无参分支的 `return` 决定「弹一级」不会被后续 `findIndex(undefined)` 误判成未知作用域"
- "`excited(payload)` 的 `payload` ↔ `pending.start` / `pending.lastEmit` ↔ `timeout` / `interval` / `dt` —— 首次触发用 `payload - start >= timeout`,第二次用 `>= interval`,其后用 `>= dt`"
- "`setKeys(count)` ↔ `keys.length` ↔ `length` ↔ 构造器的 `setKeys(init.defaults.length)` —— `count === 0` 必须能通过,否则 `defaults: []` 会误报 207"
- "`IHotkeyEmitInfo.command`(对象)↔ `IHotkeyCommand.mode` / `.command` —— 测试断言的是对象与其 id,不是旧的字符串 id 与 `HotkeyEmitType`"
- "测试文件的导入路径 ↔ `hotkey/index.ts` 的模块顶层副作用 —— 一旦从 `../index` 导入,`hotkey.ts` 的 `document.addEventListener` 会让整个测试文件在 Node 下崩掉"
assumptions:
- "**`matchArray` 语义(已核验)**:`packages/common/src/utils/func.ts:30-45` 返回 `[match: T[], unmatch: T[]]`,`match` 为谓词为真的项。`isAuxiliaryKey` 对 `Ctrl` / `Shift` / `Alt` / `Meta` 返回真,故正确解构为 `const [auxiliary, main]`。"
- "**当前代码与测试基线(已实测,HEAD `3fd339f`,工作树干净)**:`system.ts` 含 `const [main, auxiliary] = matchArray(...)`、无参 `leaveScope` 无 `return`、`dt < command.timeout`;`command.ts` 含 `if (count <= 0)`;`cmd /c \"pnpm exec vitest run <两个测试>\"` 当前为 **2 files failed / 18 tests failed / 42 passed**(旧测试按已删除的 `HotkeyEmitType` / `Throttle` / 旧 context 口径写成)。"
- "**类型基线(已实测)**:`cmd /c \"pnpm exec vue-tsc --noEmit\"` 对本目录的诊断只有 `__test__/command.test.ts` 3 条(`readonly KeyCode[]` 上 `push`、`throttle`、`HotkeyEmitMode.Throttle`)与 `__test__/system.test.ts` 2 条(`HotkeyEmitType`、`Throttle`);`command.ts` / `system.ts` **0 条**;`hotkey.ts` / `keyboard.ts` 各 1 条 legacy `KeyCode` 导出缺失(共 2 条,属既定边界)。重写后本计划文件应为 0 条。"
- "**`logger.json` 无需改动(已核验)**:`warn` 段已有 201–207,207 文案为 `Key combination count needs to be a positive integer, but got $1.`,206 为 `... expected positive integer, found $1.`,203 文案已含 `at most one non-auxiliary key`;本次不允许改 `logger.json`。"
- "**测试收集与解析**:根 `vite.config.ts` 的 `test` 未设 `include`,vitest 默认收集 `**/*.test.ts`;`@motajs/*` 由 `vite.config.ts` 的 glob 别名与 `tsconfig.json` 的 `paths` 指向 `packages/*/src`,故 `@motajs/animate` / `@motajs/common` 可直接导入。"
- "**`HotkeyEmitMode` 是 `const enum`,但测试可正常引用成员**:既有测试已用 `HotkeyEmitMode.KeyUp` 并在 vitest 下运行(42 条通过),重写沿用同一写法即可。"
- "**条件求值次数**:`down` / `up` 的闸口与 `emit` 的闸口会让同一次派发内 `condition()` 被调用 1–2 次;`when` 的约定是纯判定函数(`type.ts` 的 jsDoc 只要求返回布尔),重复求值不视为问题;本计划不改 `when` 的存储方式。"
- "**KeyUp 的 context 是匹配时刻快照(用户裁定)**:`addPending` 在 `down` 命中组合时 `createContext()` 并保存;`up` 的清理循环只为非 KeyUp 模式刷新 `pending.context`。因此 KeyUp 触发时 `holding` 含被抬起的主键,测试按此断言并在注释里写明这是当前口径。"
- "**`down` / `up` 返回值按当前实现**:KeyUp 在 `up` 内立即触发后 `holdingCmds` 已删除该指令、`waitTimeout` 为空 → `up` 返回 `false`;而 `down` 建立起待触发项 → 返回 `true`。测试按此断言并注明「返回值反映调用后仍存在的待触发项」。"
- "**Task 3 的 `excited` 断言只覆盖不会踩到 OI-3 的形态**:`Once + timeout` 只走 `pending.emitted === 0` 分支;`Continuous + timeout === 0` 在 `addPending` 已立即触发(`emitted` 从 1 起算),因此 `interval` / `dt` 断言不受 OI-3 影响。`Continuous + timeout > 0` 不在本计划断言范围内(见 OI-3)。"
prohibitions:
- "不得在用户回复通过 Task 0 关卡之前执行任何任务或派发执行子代理。"
- "**不得修正确认清单以外的任何行为**:只能落地 5 处修正;`down` / `up` 返回值、KeyUp 的 context 快照、`priority` / `block` 的生效范围、`waitTimeout` 的计时起点、未实现指令的待触发项、`excited` 的分支结构一律按现状保留。**疑似问题 OI-3 未经用户确认前不得修改,也不得写成已确认缺陷。**"
- "**不得改受保护文件**:`type.ts`(权威设计,逐字保留)、`hotkey.ts` / `keyboard.ts`(旧实现,含顶层 `document` 监听)、`keyCodes.ts`、`hotkey/index.ts`、`packages/common/src/logger.json`、`package.json` / lockfile、`packages-user/**`、`src/**`、`public/**`、`script/**`、`vite.config.ts`、`tsconfig.json`、`eslint.config.js`、`dev.md`、`AGENTS.md`。"
- "**不得改写既有注释 / jsDoc(AGENTS.md)**:`system.ts` / `command.ts` 的既有注释逐字保留;`matchKeyUp` 的注释「抬起按键仅允许一个非辅助按键」在修正后依然成立,不得改动;新逻辑的说明只允许写在新成员(`isActive`)上。"
- "**不得从 `../index` 导入测试**:会让 `hotkey.ts` 的模块顶层 `document.addEventListener` 在 Node 下抛错;`command.test.ts` 只导 `../command` + `../keyCodes` + `../type`,`system.test.ts` 只导 `../system` + `../keyCodes` + `../type`。"
- "**不得保留旧设计断言**:不得再出现 `HotkeyEmitType`、`HotkeyEmitMode.Throttle`、`.throttle(`、`context.type`、`context.key`、`expect(info.command).toBe('<id>')`(`info.command` 是对象)。"
- "**不得用 jsdom / 改 vitest 配置 / 引入新依赖**:Node 环境 + fake 元素 + fake 激励源;`packages/system/package.json` 不动。"
- "**不得伪造门禁**:不得删减用例充数、不得把未覆盖的形态写成「已覆盖」、不得放宽断言以通过(例如把 KeyUp 断言改成「至少调用一次」而丢掉 `keys` / `index` / `context` 校验);结构闸门与类型闸门都必须校验命令真正跑完(`spawnSync` 的 `out.error` / `out.status` / `out.signal`)。"
- "**不得跑全量构建或全仓类型修复**:不执行 `build:game` / `build:packages`,不修 `hotkey.ts` / `keyboard.ts` 的 2 条 legacy 类型错误,不改其它包。"
- "**不得用 `cmd` 以外的裸 `pnpm`**:PowerShell 5.1 下 `pnpm.ps1` 会因 `PSSecurityException` 失败,命令一律 `cmd /c \"pnpm exec ...\"`;`node -e` 内联脚本只允许单引号字面量(不得出现 `\\\"`,否则 PowerShell 会提前截断字符串)。"
open_items:
- "**OI-1(新增私有方法命名,待批准)**:提议 `private isActive(): boolean`(`system.ts`,判定栈顶作用域及其条件)。备选:`isConditionMet` / `canEmit` / `isScopeActive`。**未获批前不得落码**;若改名,Task 3 的结构闸门字符串与 `must_haves` 同步修订并重新汇报。"
- "**OI-2(测试内部 helper 命名,报备)**:`createInit(overrides?: Partial<IHotkeyCommandInit>)`、`class FakeElement`(`listeners` / `addEventListener` / `removeEventListener` / `dispatch` / `count`)、`interface FakeKeyEvent`(`keyCode` / `repeat` / `preventDefault`)、`createKeyEvent(keyCode, repeat?)`、`createExcitation()`(返回 `IExcitation<number>` + 记录 `remove` 调用)。均为测试文件内部声明、不导出。"
- "**OI-3(疑似问题,仅提问、未确认为问题、本计划不修复)**:`system.ts` 的 `excited` 在 `Continuous` 分支里写的是 `if (pending.emitted === 1) {...} else { if (dt2 >= command.dt) emit }`;首次触发前 `emitted === 0` 且 `lastEmit === -Infinity`,`dt2 = payload - (-Infinity) = Infinity` 恒 `>= dt`,因此 **`continuous` 且 `timeout > 0` 的指令会在 timeout 到期前的那次激励就被触发**(例如 `timeout: 100` 时 `excite(99)` 即触发);同理 `timeout` 到期的同一次激励可能连续触发两次(`interval` 为 0 时)。**这只是向用户的提问,不是已登记缺陷**:本计划不修、不在测试中断言该组合;若用户确认要修,先修订本计划(改为 `else if (pending.emitted > 1)` 或等价结构 + 补 `continuous + timeout > 0` 的时序用例)再执行。"
- "**OI-4(口径备注,已按用户裁定执行,不需另批)**:KeyUp 触发时 `info.context.holding` 含被抬起的主键(匹配时刻快照),`up` 在 KeyUp 立即触发后返回 `false`、`down` 返回 `true`(返回值反映仍存在的待触发项)。测试按此断言并在 `it` 注释中注明「按当前实现」,SUMMARY 需如实记录这两条与 `type.ts` jsDoc 的差异。"
---
<objective>
Phase 9 · 第 24 个计划 —— **修正用户重写后按键系统(`command.ts` / `system.ts`)的 5 处已确认缺陷,并按重写后的设计整体重写两个适配测试**。
**要解决的问题:** 用户在 `564f53d`(refactor: 按键系统)与 `3fd339f`(feat: 抬起按键的优先级)中重写了 `packages/system/src/hotkey/command.ts` / `system.ts`,`type.ts` 是权威设计。但实现里有 5 处已由用户确认的缺陷(`matchKeyUp` 解构写反导致 KeyUp 组合几乎全不触发、`when` 条件只写不读、无参 `leaveScope` 缺 `return` 误发 205、`excited` 首次触发比较符写反、`setKeys` 把 0 当非法),同时 `__test__/command.test.ts` 与 `__test__/system.test.ts` 仍按**旧设计**编写(`HotkeyEmitType`、`HotkeyEmitMode.Throttle`、`command.throttle(...)`、`context.type` / `context.key`、`info.command` 为字符串),当前 **18 failed / 42 passed**,既拦不住缺陷也无法作为回归基线。
**做法(最小):**
- `packages/system/src/hotkey/system.ts`:只改四处 —— `matchKeyUp` 的解构顺序;`leaveScope` 无参分支补 `return;`;新增私有 `isActive()` 并在 `down` / `up` / `emit` 设条件闸口;`excited` 的首次触发改为 `dt >= command.timeout`。
- `packages/system/src/hotkey/command.ts`:只改一处 —— `setKeys` 的 `count <= 0` 改为 `count < 0`(0 合法)。
- `__test__/system.test.ts`:整体重写(Task 1 交付不依赖修正 2 / 4 的用例并跑绿,Task 3 追加条件与时序用例)。
- `__test__/command.test.ts`:整体重写(Task 2 与修正 5 同批交付并跑绿)。
- 不碰 `type.ts` / `hotkey.ts` / `keyboard.ts` / `keyCodes.ts` / `index.ts` / `logger.json` / `package.json` / lockfile / `packages-user/**` / `src/**`。
**约束:** 只动 `files_modified` 内的 5 个文件;除 5 处修正外不改任何行为(含 `down` / `up` 返回值、KeyUp 的 context 快照、`priority` / `block` 的生效范围);不把疑似问题 OI-3 当成已确认缺陷;不跑全量构建。
**修改方式声明(AGENTS.md):** 本计划执行含文件写入(改写 2 个实现文件的部分行、整体重写 2 个测试文件、写 1 个 SUMMARY),由**派发子代理(`gsd-executor`)执行**,非内联执行;除 `files_modified` 外无任何写入。
</objective>
<execution_context>
@C:/Users/13194/.config/opencode/gsd-core/workflows/execute-plan.md
@C:/Users/13194/.config/opencode/gsd-core/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/09-minimum-browser-runnable/09-CONTEXT.md
@.planning/phases/09-minimum-browser-runnable/09-23-PLAN.md
@.planning/phases/09-minimum-browser-runnable/09-23-SUMMARY.md
@AGENTS.md
@dev.md
@packages/system/src/hotkey/type.ts
@packages/system/src/hotkey/command.ts
@packages/system/src/hotkey/system.ts
@packages/system/src/hotkey/keyCodes.ts
@packages/system/src/hotkey/index.ts
@packages/common/src/utils/func.ts
@packages/common/src/logger.json
@packages/animate/src/types.ts
@packages/system/src/hotkey/__test__/command.test.ts
@packages/system/src/hotkey/__test__/system.test.ts
</context>
<tasks>
<task type="checkpoint:decision" gate="blocking-human">
<name>Task 0: 预执行汇报关卡 —— 汇报 5 处已确认修正、测试重写范围与口径、待批准命名与疑似问题,等待「可以执行」</name>
<precondition>`packages/system/src/hotkey/system.ts` 仍含 `const [main, auxiliary] = matchArray(`、无参 `leaveScope` 无 `return`、`dt < command.timeout`;`command.ts` 仍含 `if (count <= 0)`;两个测试文件仍按旧设计编写(含 `HotkeyEmitType` / `Throttle`);`logger.json` 的 warn 201–207 已存在;工作树对这两个实现文件与两个测试文件无未提交改动。</precondition>
<files>(不改仓库文件)</files>
<read_first>
- packages/system/src/hotkey/type.ts(权威设计:`IHotkeyCommand` 的 `setKeys` / `continuous(interval, dt, timeout)`、`IHotkeyEmitInfo.command` 为对象、`IHotkeyEmitContext` 无 `type` / `key`)
- packages/system/src/hotkey/command.ts(`isAuxiliaryKey`、`setKeys` 的 `count <= 0`、`setKeyCombination` 的 206 / 203)
- packages/system/src/hotkey/system.ts(`matchKeyUp` 解构、`leaveScope` 无 `return`、`when` 写入但从不读取、`excited` 的 `dt < command.timeout`)
- packages/common/src/utils/func.ts(`matchArray` 返回 `[match, unmatch]`)
- packages/system/src/hotkey/__test__/command.test.ts、system.test.ts(旧测试:`HotkeyEmitType` / `Throttle` / `context.key` / `context.type` / 字符串 `info.command`)
- packages/common/src/logger.json(warn 201–207 的现有文案,本次不改)
- .planning/phases/09-minimum-browser-runnable/09-23-SUMMARY.md(09-23 的既有边界与语义口径)
- AGENTS.md(执行前须获批、命名须先反馈、既有注释不得修改、测试 `it` 前注释、修改方式须预先声明)、dev.md(命名、jsDoc、无副作用、类先 interface 后 implements、CRLF)
</read_first>
<action>
**停下并向用户汇报,等待回复「可以执行」后才继续;本任务不修改任何仓库文件、不派发执行子代理。**
① **要解决的问题:** 用户重写后的按键系统有 5 处已确认缺陷(下面逐条列出,含确切落点与当前错误写法);同时两个测试文件仍按旧设计写成,当前 `pnpm exec vitest run` 为 **18 failed / 42 passed**,既拦不住缺陷也不能当回归基线。本计划只修这 5 处 + 按重写后的设计整体重写两个测试。
② **修正 1(`system.ts`,`matchKeyUp`):** `matchArray(keys, v => isAuxiliaryKey(v))` 返回 `[匹配, 未匹配]`,谓词为真者是**辅助键**;当前 `const [main, auxiliary] = ...` 把两者拿反,导致 `[Ctrl, KeyS]` 在抬起 `KeyS` 时 `main[0]` 是 `Ctrl` ≠ `KeyS` → 永不触发。改为 `const [auxiliary, main] = ...`,其余逻辑逐字不变。修正后 KeyUp 四条规则成立。
③ **修正 2(`system.ts`,`when`):** `scope.condition` 只写不读。新增私有方法 `isActive()`(OI-1 待批准:取栈顶、无作用域 `false`、`condition` 为 `null` 为 `true`、否则取 `condition()`),并在三处设闸:`down` 在 `holding` 入栈后、遍历指令前;`up` 在清理循环后、`toEmit.sort(...)` 与优先级 / block 循环前;`emit` 在取到作用域后、取实现前(延时触发的唯一兜底)。条件不满足时:不建立待触发项、不算 block、不调实现;`waitTimeout` 中的项到期而条件不满足 → 本次吞掉并出队、不补发;`holdingCmds` 中的项保留,条件恢复后按自身规则继续。
④ **修正 3(`system.ts`,`leaveScope`):** 无参分支在 `this.scopes.pop()` 与两次 `clear()` 之后补 `return;`。当前缺 `return` 会继续 `findIndex(v => v.symbol === undefined)` → 误发 205。空栈无参仍发 205,有参未知仍发 205,有参命中仍 `splice(index + 1)` 保留命中作用域。
⑤ **修正 4(`system.ts`,`excited`):** `dt < command.timeout` 改为 `dt >= command.timeout`(`timeout > 0` 时未到期不触发,到期的那次激励才首次触发)。只改比较符,`waitTimeout` 循环与 `interval` / `dt` 分支不动。
⑥ **修正 5(`command.ts`,`setKeys`):** `if (count <= 0)` 改为 `if (count < 0)`,使 `count === 0` 合法(无 207、缩容到空、`length = 0`);非整数 / 负数仍按 207 告警(文案沿用 `logger.json` 现有 207,不改文案)。附带的实际效果是构造器对 `defaults: []` 的 `setKeys(0)` 不再误报 207。
⑦ **按用户裁定保持现状(明确不改,并会写进测试预期):** `down` / `up` 返回值仍是「调用后是否仍有待触发项」(KeyUp 在 `up` 立即触发后 `up` 返回 `false`、`down` 返回 `true`);KeyUp 的 `context` 仍是匹配时刻快照(`holding` 含被抬起的主键,与 `type.ts` jsDoc 不同);`priority` / `block` 只对抬起批次生效;`waitTimeout` 的计时起点是 `addPending` 时刻;未实现指令仍建立待触发项(`block` 在匹配层计算);`excited` 的三段分支结构不动。
⑧ **测试重写范围(两个文件,逐 `it` 前一行注释,只从 `../command` / `../system` 导入):**
- `system.test.ts` Task 1 交付:注册表(204 / 返回同一对象 / `getCommand` / `deleteCommand` 清理待触发项 / `iterateCommands`)、作用域(`enterScope` 返回 symbol;**无参 `leaveScope` 只弹一级且不再误发 205**;空栈 205;`leaveScope(sym)` 保留命中作用域、上方丢弃;未知 sym 205;无作用域 201;未知指令 202)、KeyUp 四条规则(`[Ctrl, KeyS]` 抬 S 触发且 Ctrl 先松不触发、`[Ctrl]` 单辅助键触发、`[Ctrl, Shift]` 不触发、`[KeyA, KeyB]` 203 且永不触发、空组合不触发不告警)、holding 顺序与去重、KeyDown 子集匹配(`[Ctrl, KeyS]` 与 `[KeyS]` 同时命中)、优先级降序 / 严格更低阻断 / 同优先级全触发、`down` / `up` 返回值(按当前实现)、`listen` 映射(65→`KeyA`、91/92/224→`Meta`、无映射跳过)、重复按下不重复触发、`preventDefault`(按下时 `down` 为真 → 调用;KeyUp 抬起后 `up` 为假 → 不调用)、`listen(null)` 解绑、同元素不重复注册。
- `system.test.ts` Task 3 追加:`when` 条件生效与回退(条件为假时按下 / 抬起都不触发,恢复后重新按下可触发;延时触发到期时条件为假 → 吞掉且不补发)、`excited` 驱动的 `Once + timeout`(99 不触发 / 100 触发 / 之后仍只 1 次)、`Continuous + timeout 0` 的 `interval` / `dt`(down 1 次;`excite(40)` 仍 1;`excite(50)` → 2;`excite(100)` 仍 2;`excite(250)` → 3)、松键后 Continuous 停止、未触发的 `Once` 松键后转入待触发并在 `excite(200)` 触发一次(当前实现,非取消)、KeyUp 延时项的 `context` 快照(`holding` 含被抬起的主键)、`excitedBy` 单源切换(切换后旧源不再驱动、新源驱动)与 `excitedBy(null)`、未绑定激励源时延时指令永不触发、`info.command` 为对象且含 `mode` / `command` id。
- `command.test.ts` Task 2 交付:构造缺省(`mode` 为 `KeyUp`、`timeout` / `interval` / `dt` 为 0、`block` 为 `false`)与 `defaults` 拷贝、`defaults: []` 不告警、`isAuxiliaryKey` 四真四假、`setKeys(0)` 合法(无 207、`length` 0)、`setKeys(-1)` / `setKeys(1.5)` 各发一次 207 且状态不变、扩缩容、`setKeyCombination` 的非整数 / 负数 / 越界三种 206 且组合不变、写入副本、KeyUp 模式 203(2 常规键)与 1 常规 + 辅助 / 2 辅助 / 空组合不告警、非 KeyUp 模式不校验 203、`getKeyCombination` 越界空数组与副本、`iterateKeyCombinations` 顺序与副本、`keyUp(120)` / `once(15)` / `continuous(30, 60, 90)` / `continuous()` 的 `mode` 与字段、`command` id。
⑨ **验证方式:** `cmd /c "pnpm exec vitest run packages/system/src/hotkey/__test__/command.test.ts packages/system/src/hotkey/__test__/system.test.ts"`;`cmd /c "pnpm exec eslint <四个改动 TS 文件>"`;结构闸门(5 处修正的代码形态、测试卫生:无 `HotkeyEmitType` / `Throttle` / `context.key` / `context.type` / `../index`,每个 `it` 前有注释);按文件过滤的类型闸门(`vue-tsc --noEmit`,先校验 `spawnSync` 的 `out.error` / `out.status` / `out.signal`,本计划文件 0 条诊断,`hotkey.ts` / `keyboard.ts` 恰 2 条已知 legacy 诊断作为输出完整的基线证据);受保护文件 `git status --porcelain` 零改动;范围闸门(白名单由 `files_modified` 派生)。不跑 `build:game`。
⑩ **已知边界(原样告知):** ①不做浏览器 E2E、不接线 `packages-user/**` 的 `IClientHotkey`;②`hotkey.ts` / `keyboard.ts` 的 2 条 legacy 类型错误保持原状;③`type.ts` / `keyCodes.ts` / `index.ts` / `logger.json` / `package.json` / lockfile 零改动;④不修 OI-3(疑似问题,见下)除非用户确认并要求先修订计划。
⑪ **待批准命名与待裁决问题:** OI-1 私有方法名 `isActive`(备选 `isConditionMet` / `canEmit` / `isScopeActive`);OI-2 测试内部 helper(`createInit` / `FakeElement` / `FakeKeyEvent` / `createKeyEvent` / `createExcitation`,不导出);**OI-3(只提问,未确认为问题、本计划不修)**:`excited` 的 `Continuous` 分支 `if (pending.emitted === 1) {...} else { if (dt2 >= command.dt) emit }` 在 `emitted === 0` 且 `lastEmit === -Infinity` 时 `dt2` 为 `Infinity`,会让 `continuous` + `timeout > 0` 在 timeout 到期前就触发(如 `timeout: 100` 时 `excite(99)` 即触发);本计划不修、不在测试中断言该组合,若确认要修请先说,届时先修订本计划再执行。OI-4 口径备注:KeyUp 的 `context.holding` 含被抬起主键、`up` 在 KeyUp 立即触发后返回 `false`(按用户裁定的当前实现)。
⑫ **修改方式:** 由 `gsd-executor` 子代理执行(非内联),只写 `files_modified` 的 5 个文件。
</action>
<decision>是否按本计划(09-24)执行:在 `packages/system/src/hotkey/system.ts` 修正 `matchKeyUp` 解构顺序、给 `leaveScope` 无参分支补 `return;`、新增 `isActive()` 并让 `when` 条件在 `down` / `up` / `emit` 生效、把 `excited` 首次触发改为 `dt >= command.timeout`;在 `command.ts` 把 `setKeys` 的 `count <= 0` 改为 `count < 0`(0 合法);按重写后的设计整体重写 `__test__/command.test.ts` 与 `__test__/system.test.ts`(每 `it` 前一行注释、只从 `../command` / `../system` 导入);其余行为按用户裁定保持不变,`type.ts` / 旧实现 / `logger.json` / `index.ts` / 依赖文件零改动。</decision>
<context>
AGENTS.md 要求执行前先汇报并等待「可以执行」、命名先反馈、既有注释不得修改;`.planning/config.json` 为 `mode: yolo` 且 `auto_advance: false`,故本关卡显式 `blocking-human` 且 `autonomous: false`。已核验(HEAD `3fd339f`,工作树干净):`system.ts` 的 `const [main, auxiliary] = matchArray(...)`、无参 `leaveScope` 无 `return`、`dt < command.timeout`;`command.ts` 的 `if (count <= 0)`;`logger.json` 已有 warn 201–207;两个测试当前 18 failed / 42 passed;`vue-tsc` 对本目录只有两个测试文件的 5 条旧设计诊断,`command.ts` / `system.ts` 为 0 条,`hotkey.ts` / `keyboard.ts` 各 1 条 legacy。执行期含写操作但没有删除动作,也没有依赖 / lockfile 变更。
</context>
<options>
<option id="execute-default">
<name>可以执行(按本计划默认口径;OI-1 采用 `isActive`,OI-3 不修)</name>
<pros>一次修掉 5 处已确认缺陷并让两个测试重新成为可信回归基线;只改 4 个代码/测试文件与 1 个 SUMMARY;不引入依赖、不碰权威设计与旧实现,风险面最小</pros>
<cons>测试整体重写(两个文件约 900–1100 行),审阅量较大;KeyUp 的 `context` 快照与 `down` / `up` 返回值仍与 `type.ts` 的 jsDoc 不一致(按用户裁定保留);OI-3 的 `continuous + timeout > 0` 仍留在现状且无测试覆盖</cons>
</option>
<option id="revise">
<name>先修订 OI-1 命名 / 确认 OI-3 的处置,再重新汇报</name>
<pros>避免带着未确认的命名落码,也能一并决定是否连带修 OI-3</pros>
<cons>须修订 PLAN.md 后重新汇报,执行延后;若确认修 OI-3,还需补 `continuous + timeout > 0` 的时序用例与结构闸门</cons>
</option>
<option id="tests-only">
<name>先只重写测试(记录红灯),修正另开计划</name>
<pros>改动面最小,先让测试与新设计对齐</pros>
<cons>仓库会停在「测试按新设计写、实现有 5 处缺陷」的红灯状态,`test:ci` 被阻断;本计划的 Task 1 已按「修正 + 对应用例同批跑绿」切分,拆开收益有限</cons>
</option>
</options>
<resume-signal>回复「可以执行」即可;如需改 OI-1 的命名、或要求一并修 OI-3(`continuous + timeout > 0`),请在回复中一并指明。</resume-signal>
<verify>
<automated>node -e "const fs=require('fs');const bad=[];const need=['packages/system/src/hotkey/type.ts','packages/system/src/hotkey/command.ts','packages/system/src/hotkey/system.ts','packages/system/src/hotkey/keyCodes.ts','packages/system/src/hotkey/index.ts','packages/common/src/utils/func.ts','packages/common/src/logger.json','packages/system/src/hotkey/__test__/command.test.ts','packages/system/src/hotkey/__test__/system.test.ts'];for(const p of need){if(!fs.existsSync(p))bad.push('missing '+p)}const sys=fs.readFileSync('packages/system/src/hotkey/system.ts','utf8');const cmd=fs.readFileSync('packages/system/src/hotkey/command.ts','utf8');if(sys.indexOf('const [main, auxiliary] = matchArray(')<0)bad.push('prestate changed: matchKeyUp no longer destructures [main, auxiliary]');if(/this\.scopes\.pop\(\);[\s\S]{0,120}return;/.test(sys))bad.push('prestate changed: leaveScope no-arg branch already returns');if(sys.indexOf('dt < command.timeout')<0)bad.push('prestate changed: excited first-trigger comparison is not dt < timeout');if(cmd.indexOf('if (count <= 0)')<0)bad.push('prestate changed: setKeys is not count <= 0');if(sys.indexOf('scope.condition()')>=0||sys.indexOf('isActive')>=0)bad.push('prestate changed: condition gating already present');const st=fs.readFileSync('packages/system/src/hotkey/__test__/system.test.ts','utf8');const ct=fs.readFileSync('packages/system/src/hotkey/__test__/command.test.ts','utf8');if(st.indexOf('HotkeyEmitType')<0)bad.push('prestate changed: system.test.ts is no longer the old-design test');if(ct.indexOf('throttle')<0)bad.push('prestate changed: command.test.ts is no longer the old-design test');const log=JSON.parse(fs.readFileSync('packages/common/src/logger.json','utf8'));for(const c of [201,202,203,204,205,206,207]){if(!log.warn[c])bad.push('warn '+c+' missing from logger.json')}const type=fs.readFileSync('packages/system/src/hotkey/type.ts','utf8');if(type.indexOf('interface IHotkeySystem')<0||type.indexOf('continuous(')<0)bad.push('type.ts is not the expected authoritative design');if(bad.length){console.error('PRESTATE FAILED :: '+bad.join(' ; '));process.exit(1)}console.log('OK prestate: 5 defects present, old-design tests present, warn 201-207 available')"</automated>
<fails_when>non-zero exit,或 stderr 含 "PRESTATE FAILED":必需文件缺失 / 5 处缺陷中任一已不在(说明实现已被提前修改,需先核对本计划)/ 旧测试已不是旧设计 / warn 201–207 有缺条 / `type.ts` 不是预期设计</fails_when>
<human-check>用户明确回复「可以执行」;若就 OI-1 命名或 OI-3 的处置给出不同意见,须先按新决定修订本计划(结构闸门字符串、`must_haves`、测试用例与 SUMMARY 关键字同步)并重新获批,未修订前不得执行</human-check>
</verify>
<acceptance_criteria>
- 用户回复中明确包含「可以执行」
- 汇报涵盖:要解决的问题(5 处已确认缺陷 + 旧测试 18 failed / 42 passed)、5 处修正的确切落点与当前错误写法、按用户裁定保持现状的六项、测试重写范围与预期口径、验证方式、四项已知边界
- 已逐条说明修正 2 的三个闸口位置(`down` / `up` / `emit`)与时序语义(延时触发到期而条件不满足 → 吞掉并出队、不补发)
- 已说明 `count === 0` 变合法但 207 文案不改,以及构造器 `defaults: []` 不再误报 207 的附带效果
- 已说明 `down` / `up` 返回值与 KeyUp 的 `context` 快照仍按当前实现(与 `type.ts` jsDoc 的差异会如实写进测试注释与 SUMMARY)
- 已说明 Task 1 / Task 3 的测试交付边界(Task 1 只写不依赖修正 2、4 的用例并跑绿)
- 已提出 OI-1(`isActive`)与 OI-2(测试 helper)命名,并**以提问方式**提出 OI-3(`continuous + timeout > 0`),明确「未确认为问题、本计划不修、不写进测试」
- 已说明修改由子代理执行、只写 `files_modified` 的 5 个文件
- 已运行 `<automated>` 前置校验并通过(`OK prestate: ...`)
</acceptance_criteria>
<done>用户明确回复「可以执行」;在此之前未执行任何任务、未修改任何仓库文件、未派发任何执行子代理</done>
</task>
<task type="tracer">
<name>Task 1: 端到端打通「一条 KeyUp 热键」—— 修正 `matchKeyUp` 解构与无参 `leaveScope`,并整体重写 `system.test.ts` 的作用域 / 注册表 / KeyUp / 优先级 / listen 切片</name>
<precondition>Task 0 已闭环(用户回复「可以执行」;OI-1 / OI-2 / OI-3 已获批或按提议默认执行)。`system.ts` 仍含 5 处缺陷的原始写法;`system.test.ts` 仍是旧设计的 1059 行版本。</precondition>
<files>packages/system/src/hotkey/system.ts, packages/system/src/hotkey/__test__/system.test.ts</files>
<read_first>
- packages/system/src/hotkey/system.ts(全部 474 行:`matchKeyUp`、`leaveScope`、`down`、`up`、`emit`、`addPending`、`excited`、`listen`)
- packages/system/src/hotkey/command.ts(`isAuxiliaryKey` 的辅助键集合;`setKeyCombination` 在 KeyUp 模式下发 203)
- packages/system/src/hotkey/type.ts(`IHotkeyEmitInfo` 的 `command` 是对象、`IHotkeyEmitContext` 只有 `holding` / `ctrl` / `shift` / `alt` / `meta`)
- packages/common/src/utils/func.ts(`matchArray` 的 `[match, unmatch]` 顺序)
- packages/system/src/hotkey/__test__/system.test.ts(旧文件:可复用的 fake 元素 / fake 激励源 / 伪事件写法,但断言整体作废)
- packages/system/src/hotkey/keyCodes.ts(`KeyCode.KeyA` / `Shift` / `Ctrl` / `Alt` / `Meta` / `Unknown`;`EVENT_KEY_CODE_MAP` 是 `new Array(230)`,无 Meta 事件码)
- .planning/phases/09-minimum-browser-runnable/09-23-PLAN.md 的 Task 1(`command.ts` / `system.ts` 的既有实现口径与测试风格)
- AGENTS.md(测试 `it` 前一行注释;测试文件不受其它 dev.md 规则约束)、dev.md(CRLF、import 连续、注释风格)
</read_first>
<action>
**本任务只交付「KeyUp 热键端到端可用」+ 与其同层的作用域 / 注册表 / 优先级 / listen 行为,并让 `system.test.ts` 在结束时全绿。条件与激励时序用例留给 Task 3,不得在本任务提前写入依赖未修正代码的断言。**
(1) **`system.ts` 修正 1(`matchKeyUp` 解构):** 把该行从 `const [main, auxiliary] = matchArray(keys, v => isAuxiliaryKey(v));` 改为 `const [auxiliary, main] = matchArray(keys, v => isAuxiliaryKey(v));`。方法内其余行(`keys.length === 0` 早退、`main.length > 1` 返回 `false`、`main.length === 0` 时 `auxiliary.length === 1 && auxiliary[0] === key`、`main[0] !== key` 返回 `false`、`auxiliary.every(v => this.holdingSet.has(v))`)与既有注释**逐字不动**。
(2) **`system.ts` 修正 3(无参 `leaveScope` 提前返回):** 在 `if (!sym) { ... }` 分支内、`this.waitTimeout.clear();` 之后、该分支右花括号之前插入 `return;`(即「栈非空 → pop + 两次 clear → return」;空栈分支保持既有的 `logger.warn(205); return;`)。有参分支(`findIndex` / `splice(index + 1)` / 两次 clear)与 `when` / `implement` 一律不动。
(3) **`system.test.ts` 整体重写(Task 1 切片):** 以新内容覆盖整个文件。文件头一行注释说明覆盖范围;首行起连续 `import`(`IExcitable` / `IExcitation` from `@motajs/animate`;`logger` from `@motajs/common`;`afterAll` / `afterEach` / `describe` / `expect` / `it` / `vi` from `vitest`;`KeyCode` from `../keyCodes`;`HotkeySystem` from `../system`;`HotkeyEmitMode` / `IHotkeyCommandInit` from `../type`)。**禁止从 `../index` 导入。**
(4) **测试骨架:** `vi.hoisted(() => { vi.stubGlobal('main', { replayChecking: true }); vi.stubGlobal('location', { origin: 'http://localhost' }); })`;`afterEach(() => vi.restoreAllMocks())`;`afterAll(() => vi.unstubAllGlobals())`。helper(OI-2):`createInit(overrides: Partial<IHotkeyCommandInit> = {}): IHotkeyCommandInit` 返回 `{ priority: 0, block: false, defaults: [], ...overrides }`;`class FakeElement`(`listeners: Map<string, ((event: KeyboardEvent) => void)[]>`、`addEventListener` / `removeEventListener` / `dispatch(type, event: FakeKeyEvent)` / `count()`);`interface FakeKeyEvent { keyCode: number; repeat: boolean; preventDefault: ReturnType<typeof vi.fn> }`;`createKeyEvent(keyCode, repeat = false)`。`listen` 处沿用既有写法 `target as unknown as HTMLElement`(测试文件不受 dev.md 的连续 `as` 限制,AGENTS.md 只强制「每个 `it` 前一行注释」)。
(5) **注册表用例(每个 `it` 前一行 `//` 注释):**
- 重复 `addCommand` 发 204 且返回同一对象、`[...iterateCommands()].length === 1`(`warn` 断言 `toHaveBeenCalledWith(204, 'save')`)。
- `getCommand` 未注册返回 `null`;注册后返回同一对象。
- `deleteCommand` 后 `getCommand` 返回 `null`、`iterateCommands` 为空,且**已建立的待触发项被清理**:`addCommand('fire', { defaults: [[KeyCode.KeyA]] })` → `enterScope` → `implement` → `down(KeyA)` → `deleteCommand('fire')` → `up(KeyA)`,断言 handler 未被调用;`deleteCommand` 对未知 id 不抛错。
- `iterateCommands` 按注册顺序产出。
(6) **作用域用例:**
- `enterScope()` 返回 `symbol`。
- **无参 `leaveScope()` 只弹一级且不告警(修正 3 的直接回归)**:`addCommand` + 父作用域 `implement` + 子作用域 `implement`;`leaveScope()` 后 `down` / `up`,断言子 handler 未调用、父 handler 调用 1 次,且 `warn` **未被调用**(spy 必须在本用例开始时装好)。
- 空栈无参 `leaveScope()` 发 205 且不抛错。
- `leaveScope(sym)` 保留命中作用域、丢弃其上方:三个作用域各自 `implement`,`leaveScope(first)` 后只有第一个 handler 触发。
- `leaveScope(Symbol())` 发 205 且栈顶实现仍生效。
- 无作用域时 `when` / `implement` 各发 201 并返回 `this`。
- `enterScope` 后 `implement('missing', fn)` 发 202(`toHaveBeenCalledWith(202, 'missing')`)并返回 `this`。
(7) **KeyUp 用例(修正 1 的直接回归):**
- `[Ctrl, KeyS]`:`down(Ctrl)` → `down(KeyS)` → `up(KeyS)`,handler 1 次;`info.mode === HotkeyEmitMode.KeyUp`、`info.index === 0`、`info.keys` 等于 `[Ctrl, KeyS]`、`info.command` 是**对象**且 `info.command.command === 'save'`、`info.command.mode === HotkeyEmitMode.KeyUp`;`info.context.holding` 等于 `[Ctrl, KeyS]`(匹配时刻快照,含被抬起的主键——按用户裁定,注释注明)、`ctrl` 为真、`shift` / `alt` / `meta` 为假。
- `[Ctrl, KeyS]` 先松 Ctrl:`down(Ctrl)` → `down(KeyS)` → `up(Ctrl)` → `up(KeyS)`,handler 0 次。
- `[Ctrl]` 单辅助键:`down(Ctrl)` → `up(Ctrl)` 触发 1 次。
- `[Ctrl, Shift]` 两辅助键:两键依次按下、依次抬起,handler 0 次。
- `[KeyA, KeyB]` 两常规键:`addCommand` 时 `warn` 收到 `(203, 'x')`,组合仍可经 `getKeyCombination(0)` 读回;两键按下 / 抬起后 handler 0 次。
- 空组合 `[[]]`:`warn` 未被调用,按下 / 抬起后 handler 0 次。
(8) **holding 与 KeyDown 子集用例:**
- `system.holding` 按下顺序为 `[Ctrl, KeyS]`;重复 `down(Ctrl)` 不改变顺序也不重复入栈。
- `Once` 模式下 `[Ctrl, KeyS]` 与 `[KeyS]`:`down(Ctrl)` 后两者都未触发;`down(KeyS)` 后两者各触发 1 次(子集匹配,辅助键不写进组合不构成限制)。
(9) **返回值用例(按当前实现,注释注明口径):**
- KeyUp `[KeyA]`:`down(KeyA)` 返回 `true`(建立了待触发项)、`up(KeyA)` 返回 `false`(抬起处立即触发后已无待触发项)。
- `up(KeyC)` 对未按下的键返回 `false`。
(10) **优先级与 block 用例:** 优先级 1 / 10 → 顺序为 `['high', 'low']`;`block: true` 的 10 阻断 5;同优先级(都 5,其中一个 `block: true`)两者都触发。**不要**断言「未实现的 block 指令不阻断」——当前实现在匹配层计算 block,本计划按用户裁定不改该行为。
(11) **`listen` 用例:**
- 注册 `keydown` / `keyup` 两个监听(`count() === 2`);派发 `keydown(65)` + `keyup(65)` 后 handler 1 次;**`keydown` 的 `preventDefault` 被调用 1 次**(`down` 返回真)、**`keyup` 的 `preventDefault` 未被调用**(KeyUp 已触发、`up` 返回假)——注释写明这是当前实现。
- 重复按下不重复触发:`keydown(65)` → `keydown(65, true)` → `keyup(65)`,handler 1 次(依赖 `holding` 去重,`repeat` 字段本身未被读取)。
- `91` / `92` / `224` 映射为 `KeyCode.Meta`:对三个码各自新建 system + `[Meta]` KeyUp 指令,`keydown(code)` → `keyup(code)` 触发 1 次且 `info.context.meta` 为真(循环内每个码单独断言)。
- 无映射键码(如 `999`)被跳过:`keydown(999)` → `keyup(999)` 后 handler 0 次、`system.holding` 为空。
- `listen(null)` 解绑:`count() === 0`、后续派发不触发、`system.listening === null`。
- 同一元素重复 `listen` 不重复注册(`count() === 2`);`listen(other)` 后旧元素 `count() === 0`、新元素 `count() === 2`、`system.listening === other`。
(12) **不要在本任务写入:** `when` 条件相关断言、`excited` / 激励源 / timeout / interval / dt 相关断言、`excitedBy` 相关断言、`Continuous` / `Once` 的时序断言(全部留给 Task 3);也不得写入任何依赖修正 2 / 4 的断言。
(13) 两个文件以 CRLF 保存,写完后执行 `cmd /c "pnpm exec prettier --write packages/system/src/hotkey/system.ts packages/system/src/hotkey/__test__/system.test.ts"`。
</action>
<verify>
<automated>cmd /c "pnpm exec vitest run packages/system/src/hotkey/__test__/system.test.ts"</automated>
<fails_when>non-zero exit,或任一用例失败:KeyUp 四条规则(尤其是 `[Ctrl, KeyS]` 抬 S 触发)、无参 `leaveScope` 只弹一级且不发 205、注册表 / 201 / 202 / 204、优先级与 block、`listen` 的映射与解绑与设计不符</fails_when>
<automated>cmd /c "pnpm exec eslint packages/system/src/hotkey/system.ts packages/system/src/hotkey/__test__/system.test.ts"</automated>
<fails_when>non-zero exit:存在 eslint 报告(未使用变量、格式、`no-console` 等)</fails_when>
<automated>node -e "const fs=require('fs');const bad=[];const sys=fs.readFileSync('packages/system/src/hotkey/system.ts','utf8');if(sys.indexOf('const [auxiliary, main] = matchArray(')<0)bad.push('matchKeyUp is not destructured as [auxiliary, main]');if(sys.indexOf('const [main, auxiliary] = matchArray(')>=0)bad.push('old reversed destructuring still present');if(!/this\.scopes\.pop\(\);[\s\S]{0,160}return;/.test(sys))bad.push('leaveScope no-arg branch lacks return after pop');const t=fs.readFileSync('packages/system/src/hotkey/__test__/system.test.ts','utf8');for(const s of ['HotkeyEmitType','HotkeyEmitMode.Throttle','.throttle(','context.type','context.key','../index']){if(t.indexOf(s)>=0)bad.push('system.test.ts still references obsolete design token: '+s)}if(t.indexOf('../system')<0)bad.push('system.test.ts must import from ../system');if(t.indexOf('excitedBy')>=0||t.indexOf('excite(')>=0)bad.push('Task 1 must not yet contain excitation-driven cases (they belong to Task 3)');const lines=t.split(/\r?\n/);let its=0;lines.forEach((l,i)=>{if(/(^|[^A-Za-z.])it\(/.test(l)){its++;let j=i-1;while(j>=0&&lines[j].trim()==='')j--;if(j<0||!lines[j].trim().startsWith('//'))bad.push('system.test.ts:'+(i+1)+' it() without a preceding comment line')}});if(its<18)bad.push('system.test.ts has only '+its+' it() cases, expected at least 18 in Task 1');if(bad.length){console.error('TRACER GATE FAILED :: '+bad.join(' ; '));process.exit(1)}console.log('OK tracer gate: matchKeyUp fixed, leaveScope returns, '+its+' system cases')"</automated>
<fails_when>non-zero exit,或 stderr 含 "TRACER GATE FAILED":`matchKeyUp` 解构未改或改动方向错 / 无参 `leaveScope` 缺 `return` / 测试仍含旧设计 token / 未从 `../system` 导入 / 提前写入激励相关用例 / 某 `it` 前无注释 / 用例少于 18 条</fails_when>
<automated>node -e "const cp=require('child_process');const r=cp.spawnSync('git',['status','--porcelain','--','packages/system/src/hotkey/type.ts','packages/system/src/hotkey/hotkey.ts','packages/system/src/hotkey/keyboard.ts','packages/system/src/hotkey/keyCodes.ts','packages/system/src/hotkey/index.ts','packages/common/src/logger.json','packages/system/package.json'],{encoding:'utf8'});const out=(r.stdout ? r.stdout : '').trim();if(r.status!==0){console.error('LEGACY GATE FAILED :: git status did not complete, status='+r.status);process.exit(1)}if(out.length>0){console.error('LEGACY GATE FAILED :: dirty='+out);process.exit(1)}console.log('OK protected files unchanged vs HEAD')"</automated>
<fails_when>non-zero exit,或 stderr 含 "LEGACY GATE FAILED":受保护文件(`type.ts` / 旧实现 / `keyCodes.ts` / `index.ts` / `logger.json` / `package.json`)在工作树中被改动</fails_when>
</verify>
<done>`system.ts` 的 `matchKeyUp` 解构改为 `[auxiliary, main]`、无参 `leaveScope` 在 `pop` + 两次 `clear` 后 `return;`;`system.test.ts` 已按新设计整体重写为至少 18 条用例并**全绿**(KeyUp 四条规则、作用域、注册表、优先级 / block、holding、`listen`);eslint、`TRACER GATE`、`LEGACY GATE` 全部通过;未写入任何条件 / 激励时序用例,也未改动其它行为</done>
</task>
<task type="auto">
<name>Task 2: 修正 `setKeys` 的 0 值判定并整体重写 `command.test.ts`(构造缺省 / 206 / 207 / 203 / 模式方法)</name>
<precondition>Task 1 已通过(`system.test.ts` 全绿)。`command.ts` 仍含 `if (count <= 0)`;`command.test.ts` 仍是旧设计版本(含 `throttle` / `HotkeyEmitMode.Throttle` / `readonly KeyCode[]` 上 `push` 的用例)。</precondition>
<files>packages/system/src/hotkey/command.ts, packages/system/src/hotkey/__test__/command.test.ts</files>
<read_first>
- packages/system/src/hotkey/command.ts(123 行:`isAuxiliaryKey`、构造器、`setKeys`、`setKeyCombination`、`getKeyCombination`、`iterateKeyCombinations`、`keyUp` / `once` / `continuous`)
- packages/system/src/hotkey/type.ts(`IHotkeyCommand` 的成员与 jsDoc、`continuous(interval?, dt?, timeout?)` 的三参与默认值、`HotkeyEmitMode` 三个成员)
- packages/system/src/hotkey/__test__/command.test.ts(旧文件:可复用的 `createInit` 与 `vi.hoisted` 写法,断言整体作废)
- packages/common/src/logger.json(warn 203 / 206 / 207 的现有文案与 `$1` 占位)
- AGENTS.md(测试 `it` 前一行注释)、dev.md(CRLF、import 连续)
</read_first>
<action>
**本任务只改 `setKeys` 的一处守卫并整体重写 `command.test.ts`;`command.ts` 的其它方法与 `logger.json` 一字不动。**
(1) **`command.ts` 修正 5:** 把 `setKeys(count)` 中的 `if (count <= 0) { logger.warn(207, 'negative integer'); return; }` 改为 `if (count < 0) { ... }`(告警参数与文案保持 `'negative integer'`)。非整数分支 `if (!Number.isInteger(count)) { logger.warn(207, 'float'); return; }` 与后续扩缩容逻辑(`while (count > this.keys.length) push([])` / `while (count < this.keys.length) pop()` / `this.length = this.keys.length`)不动。`count === 0` 因此合法:缩容到空、`length = 0`、不发 207。
(2) **`command.test.ts` 整体重写:** 覆盖整个文件。文件头一行注释说明覆盖范围;首行起连续 `import`(`logger` from `@motajs/common`;`afterAll` / `afterEach` / `describe` / `expect` / `it` / `vi` from `vitest`;`HotkeyCommand` / `isAuxiliaryKey` from `../command`;`KeyCode` from `../keyCodes`;`HotkeyEmitMode` / `IHotkeyCommandInit` from `../type`)。**禁止从 `../index` 导入**,**禁止引用 `HotkeyEmitType` / `HotkeyEmitMode.Throttle` / `command.throttle(`**。`vi.hoisted` 里 stub `main` / `location`,`afterEach` 恢复 spy,`afterAll` 还原全局;`createInit` helper 与 system.test.ts 同形。
(3) **用例(每个 `it` 前一行 `//` 注释):**
- `isAuxiliaryKey` 对 `Ctrl` / `Shift` / `Alt` / `Meta` 返回真。
- `isAuxiliaryKey` 对 `KeyA` / `Digit1` / `Space` / `Unknown` 返回假。
- 构造缺省:`new HotkeyCommand('save', createInit({ priority: 7, defaults: [[Ctrl, KeyS]] }))` → `command.command` 为 `'save'`、`priority` 为 7、`block` 为 `false`、`mode` 为 `HotkeyEmitMode.KeyUp`、`timeout` / `interval` / `dt` 为 0、`length` 为 1、`getKeyCombination(0)` 等于 `[Ctrl, KeyS]`。
- `defaults` 是拷贝:构造后向调用方的内层数组 `push` 一个键,`getKeyCombination(0)` 不变(**不要**对 `getKeyCombination` 的返回值 `push`——它类型为 `readonly KeyCode[]`)。
- `defaults: []` 不告警(`warn` 未被调用)、`length` 为 0(修正 5 的构造器回归)。
- `setKeys(0)` 合法:先建一个含 1 组组合的指令,再 `setKeys(0)` → `warn` 未被调用、`length` 为 0、`[...iterateKeyCombinations()]` 为空(修正 5 的直接回归)。
- `setKeys(-1)` 与 `setKeys(1.5)`:各发一次 207(断言 `warn.mock.calls.map(c => c[0])` 为 `[207, 207]`,**不要**断言参数字符串)、`length` 与原组合不变。
- `setKeys` 扩缩容:`setKeys(3)` 后 `length` 3 且索引 2 的组合为空数组;`setKeys(1)` 后 `length` 1 且索引 1 读回空数组。
- `setKeyCombination` 三种 206:非整数索引、负数索引、越界索引各发一次 206(断言 `warn.mock.calls.map(c => c[0])` 为 `[206, 206, 206]`,**不要**断言参数字符串——现有实现里有一处拼写为 `negetive`,本计划不改它)、原组合不变。
- `setKeyCombination` 写入副本:传入数组后再修改该数组,`getKeyCombination(0)` 不变。
- KeyUp 模式两常规键:`warn` 收到 `(203, 'save')` 一次,`getKeyCombination(0)` 仍等于 `[KeyA, KeyB]`。
- KeyUp 模式「1 常规 + 辅助」与「2 辅助」与空组合:三种都**不**发 203。
- 非 KeyUp 模式(`mode: HotkeyEmitMode.Once`)两常规键:**不**发 203(`setKeyCombination` 的 KeyUp 模式守卫)。
- `getKeyCombination` 越界返回空数组;返回副本(修改返回值不影响内部——同样只读断言相等,不 `push`)。
- `iterateKeyCombinations` 索引升序(`[0, 1]`)且 `keys` 为副本。
- `keyUp(120)` → `mode` 为 KeyUp、`timeout` 为 120;`once(15)` → `mode` 为 Once、`timeout` 为 15。
- `continuous(30, 60, 90)` → `mode` 为 Continuous、`interval` 30、`dt` 60、`timeout` 90;`continuous()` → 三者归 0(实现无条件写入默认值)。
- `command` id 经直接构造仍保留(`new HotkeyCommand('fire', createInit()).command === 'fire'`)。
(4) 两个文件以 CRLF 保存,写完后执行 `cmd /c "pnpm exec prettier --write packages/system/src/hotkey/command.ts packages/system/src/hotkey/__test__/command.test.ts"`。**不得**把 `logger.json` 传给 prettier。
</action>
<verify>
<automated>cmd /c "pnpm exec vitest run packages/system/src/hotkey/__test__/command.test.ts packages/system/src/hotkey/__test__/system.test.ts"</automated>
<fails_when>non-zero exit,或任一用例失败:`setKeys(0)` 仍告警 / `length` 未归零、207 与 206 的触发次数、203 的 KeyUp 模式守卫、模式方法的字段写入与 system.test.ts 的回归不符</fails_when>
<automated>cmd /c "pnpm exec eslint packages/system/src/hotkey/command.ts packages/system/src/hotkey/__test__/command.test.ts"</automated>
<fails_when>non-zero exit:存在 eslint 报告</fails_when>
<automated>node -e "const fs=require('fs');const bad=[];const cmd=fs.readFileSync('packages/system/src/hotkey/command.ts','utf8');if(cmd.indexOf('if (count < 0)')<0)bad.push('setKeys does not guard only negative counts');if(cmd.indexOf('if (count <= 0)')>=0)bad.push('old count <= 0 guard still present');if(!/count < 0[\s\S]{0,120}warn\(207/.test(cmd))bad.push('negative branch lost its 207 warning');if(!/Number\.isInteger\(count\)[\s\S]{0,120}warn\(207/.test(cmd))bad.push('non-integer branch lost its 207 warning');const t=fs.readFileSync('packages/system/src/hotkey/__test__/command.test.ts','utf8');for(const s of ['HotkeyEmitType','HotkeyEmitMode.Throttle','.throttle(','../index','context.key']){if(t.indexOf(s)>=0)bad.push('command.test.ts still references obsolete design token: '+s)}if(t.indexOf('../command')<0)bad.push('command.test.ts must import from ../command');if(t.indexOf('setKeys(0)')<0)bad.push('command.test.ts must cover setKeys(0)');if(!/toHaveBeenCalledWith\(203, 'save'\)/.test(t))bad.push('command.test.ts must assert warn 203 with the command id');const lines=t.split(/\r?\n/);let its=0;lines.forEach((l,i)=>{if(/(^|[^A-Za-z.])it\(/.test(l)){its++;let j=i-1;while(j>=0&&lines[j].trim()==='')j--;if(j<0||!lines[j].trim().startsWith('//'))bad.push('command.test.ts:'+(i+1)+' it() without a preceding comment line')}});if(its<12)bad.push('command.test.ts has only '+its+' it() cases, expected at least 12');if(bad.length){console.error('COMMAND GATE FAILED :: '+bad.join(' ; '));process.exit(1)}console.log('OK command gate: setKeys(0) legal, '+its+' command cases')"</automated>
<fails_when>non-zero exit,或 stderr 含 "COMMAND GATE FAILED":`setKeys` 仍拒绝 0 / 207 分支丢失 / 测试含旧设计 token / 未从 `../command` 导入 / 缺少 `setKeys(0)` 或 203 断言 / 某 `it` 前无注释 / 用例少于 12 条</fails_when>
<automated>node -e "const cp=require('child_process');const r=cp.spawnSync('git',['status','--porcelain','--','packages/common/src/logger.json','packages/system/package.json','pnpm-lock.yaml','packages/system/src/hotkey/type.ts','packages/system/src/hotkey/keyCodes.ts','packages/system/src/hotkey/index.ts','packages/system/src/hotkey/hotkey.ts','packages/system/src/hotkey/keyboard.ts'],{encoding:'utf8'});const out=(r.stdout ? r.stdout : '').trim();if(r.status!==0){console.error('LEGACY GATE FAILED :: git status did not complete, status='+r.status);process.exit(1)}if(out.length>0){console.error('LEGACY GATE FAILED :: dirty='+out);process.exit(1)}console.log('OK protected files unchanged vs HEAD')"</automated>
<fails_when>non-zero exit,或 stderr 含 "LEGACY GATE FAILED":受保护文件(`logger.json` / 依赖文件 / `type.ts` / `keyCodes.ts` / `index.ts` / 旧实现)在工作树中被改动</fails_when>
</verify>
<done>`setKeys(0)` 合法(无 207、`length = 0`)、负数与非整数仍发 207;`command.test.ts` 已按新设计整体重写为至少 12 条用例并**全绿**(构造缺省与拷贝、`isAuxiliaryKey`、206 三态、203 与模式守卫、模式方法三参、组合读取与迭代);两个测试文件同时通过;eslint、`COMMAND GATE`、`LEGACY GATE` 全部通过</done>
</task>
<task type="auto" tdd="true">
<name>Task 3: 让 `when` 条件真正生效、修正 `excited` 首次触发比较符,并追加条件 / 时序 / `excitedBy` 用例与 SUMMARY</name>
<precondition>Task 1、Task 2 已通过(两个测试文件全绿,修正 1 / 3 / 5 已落地)。`system.ts` 尚无 `isActive`、`scope.condition` 仍只写不读、`excited` 仍是 `dt < command.timeout`。</precondition>
<files>packages/system/src/hotkey/system.ts, packages/system/src/hotkey/__test__/system.test.ts, .planning/phases/09-minimum-browser-runnable/09-24-SUMMARY.md</files>
<read_first>
- packages/system/src/hotkey/system.ts(Task 1 之后的版本:`getScope` / `enterScope` / `leaveScope` / `when` / `implement` / `emit` / `findMatchKeys` / `addPending` / `down` / `up` / `excited` / `excitedBy`)
- packages/system/src/hotkey/type.ts(`when` 的 jsDoc「当函数返回 `true` 时才可能触发按键指令」、`excitedBy`、`IHotkeyEmitContext.holding` 的时间线说明)
- packages/animate/src/types.ts(`IExcitation<number>` / `IExcitable<number>` / `IExcitableController` 的 `add` 可能返回 `null`)
- packages/system/src/hotkey/keyCodes.ts(`KeyCode.KeyA` / `KeyS` / `Digit1` / `Ctrl` / `Meta`)
- .planning/phases/09-minimum-browser-runnable/09-24-PLAN.md 的 `must_haves`(修正 2 / 4 的口径、按用户裁定保持现状的六项、OI-3)
- .planning/phases/09-minimum-browser-runnable/09-23-SUMMARY.md(SUMMARY 的写法与边界声明)
- AGENTS.md(测试 `it` 前一行注释、既有注释不得修改)、dev.md(CRLF、私有方法置于调用者之前)
</read_first>
<action>
**本任务只落地修正 2 与修正 4,并只追加条件 / 时序 / `excitedBy` 用例;不改 Task 1 已通过的任何断言与实现分支。**
(1) **新增私有方法 `isActive()`(OI-1 命名)**:放在 `emit` 之前(私有方法置于调用者之前,dev.md)。实现为三段:取 `this.getScope()`;`if (!scope) return false;`;`return scope.condition ? scope.condition() : true;`。只写一行 jsDoc 说明「当前栈顶作用域是否存在且其条件是否满足」。
(2) **`emit` 闸口(修正 2 ③)**:在 `emit` 内、既有 `if (!scope) return;` 之后、`const impl = scope.implements.get(command);` 之前插入 `if (!this.isActive()) return;`。这样 `addPending` 的立即触发、`up` 的 KeyUp 触发、`excited` 的 `waitTimeout` 与 `holdingCmds` 触发全部受同一闸口约束。
(3) **`down` 闸口(修正 2 ①)**:把 `down` 内 `for (const command of this.commands.values()) { ... }` 整个循环包进 `if (this.isActive()) { ... }`,位置在 `holding.push` / `holdingSet.add` 之后、末尾的 `return this.holdingCmds.size > 0 || this.waitTimeout.size > 0;` 之前。条件不满足时不建立任何待触发项、不做 `findMatchKeys`、不占用 block。既有的「键已在 `holding` 中则直接返回待触发项布尔值」早退分支不动。
(4) **`up` 闸口(修正 2 ②)**:在既有清理循环(`for (const [command, pending] of new Map(this.holdingCmds)) { ... }`)之后、`toEmit.sort((a, b) => b.command.priority - a.command.priority);` 之前插入 `if (!this.isActive()) return this.holdingCmds.size > 0 || this.waitTimeout.size > 0;`。条件不满足时不排序、不算 block、不调 `emit`、不把 KeyUp 项转投 `waitTimeout`(`toEmit` 中已从 `holdingCmds` 删除的项就此丢弃)。
(5) **`excited` 修正 4**:把 `if (pending.emitted === 0 && dt < command.timeout)` 改为 `if (pending.emitted === 0 && dt >= command.timeout)`。`waitTimeout` 循环、`Continuous` 的 `emitted === 1` → `interval` / 否则 `dt` 分支、以及 `pending.lastEmit` 的写法全部不动(OI-3 未获确认,不得顺手改)。
(6) **`system.test.ts` 追加用例(每个 `it` 前一行 `//` 注释,插在 `listen` 用例之后、`describe` 结尾之前):**
- `when` 条件为假时不触发:`Once` / `[KeyA]`,`let ok = false`,`when(() => ok)`;`down(KeyA)` 与 `up(KeyA)` 都返回 `false`、handler 0 次。
- 条件恢复后重新按下可触发:接着上面的状态把 `ok` 置 `true`,再次 `down(KeyA)` / `up(KeyA)` → handler 1 次(证明条件在派发时刻求值,未建立的待触发项不会补发)。
- 条件为假时不占用 KeyUp 批次:两个 `[KeyA]` KeyUp 指令(一个 `block: true` 优先级 10、一个优先级 5),条件为假时两者都不触发(handler 各 0 次);条件为真时两键流程按优先级各触发 1 次。
- `Once + timeout` 未到期不触发、到期触发一次、之后不再触发:`once` 同 `timeout: 100`;`down(KeyA)` → 0;`excite(99)` → 0;`excite(100)` → 1;`excite(150)` → 1(修正 4 的直接回归)。
- `Continuous + timeout 0` 的 `interval` / `dt`:`mode: Continuous`、`timeout: 0`、`interval: 50`、`dt: 200`;`down(KeyA)` → 1;`excite(40)` → 1;`excite(50)` → 2;`excite(100)` → 2;`excite(250)` → 3。
- `Continuous` 在 `interval` / `dt` 都为 0 时每次激励都触发:`down` → 1;`excite(10)` → 2;`excite(20)` → 3。
- 松键后 `Continuous` 停止:`down` → 1、`up` 后 `excite(10)` → 仍 1。
- 未触发的 `Once` 松键后转入待触发并在 `excite(200)` 触发一次:`once` 同 `timeout: 100`;`down(KeyA)`、`up(KeyA)` → 0;`excite(200)` → 1;`excite(300)` → 仍 1(当前实现:松键不是取消,而是转入 `waitTimeout`;注释写明)。
- KeyUp 延时项的 `context` 是匹配时刻快照:`keyUp` 同 `timeout: 100`;`down(KeyS)`、`up(KeyS)` → 0;`excite(200)` → 1,且 `info.context.holding` 等于 `[KeyS]`(含被抬起的主键,注释注明按当前实现)。
- 延时触发到期时条件为假 → 吞掉且不补发:`keyUp` 同 `timeout: 100`、`when(() => ok)`(初始 `true`);`down(KeyS)`、`up(KeyS)`;`ok = false`;`excite(200)` → 0;`ok = true`;`excite(300)` → 仍 0。
- `excitedBy` 单源切换:`Continuous` / `timeout: 0`;绑定 first → `down` → 1;切换 second;`first.excite(10)` → 仍 1;`second.excite(10)` → 2。
- `excitedBy(null)`:绑定后置 `null`,原激励源 `excite(10)` 不再驱动(计数不变),且 `down` / `up` 仍可正常触发。
- 未绑定激励源时延时指令永不触发:`once` 同 `timeout: 100`、`down(KeyA)` → 0(不调用任何 `excite`)。
- `info.command` 为对象且 `info` 字段完整:`Once` / `[KeyS]`,先 `down(Ctrl)` / `down(Shift)` 再 `down(KeyS)`;断言 `info.command.command === 'fire'`、`info.command.mode === HotkeyEmitMode.Once`、`info.index === 0`、`info.keys` 等于 `[KeyS]`、`info.context.holding` 等于 `[Ctrl, Shift, KeyS]`、`ctrl` / `shift` 为真、`alt` / `meta` 为假。
(7) **假激励源**:`createExcitation()` 返回 `IExcitation<number>`,`payload: () => 0`(`start` / `lastEmit` 落在 0,便于用 `excite(p)` 推时间)、`excite(payload)` 遍历内部 `Set` 调 `excited(payload)`、`add` 返回带 `revoke` 的控制器并记录、`remove` 从集合删除并记录、`destroy` 清空。
(8) **`09-24-SUMMARY.md`**:记录 5 处修正的最终落点(文件 + 位置 + 改动前后写法)、按用户裁定保持现状的六项、两个测试文件的用例清单与旧用例的对应关系、OI-1 / OI-2 的批准情形、OI-3 的提问与用户回复(只有用户确认时才可写成「已确认缺陷」,否则只记录「已提问、按用户指示未修」)、四道闸门的实际结果、边界(未做浏览器 E2E、未接线 `packages-user/**`、未跑全量构建、旧实现与 `type.ts` 零改动)、以及 `estimate.tokens=70000` 为未校准估算(`confidence: low`)的声明。
(9) 改动文件以 CRLF 保存,写完后执行 `cmd /c "pnpm exec prettier --write packages/system/src/hotkey/system.ts packages/system/src/hotkey/__test__/system.test.ts"`。
</action>
<behavior>
- `isActive()`:无作用域 → `false`;`condition === null` → `true`;`condition()` 返回 `false` → 触发路径全部不执行且不占用 block。
- `down` 在条件为假时不建立待触发项(`down` 返回 `false`);条件恢复后重新 `down` 可正常触发一次。
- `up` 在条件为假时不派发、不转投 `waitTimeout`;`emit` 在延时到期但条件为假时被抑制,且该 `waitTimeout` 项按既有代码出队(不补发)。
- `excited`:`timeout: 100` 时 `payload 99` 不触发、`payload 100` 触发;`Continuous` 的第二次按 `interval`、其后按 `dt`。
- `excitedBy` 只保留一个激励源;置 `null` 后旧源不再驱动、系统本身仍可用。
- `info.command` 是对象(`command.command` 为 id、`command.mode` 为模式),`info.keys` 为触发组合,`info.context.holding` 含 KeyDown 触发键。
</behavior>
<verify>
<automated>cmd /c "pnpm exec vitest run packages/system/src/hotkey/__test__/command.test.ts packages/system/src/hotkey/__test__/system.test.ts"</automated>
<fails_when>non-zero exit,或任一用例失败:`when` 条件未生效 / 条件恢复后补发 / `timeout` 边界(99 与 100)/ `interval` / `dt` 的触发次数 / 松键后仍触发 / `excitedBy` 双源同时驱动 / 延时 `context` 不是匹配时刻快照</fails_when>
<automated>cmd /c "pnpm exec eslint packages/system/src/hotkey/system.ts packages/system/src/hotkey/__test__/system.test.ts"</automated>
<fails_when>non-zero exit:存在 eslint 报告</fails_when>
<automated>node -e "const fs=require('fs');const bad=[];const sys=fs.readFileSync('packages/system/src/hotkey/system.ts','utf8');if(!/private isActive\(\): boolean/.test(sys))bad.push('isActive() private method missing');if(!/scope\.condition \? scope\.condition\(\) : true/.test(sys))bad.push('isActive does not evaluate scope.condition()');const calls=(sys.match(/this\.isActive\(\)/g)||[]).length;if(calls<3)bad.push('isActive() is not gated at down/up/emit (found '+calls+')');if(sys.indexOf('dt >= command.timeout')<0)bad.push('excited first-trigger comparison is not dt >= timeout');if(sys.indexOf('dt < command.timeout')>=0)bad.push('old dt < timeout comparison still present');if(/pending\.emitted === 0 && dt >= command\.timeout[\s\S]{0,80}else if/.test(sys))bad.push('OI-3 branch was modified without approval');const t=fs.readFileSync('packages/system/src/hotkey/__test__/system.test.ts','utf8');for(const s of ['HotkeyEmitType','HotkeyEmitMode.Throttle','.throttle(','context.type','context.key','../index']){if(t.indexOf(s)>=0)bad.push('system.test.ts still references obsolete design token: '+s)}for(const s of ['when(','excitedBy(','excite(','timeout']){if(t.indexOf(s)<0)bad.push('system.test.ts lacks coverage for '+s)}if(t.indexOf('private isActive')>=0)bad.push('system.test.ts asserts a private member name it should not depend on');const lines=t.split(/\r?\n/);let its=0;lines.forEach((l,i)=>{if(/(^|[^A-Za-z.])it\(/.test(l)){its++;let j=i-1;while(j>=0&&lines[j].trim()==='')j--;if(j<0||!lines[j].trim().startsWith('//'))bad.push('system.test.ts:'+(i+1)+' it() without a preceding comment line')}});if(its<32)bad.push('system.test.ts has only '+its+' it() cases, expected at least 32 across Task 1 + Task 3');if(!fs.existsSync('.planning/phases/09-minimum-browser-runnable/09-24-SUMMARY.md'))bad.push('09-24-SUMMARY.md missing');if(bad.length){console.error('FINAL GATE FAILED :: '+bad.join(' ; '));process.exit(1)}console.log('OK final gate: condition gating wired at 3 sites, timeout comparison fixed, '+its+' system cases, SUMMARY present')"</automated>
<fails_when>non-zero exit,或 stderr 含 "FINAL GATE FAILED":`isActive` 缺失或未引用 `scope.condition()` / 闸口少于 3 处 / `excited` 比较符未改或旧写法残留 / 未获批准就改了 OI-3 的分支 / 测试仍含旧设计 token / 缺少条件或激励覆盖 / 某 `it` 前无注释 / 用例少于 32 条 / SUMMARY 缺失</fails_when>
<automated>node -e "const cp=require('child_process');const out=cp.spawnSync('cmd',['/c','pnpm exec vue-tsc --noEmit'],{encoding:'utf8',timeout:170000});const text=(out.stdout ? out.stdout : '')+(out.stderr ? out.stderr : '');const bad=[];if(out.error||out.status===null||out.signal)bad.push('vue-tsc did not run to completion: '+((out.error&&out.error.code)||out.signal||'status=null'));if(out.status!==0&&text.trim()==='')bad.push('vue-tsc exited with status '+out.status+' and produced no output - the command did not execute normally');const errs=text.split(/\r?\n/).filter(l=>/packages[\\/]system[\\/]src[\\/]hotkey[\\/](command|system|index|__test__)/.test(l));for(const e of errs)bad.push(e);const legacy=text.split(/\r?\n/).filter(l=>/packages[\\/]system[\\/]src[\\/]hotkey[\\/](hotkey|keyboard)\.ts/.test(l));if(legacy.length!==2)bad.push('expected exactly 2 known-baseline legacy diagnostics in hotkey.ts/keyboard.ts, got '+legacy.length+' - vue-tsc output is not the known baseline (false green?)');if(bad.length){console.error('TYPECHECK FAILED :: '+bad.join(' ; '));process.exit(1)}console.log('OK hotkey implementation and tests type-check clean (vue-tsc exit '+out.status+'); 2 pre-existing legacy errors are out of scope')"</automated>
<fails_when>non-zero exit,或 stderr 含 "TYPECHECK FAILED":`vue-tsc` 没能正常跑完(`out.error` / `out.status === null` / 被信号终止)、非零退出且无任何输出、本计划文件(`command.ts` / `system.ts` / `index.ts` / `__test__/**`)存在类型错误,或 `hotkey.ts` / `keyboard.ts` 的 legacy 诊断数不等于 2(输出不是已知基线)</fails_when>
<automated>node -e "const fs=require('fs'),cp=require('child_process');const bad=[];const plan=fs.readFileSync('.planning/phases/09-minimum-browser-runnable/09-24-PLAN.md','utf8');const start=plan.indexOf('files_modified:');const end=plan.indexOf('autonomous:');if(start<0||end<0)bad.push('cannot read files_modified from the plan');const allow=start>=0&&end>start?plan.slice(start,end).split(/\r?\n/).slice(1).map(s=>s.replace(/^\s*-\s*/,'').trim()).filter(Boolean):[];if(allow.indexOf('packages/system/src/hotkey/system.ts')<0)bad.push('allowlist derived from files_modified is wrong');if(allow.indexOf('packages/system/src/hotkey/command.ts')<0)bad.push('allowlist derived from files_modified is wrong');const wipAny=['.planning/','docs/'];const g=cp.spawnSync('git',['status','--porcelain'],{encoding:'utf8'});if(g.status!==0)bad.push('git status failed: status='+g.status);const lines=(g.stdout ? g.stdout : '').split(/\r?\n/).filter(Boolean);for(const l of lines){const p=l.slice(3).trim();if(allow.some(a=>[p===a,a.startsWith(p),p.startsWith(a)].some(Boolean)))continue;if(wipAny.some(a=>p.startsWith(a)))continue;bad.push('out-of-scope change: '+p)}for(const p of ['packages/system/src/hotkey/type.ts','packages/system/src/hotkey/hotkey.ts','packages/system/src/hotkey/keyboard.ts','packages/system/src/hotkey/keyCodes.ts','packages/system/src/hotkey/index.ts','packages/common/src/logger.json','packages/system/package.json','pnpm-lock.yaml']){if(lines.some(l=>l.slice(3).trim()===p))bad.push('must not be modified: '+p)}if(bad.length){console.error('SCOPE GATE FAILED :: '+bad.join(' ; '));process.exit(1)}console.log('OK scope gate: every change matches files_modified ('+allow.length+' entries)')"</automated>
<fails_when>non-zero exit,或 stderr 含 "SCOPE GATE FAILED":工作树出现 `files_modified` 之外的改动(白名单由本计划派生;`docs/` 与 `.planning/` 为流程 / 文档产物例外),或受保护文件(`type.ts` / 旧实现 / `keyCodes.ts` / `index.ts` / `logger.json` / 依赖文件 / lockfile)被修改</fails_when>
</verify>
<done>`when` 条件在 `down` / `up` / `emit` 三处真实生效(条件为假时不建立待触发项、不算 block、不调实现;延时到期被抑制且不补发);`excited` 首次触发改为 `dt >= command.timeout`(99 不触发 / 100 触发);`Continuous` 的 `interval` / `dt` 与 `Once` 的 `timeout` 行为被用例锁定;`excitedBy` 单源切换与 `null` 被覆盖;`system.test.ts` 总计至少 32 条用例全绿;eslint、`FINAL GATE`、类型闸门(本计划文件 0 条诊断、legacy 恰 2 条)、范围闸门全部通过;`09-24-SUMMARY.md` 已生成并如实记录修正落点、保持现状的六项、OI-3 的裁定情形与边界</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| 浏览器事件 → 系统 | `keydown` / `keyup` 的 `keyCode` 经 `EVENT_KEY_CODE_MAP` 与本地 `91` / `92` / `224` 映射后进入 `down` / `up`(本计划只用 fake 事件验证) |
| 作用域 → 实现 | 只有栈顶作用域的实现与条件参与判定;`when` 条件是「是否允许触发」的闸门 |
| 激励源 → 系统 | `excited(payload)` 的 `payload` 决定全部时序;外部激励源可推进任意时间步 |
| 调用方 → 指令对象 | 调用方通过 `setKeys` / `setKeyCombination` / 模式方法改写指令状态 |
| 写入边界 | 本增量是写操作,只允许写 `files_modified` 白名单(5 个文件),无依赖 / lockfile 变更 |
**威胁级别说明(security_enforcement = ASVS L1,block on high):** 本计划是**纯内存按键系统的缺陷修正 + 测试重写**,不执行第三方代码、不引入网络面、不新增依赖、不改序列化与存档,**不存在未缓解的 `high` 级威胁**。`T-09-24-SC` 标注为「无安装动作」,因此不适用安装类供应链闸门。
## STRIDE Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|-----------|----------|-----------|----------|-------------|-----------------|
| T-09-24-01 | Elevation of privilege | `when` 条件(`isActive` 在 `down` / `up` / `emit` 的闸口) | high | mitigate | 三处闸口缺一不可:`down` 不建 pending、`up` 不算 block 不派发、`emit` 兜住延时路径;用例覆盖「条件为假不触发」「条件恢复后重新按下才触发」「延时到期条件为假 → 吞掉不补发」「条件为假时 block 指令不占用优先级」 |
| T-09-24-02 | Tampering | `matchKeyUp` 的辅助键 / 常规键拆分 | medium | mitigate | 解构顺序由 `matchArray` 的 `[match, unmatch]` 契约决定,改为 `[auxiliary, main]`;用例覆盖四条 KeyUp 规则与 203;结构闸门同时断言新写法存在、旧写法不存在 |
| T-09-24-03 | Denial of service | 无参 `leaveScope` 误发 205 后继续执行 | medium | mitigate | 无参分支 `pop` + 两次 `clear` 后立即 `return;`;用例断言「无参退出不告警、只弹一级」,空栈仍 205;结构闸门断言 `pop()` 后 160 字符内出现 `return;` |
| T-09-24-04 | Tampering | 权威设计与旧实现被误改 | high | mitigate | `type.ts` / `hotkey.ts` / `keyboard.ts` / `keyCodes.ts` / `index.ts` / `logger.json` / 依赖文件 / lockfile 全部由 `git status --porcelain` 零改动闸门守护;范围闸门由 `files_modified` 派生白名单 |
| T-09-24-05 | Repudiation | 时序判定被改错或超范围改动 | medium | mitigate | 只改 `dt < timeout` → `dt >= timeout` 一个比较符;`FINAL GATE` 同时断言新写法存在、旧写法不存在、OI-3 的 `Continuous` 分支未被改动;用例锁定 99 / 100 / 150 的触发次数 |
| T-09-24-06 | Information disclosure | 无网络 / 无存储 / 无敏感日志 | low | accept | 系统只在内存中保存按键状态与指令 id;`logger.warn` 只输出指令 id;本计划不改 `logger.json` |
| T-09-24-SC | Tampering | 依赖安装(npm/pip/cargo) | high | accept | 本计划**不执行任何安装**、不改 `package.json` / lockfile;`LEGACY GATE` 对两者做零改动断言,故无供应链面 |
</threat_model>
<verification>
- **Task 0**:`PRESTATE` 闸门通过(5 处缺陷仍在、旧测试仍是旧设计、warn 201–207 齐全);用户明确回复「可以执行」(含 OI-1 命名与 OI-3 处置的裁定)。
- **Task 1**:`system.test.ts` 全绿;eslint;`TRACER GATE`(`matchKeyUp` 解构正确且旧写法消失、无参 `leaveScope` 有 `return`、测试无旧设计 token、只从 `../system` 导入、未提前写激励用例、`it` 前注释、≥18 条);`LEGACY GATE`。
- **Task 2**:两个测试文件全绿;eslint;`COMMAND GATE`(`setKeys` 只拦负数、207 两分支保留、测试无旧设计 token、含 `setKeys(0)` 与 203 断言、`it` 前注释、≥12 条);`LEGACY GATE`。
- **Task 3**:两个测试文件全绿(`system.test.ts` ≥ 32 条);eslint;`FINAL GATE`(`isActive` 三处闸口 + `scope.condition()` 读、`dt >= timeout`、OI-3 分支未改、测试无旧设计 token、含 `when` / `excite` / `excitedBy` / `timeout` 覆盖、SUMMARY 存在);类型闸门(`vue-tsc --noEmit`,先校验 `spawnSync` 的 `out.error` / `out.status` / `out.signal`;本计划文件 0 条诊断;`hotkey.ts` / `keyboard.ts` 恰 2 条 legacy 作为输出完整的基线证据);`SCOPE GATE`(白名单由 `files_modified` 派生)。
- **范围诚实**:不跑 `build:game` / `build:packages`,不修 `hotkey.ts` / `keyboard.ts` 的 2 条 legacy 类型错误,不接线 `packages-user/**`,不做浏览器 E2E;SUMMARY 须如实记录这些边界,不得表述为「浏览器端已验证」;**`estimate.tokens=70000` 是未校准估算(`confidence: low`),SUMMARY 须显式声明**。
- **既有注释**:`system.ts` / `command.ts` / `type.ts` / `hotkey.ts` / `keyboard.ts` / `keyCodes.ts` 的既有注释与 jsDoc 零修改(`matchKeyUp` 的「抬起按键仅允许一个非辅助按键」在修正后依然成立)。
</verification>
<success_criteria>
- `system.ts`:`matchKeyUp` 改为 `const [auxiliary, main] = matchArray(...)`,其余逻辑与注释不变;无参 `leaveScope` 在 `pop` + 两次 `clear` 后 `return;`;新增私有 `isActive()` 并在 `down` / `up` / `emit` 三处设闸;`excited` 首次触发改为 `dt >= command.timeout`。
- `command.ts`:`setKeys` 的守卫改为 `count < 0`,`count === 0` 合法(`length = 0`、无 207),非整数 / 负数仍发 207,`logger.json` 文案不改。
- `system.test.ts`:按重写后的设计整体重写并覆盖注册表 / 作用域 / KeyUp 四条规则 / holding 与子集 / 优先级与 block / `when` 条件 / `excited` 的 timeout·interval·dt / `excitedBy` / `listen` / 返回值,≥ 32 条用例全绿,每个 `it` 前一行注释,只从 `../system` 导入。
- `command.test.ts`:按新设计整体重写并覆盖构造缺省与拷贝 / `isAuxiliaryKey` / `setKeys` 0·负·非整数 / 206 三态 / 203 与模式守卫 / 读取与迭代 / 模式方法三参,≥ 12 条用例全绿,只从 `../command` 导入。
- 按用户裁定保持现状的六项(`down` / `up` 返回值、KeyUp 的 `context` 快照、`priority` / `block` 仅抬起生效、`waitTimeout` 计时起点、未实现指令的待触发项、`excited` 分支结构)未被改动,测试按现状断言并注明口径。
- 受保护文件(`type.ts` / 旧实现 / `keyCodes.ts` / `index.ts` / `logger.json` / `package.json` / lockfile)零改动;OI-3 未被当作已确认缺陷、未被顺手修复。
- `09-24-SUMMARY.md` 记录修正落点、保持现状的六项、测试清单与旧用例对应关系、OI-1 / OI-2 / OI-3 的裁定情形、四道闸门结果、边界,并声明 `estimate` 为未校准估算。
</success_criteria>
<output>
Create `.planning/phases/09-minimum-browser-runnable/09-24-SUMMARY.md` when done
</output>