docs(09-24): evaluate when at match time on the pending (rev 3)

This commit is contained in:
unanmed 2026-10-23 14:21:06 +08:00
parent b79fc54340
commit 622b48e0bc

View File

@ -22,30 +22,30 @@ 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 待批准):取栈顶作用域,**无作用域返回 `true`**(私有 `emit` 在无作用域时本就 `return`,这样三处闸口不会改变 `down` / `up` 的返回值),`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` 中的项被抑制时保留,条件恢复后按自身规则继续。"
- "**修正 2 —— `when` 在匹配时刻求值并随 pending 保存(`system.ts`;本轮按用户裁定整体替换先前「私有判定方法 + 三处闸口」的方案)**:`IHotkeyCommandPending` 新增成员 `matchedWhen: boolean`(OI-1 待批准;备选 `whenMatched` / `active` / `conditionMet`),含义是「该 pending 的按键组合被匹配那一刻 `when` 的求值结果」,声明位置紧跟 `context` 之后(二者在同一时机捕获)。求值表达式统一为 `this.getScope()?.condition?.() ?? true`:无作用域、`condition` 为 `null` 或缺失一律视为 `true`。求值落点**严格等于现有的三处 `createContext()` 捕获点**:① `addPending` 构造 pending 的字段列表;② `down` 中既有 pending 被新按键再次匹配的分支(`pending.context = this.createContext();` 旁);③ `up` 中非 KeyUp 指令因抬起换到另一组匹配组合的分支(`pending.context = this.createContext();` 旁)。条件为假**不阻止**建立 / 更新 `holdingCmds`(`down` 不设任何条件闸口):pending 必须照常建立,因为 `up` 依赖它做 KeyUp 匹配与 KeyDown 的 `waitTimeout` 转移,且后续新按键匹配时要在原 pending 上重新求值。触发闸口只有两处:① 私有 `emit` 第一行 `if (!pending.matchedWhen) return;`(立即触发、KeyUp 触发、`waitTimeout` 到期、`holdingCmds` 的 `Continuous` 触发全部经此一处;被拦下时不 `emitted++`、不写 `lastEmit`);② `up` 的 `toEmit` 循环里,在 `if (pending.command.priority < blocking) break;` 之后、`if (pending.command.block) blocking = pending.command.priority;` 之前插入 `if (!pending.matchedWhen) continue;`,使条件为假的指令既不触发也不抬高 `blocking`(`block` / 优先级只统计「真的会触发」的指令),且 `break` 的判定顺序不变(仍是严格更低才终止)。模式口径:**KeyUp 模式**在 `up` 中不重新求值(`matchKeyUp` 与 `toEmit` 收集都不调用 `condition()`),只用匹配时刻捕获的值;**KeyDown 模式(`Once` / `Continuous`)**在按下时条件为假则不触发,之后只有在**新按键被匹配**(落点 ② / ③)时才重新求值并可能触发,没有新匹配就永不触发(该键已在 `holdingSet` 中时 `down` 的早退分支不求值)。延时路径:`matchedWhen` 为假时 `emit` 被跳过,`waitTimeout` 项按既有代码出队(**不补发**);KeyUp 批次中条件为假的项在 `toEmit` 循环里被 `continue` 跳过、**不会**进入 `waitTimeout`。不新增任何私有方法,求值表达式内联在三处捕获点。"
- "**修正 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`。修正后(**仅对 `Once + timeout > 0` 与 `Continuous + timeout === 0` 成立**):按下类指令在 `payload - start` 达到 `timeout` 的那一次激励才首次触发,未到期不触发。**`Continuous + timeout > 0` 不在本节承诺范围内**:该组合的首次触发之后会在同一次激励里继续走 `Continuous` 分支,时序仍按现状,按 **OI-3**(未裁定)记录,本计划不改、不写断言。只改这一处比较符,`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`(反映调用后仍存在的待触发项,而非「是否真的触发过实现」);② `context` **不是整体快照**:`createContext()` 把 `holding` 写成 `this.holding` 的**活引用**,只有 `ctrl` / `shift` / `alt` / `meta` 四个布尔量是匹配时刻的快照;又因为 `up` 在 `emit` 之前就 `this.holding.splice(index, 1)`,所以 KeyUp 触发时 `info.context.holding` **不含**被抬起的主键(`[Ctrl, KeyS]` 抬 S → `[Ctrl]`;`[KeyS]` 抬 S → `[]`),与 `type.ts` 的 jsDoc 一致;延时触发读取的也是同一个活数组,断言只能写派发时刻的实际内容;`createContext()` 本身不得修改;③ `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.holding` 活引用口径」用例。**不得**在 Task 1 提前写入依赖未修正代码的断言。"
- "**system.test.ts 的三段交付边界(避免半成品红灯)**:Task 1 只重写「注册表 / 作用域 / KeyUp 四条规则 / holding 与子集匹配 / 优先级与 block / `listen` / `down`·`up` 返回值」这些**不依赖修正 2、4** 的用例(该文件在 Task 1 结束时必须全绿);Task 3 再追加两组用例:① `when` 在匹配时刻生效的 10 条(KeyDown 条件为假仍建立 pending、无新匹配则永不触发、新匹配重新求值后由激励触发、松键重按不补发;KeyUp 抬起不重新求值、新匹配后抬起触发、带 `timeout` 时假值不补发 / 真值按捕获值触发;`block` 只统计会触发的指令;`up` 换组合重新求值);② `excited` 驱动的 timeout·interval·dt、`excitedBy` 单源切换与 null、延时触发的 `context.holding` 活引用口径。**不得**在 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()`,无作用域返回 `true`,并在 `down` / `up` / `emit` 三处设闸)、修正 3(无参 `leaveScope` 在 `pop` + `clear` 后 `return;`)、修正 4(`dt >= command.timeout`);除此之外不改任何既有逻辑、注释与 jsDoc"
provides: "修正 1(`matchKeyUp` 解构改为 `const [auxiliary, main]`)、修正 2(`IHotkeyCommandPending` 新增 `matchedWhen`,在与 `createContext()` 相同的三处匹配时刻用 `this.getScope()?.condition?.() ?? true` 求值,并在 `emit` 与 `up` 的 block 循环各设一处闸口;不新增私有方法、`down` / `up` 不设条件闸口)、修正 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 与空组合;`context.holding` 按活引用口径断言 `[Ctrl]` / `[]`)、holding 顺序与去重、KeyDown 子集匹配、优先级降序 / 严格更低阻断 / 同优先级全触发、`down`·`up` 返回值(按当前实现)、`listen` 的映射(65→`KeyA`、91·92·224→`Meta`、无映射跳过)、重复按下不重复触发、`preventDefault`、`listen(null)` 解绑、同元素不重复注册;每个 `it` 前一行注释"
provides: "按重写后设计重写的系统单测:注册表(204 / 重复返回同一对象 / `getCommand` / `deleteCommand` 清理待触发项 / `iterateCommands`)、作用域(`enterScope` 返回 symbol、无参 `leaveScope` 只弹一级且**不再误发 205**、空栈 205、`leaveScope(sym)` 保留命中作用域、未知 sym 205、无作用域时 201、未知指令 202)、`when` 在匹配时刻求值的 10 条用例(详见 Task 3)、KeyUp 四条规则(含 203 与空组合;`context.holding` 按活引用口径断言 `[Ctrl]` / `[]`)、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` 是延时触发的唯一兜底闸口;`isActive()` 在无作用域时返回 `true`,否则 `down` / `up` 的返回值会被闸口改写;另注意 `when` 是**作用域级**条件(`scope.condition` 由同一作用域内所有指令共享),它无法只抑制某一条指令,测试不得把「只有 block 指令被抑制」写成预期"
- "`scope.condition`(`when` 写入)↔ 三处 `createContext()` 捕获点 ↔ `IHotkeyCommandPending.matchedWhen` ↔ `emit` / `up` 的 block 循环 —— 条件值必须**随 pending 在匹配时刻保存**,不能在触发时刻现算:`down` 无条件建立 pending(`up` 依赖它),`emit` 用捕获值拦截全部触发路径,`block` / 优先级只统计捕获值为真的指令;另注意 `when` 是**作用域级**条件(`scope.condition` 由同一作用域内所有指令共享),同一次匹配里各指令通常拿到同一个返回值,它无法只抑制某一条指令(测试用顺序化条件函数刻意区分同一次 `down` 内两条指令的捕获值,属测试手段而非使用方式)"
- "`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"
@ -54,22 +54,25 @@ must_haves:
- "测试文件的导入路径 ↔ `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 口径写成)。"
- "**当前代码与测试基线(已实测,HEAD `b79fc54`、代码基线 `3fd339f`,工作树干净)**:`system.ts` 含 `const [main, auxiliary] = matchArray(...)`、无参 `leaveScope` 无 `return`、`dt < command.timeout`、`scope.condition` 只写不读;`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` 的存储方式。"
- "**条件求值次数与顺序(已按实现路径核对)**:`when` 只在三处捕获点各调用一次 —— `addPending` 一次、`down` 中既有 pending 被新按键再次匹配时一次、`up` 中换到另一组匹配组合时一次;`down` 按 `commands` 的插入顺序遍历指令,所以同一个 `down` 里多条指令的条件按注册顺序依次求值。`when` 的约定是纯判定函数(`type.ts` 的 jsDoc 只要求返回布尔),同一匹配内多次求值不视为问题;本计划不改 `when` 的存储方式(仍是作用域级 `scope.condition`)。测试用**顺序化返回**的条件函数区分同一次 `down` 内两条指令的捕获值,用例须注明依赖该求值顺序。"
- "**KeyDown 模式下「新匹配后可以触发」依赖激励源(已按实现核对)**:条件为假时 `addPending` 的立即 `emit` 被跳过、`pending.emitted` 仍为 0,因此重新求值后要真正触发必须有激励(`excited(payload)`)。`Once + timeout === 0` 走 `holdingCmds` 循环里的 `pending.emitted === 0 && dt >= command.timeout(0)` 分支,恰好触发一次;相关用例必须绑定假激励源并用 `excite(p)` 推进,不绑定激励源时不会自行触发。**不要**把「新匹配后触发」写成 `down` 的同步副作用 —— `down` 的既有 pending 分支只更新状态(`keyIndex` / `keys` / `context` / `matchedWhen`),不 `emit`。**因此「新匹配后重新求值」的用例统一用 `Once` 断言**:`Continuous` 在 `emitted` 由 0 变 1 的同一次激励里还会继续走 `emitted === 1` / `else` 的 `interval` / `dt` 分支(与 OI-3 同一段既有结构),可能在同一激励里触发两次,不在本计划断言范围内。"
- "**条件为假的 KeyUp 批次被丢弃而非延后(已按实现核对)**:`up` 的 `toEmit` 循环里 `if (!pending.matchedWhen) continue;` 位于 `pending.command.timeout === 0 ? emit : waitTimeout.add` 之前,因此条件为假的 KeyUp 项既不触发、也**不进入 `waitTimeout`**,之后 `excite()` 不会补发。反之,匹配时条件为真、到期前条件转假的 KeyUp 项仍按捕获值触发一次(触发路径不重新求值)。"
- "**`context.holding` 是活引用,只有修饰键布尔量是匹配时刻快照(已核验实现)**:`createContext()`(`system.ts:110-118`)返回 `{ holding: this.holding, ctrl: this.holdingSet.has(KeyCode.Ctrl), ... }`,`holding` 直接指向系统内部数组;`up()` 在 `holdingSet.delete` 之后、构造 `toEmit` 与 `emit` 之前执行 `this.holding.splice(index, 1)`。因此 KeyUp 触发时 `info.context.holding` **不含**被抬起的主键:`[Ctrl, KeyS]` 抬 S → `[Ctrl]`;`[KeyS]` 抬 S → `[]`。`keyUp(timeout)` 的延时项读取的仍是同一个活数组,所以延时到期的断言同样按派发时刻的内容写(`[]`)。**不得改 `createContext`**(`holding: this.holding` 逐字保留)。"
- "**`down` / `up` 返回值按当前实现**:KeyUp 在 `up` 内立即触发后 `holdingCmds` 已删除该指令、`waitTimeout` 为空 → `up` 返回 `false`;而 `down` 建立起待触发项 → 返回 `true`。测试按此断言并注明「返回值反映调用后仍存在的待触发项」。`isActive()` 在无作用域时返回 `true`,且私有 `emit` 本就无作用域即 `return`,故新增闸口不改变这两种返回值。"
- "**`down` / `up` 返回值按当前实现**:KeyUp 在 `up` 内立即触发后 `holdingCmds` 已删除该指令、`waitTimeout` 为空 → `up` 返回 `false`;而 `down` 建立起待触发项 → 返回 `true`。测试按此断言并注明「返回值反映调用后仍存在的待触发项」。本轮**不在 `down` / `up` 上设任何条件闸口**(条件随 pending 保存,只在 `emit` 与 block 循环生效),因此这两种返回值与修正前逐字一致;条件为假时 `down` 仍返回 `true`(pending 已建立),这也是「条件为假不阻止跟踪」的观测点。"
- "**Task 3 的 `excited` 断言只覆盖不会踩到 OI-3 的形态**:`Once + timeout` 只走 `pending.emitted === 0` 分支(`Once` 不进 `Continuous` 分支,故 99 不触发 / 100 触发 / 之后不再触发是确定的);`Continuous + timeout === 0` 在 `addPending` 已立即触发(`emitted` 从 1 起算,随后同一次 `excited` 调用里 `dt2 = payload - lastEmit`),因此 `interval` / `dt` 断言不受 OI-3 影响。**`Continuous + timeout > 0` 不在本计划断言范围内**(见 OI-3,未裁定),Task 3 也不得声称已修正该组合。"
- "**本轮修订前基线是已提交的 09-23 实现(INFO)**:`git log` 的 `cfdca2f docs(09-24)` 是纯计划提交,其父提交 `3fd339f feat: 抬起按键的优先级` 即 09-23 交付的实现与测试;本计划 Task 0 的前置校验与 `LEGACY GATE` 都以该已提交内容为基线。本轮修订只改 `09-24-PLAN.md`,未触碰任何代码 / 测试,`git status --porcelain` 对 `system.ts` / `command.ts` / 两个测试文件为空。"
- "**本轮修订前基线是已提交的 09-23 实现(INFO)**:`git log` 中 `cfdca2f` 与 `b79fc54`(`docs(09-24)` 的两版计划)都是纯计划提交,其共同祖先 `3fd339f feat: 抬起按键的优先级` 即 09-23 交付的实现与测试;Task 0 的前置校验与 `LEGACY GATE` 都以该已提交内容为基线。修订 3 只改 `09-24-PLAN.md`,未触碰任何代码 / 测试,`git status --porcelain` 对 `system.ts` / `command.ts` / 两个测试文件为空。"
prohibitions:
- "不得在用户回复通过 Task 0 关卡之前执行任何任务或派发执行子代理。"
- "**不得修正确认清单以外的任何行为**:只能落地 5 处修正;`down` / `up` 返回值、`createContext()` 的 `holding: this.holding` 活引用与四个修饰键布尔量的匹配时刻快照、`priority` / `block` 的生效范围、`waitTimeout` 的计时起点、未实现指令的待触发项、`excited` 的分支结构一律按现状保留。**疑似问题 OI-3 未经用户确认前不得修改,也不得写成已确认缺陷。**"
- "**不得改 `createContext()` / 不得把 `context.holding` 写成快照**:KeyUp 触发时 `holding` 不含被抬起的主键,是因为 `up` 先 `splice` 再 `emit`;把 `createContext` 改成 `holding: [...this.holding]` 属于确认清单外的行为变更,禁止。同理不得为了让断言「好看」而在测试里复制 `holding` 后再断言。"
- "**不得改受保护文件**:`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`)上。"
- "**不得改写既有注释 / jsDoc(AGENTS.md)**:`system.ts` / `command.ts` 的既有注释逐字保留;`matchKeyUp` 的注释「抬起按键仅允许一个非辅助按键」在修正后依然成立,不得改动;新逻辑只允许**新增**注释 —— `IHotkeyCommandPending.matchedWhen` 的一行 jsDoc(说明「按键组合匹配时刻的 `when` 求值结果」),不得改动 `command` / `start` / `keyIndex` / `keys` / `context` / `emitted` / `lastEmit` 的既有成员注释。"
- "**不得在触发时刻现算 `when`,也不得新增私有判定方法或 `down` / `up` 层级的条件闸口**:条件只在三处匹配时刻求值并写入 pending(`condition?.()` 在 `system.ts` 中恰好出现 3 次),`emit` / `up` 的 block 循环只读 `pending.matchedWhen`;`down` 内不得出现任何 `condition` / 条件相关判断(条件为假也必须建立 pending)。"
- "**不得从 `../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` 不动。"
@ -77,7 +80,7 @@ must_haves:
- "**不得跑全量构建或全仓类型修复**:不执行 `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`,判定栈顶作用域是否存在且其条件是否满足;**无作用域返回 `true`**,以保持 `down` / `up` 返回值与闸口引入前一致)。备选:`isConditionMet` / `canEmit` / `isScopeActive`。**未获批前不得落码**;若改名,Task 3 的结构闸门字符串与 `must_haves` 同步修订并重新汇报。"
- "**OI-1(`IHotkeyCommandPending` 新增成员命名,待批准)**:提议 `matchedWhen: boolean`,含义是「该 pending 的按键组合被匹配那一刻 `when` 的求值结果」(`system.ts` 内的模块私有接口成员,不对外导出,也不进入 `type.ts`)。备选:`whenMatched` / `active` / `conditionMet`。**未获批前不得落码**;若改名,三处捕获点、`emit` 闸口、`up` 的 block 循环、`FINAL GATE` 的结构闸门字符串与 `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)` 即触发,且这次触发来自 `Continuous` 的 `else` 分支,**修正 4 的 `dt >= timeout` 闸口管不住它**);同理 `timeout` 到期的同一次激励可能连续触发两次(`interval` 为 0 时)。**这只是向用户的提问,不是已登记缺陷**:本计划不修、不在测试中断言该组合、SUMMARY 不得表述为「timeout 行为已全部修正」;若用户确认要修,先修订本计划(改为 `else if (pending.emitted > 1)` 或等价结构 + 补 `continuous + timeout > 0` 的时序用例)再执行。"
- "**OI-4(口径备注,已按实现核验,不需另批)**:`context` 是「活引用 + 修饰键快照」的混合体 —— `createContext()` 写 `holding: this.holding`(活引用),只有 `ctrl` / `shift` / `alt` / `meta` 是匹配时刻的快照;`up` 在 `emit` 之前 `splice` 掉被抬起的键,故 KeyUp 触发时 `info.context.holding` 为 `[Ctrl]`(`[Ctrl, KeyS]` 抬 S)或 `[]`(`[KeyS]` 抬 S),**不含**被抬起的主键(与 `type.ts` jsDoc 一致)。`up` 在 KeyUp 立即触发后返回 `false`、`down` 返回 `true`(返回值反映仍存在的待触发项)。测试按此断言并在 `it` 注释中写明「holding 是活引用,仅修饰键布尔量是匹配时刻快照」,SUMMARY 如实记录。"
@ -89,19 +92,21 @@ Phase 9 · 第 24 个计划 —— **修正用户重写后按键系统(`comman
**要解决的问题:** 用户在 `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/system.ts`:只改四处 —— `matchKeyUp` 的解构顺序;`leaveScope` 无参分支补 `return;`;`IHotkeyCommandPending` 新增 `matchedWhen` 并在与 `createContext()` 相同的三处匹配时刻求值,`emit` 与 `up` 的 block 循环各设一处闸口(**不新增私有方法**);`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/**`。
**修订 2(本轮,按 plan-checker 的 2 blocker / 3 warning 修订):**
- ① KeyUp 的 `context.holding` 口径改为「**活引用 + 修饰键快照**」:`createContext()` 写 `holding: this.holding`,`up` 先 `splice` 再 `emit`,故 KeyUp 触发时 `holding` 不含被抬起的主键(`[Ctrl, KeyS]` 抬 S → `[Ctrl]`,`[KeyS]` 抬 S → `[]`);`createContext` 不改。
- ② Task 3 的条件 + block 用例重写:`block: true` 优先级 10 会始终阻断优先级 5(`break`),且 `when` 是**作用域级**、无法只抑制其中一条 —— 改为「条件为假 → 两条都 0;条件为真 + 重新按下 → 高优先级 1 次、低优先级 0 次」。
- ③ Task 1 的 import 列表移除 `IExcitable` / `IExcitation`(Task 1 禁止激励相关用例),改在 Task 3 引入 `createExcitation()` 时加回。
- ④ `isActive()` 在无作用域时返回 `true`(私有 `emit` 本就 no-op),使 `down` / `up` 的返回值与闸口引入前完全一致。
- ⑤ 修正 4 的承诺范围限定为 `Once + timeout > 0` 与 `Continuous + timeout === 0`;`Continuous + timeout > 0` 按 **OI-3** 记录为未裁定、保持现状。
- ⑥ SUMMARY 须声明 `estimate` 为未校准估算(`confidence: low`),并注明修订前基线是已提交的 09-23 实现(`3fd339f`)。
**修订 3(本轮,按用户对 `when` 语义的裁定重写修正 2):**
- ① 作废先前「私有判定方法 + `down` / `up` / `emit` 三处闸口」的方案(它在派发时刻求值,与用户裁定不符);改为**匹配时刻求值 + 随 pending 保存**,且不新增任何私有方法。
- ② `IHotkeyCommandPending` 新增 `matchedWhen`(OI-1 待批准),表达式 `this.getScope()?.condition?.() ?? true`(无作用域 / `null` / 缺失一律视为 `true`)落在与 `createContext()` 完全相同的三处捕获点:`addPending`、`down` 的再匹配分支、`up` 的换组合分支。
- ③ 条件为假**不阻止**建立 `holdingCmds`(`up` 依赖它);`down` 不再有任何条件闸口,`down` / `up` 返回值与修正前逐字一致。
- ④ 触发闸口只有两处:`emit` 第一行 `if (!pending.matchedWhen) return;`;`up` 的 block 循环在 `break` 之后、`blocking` 赋值之前 `if (!pending.matchedWhen) continue;`(条件为假既不触发、也不抬高阻断线)。
- ⑤ 模式口径:KeyUp 用匹配时刻的捕获值、`up` 不重新求值;KeyDown(Once / Continuous)按下为假则不触发,只有新按键匹配才重新求值并可能触发(依赖激励源推进)。
- ⑥ 先前「延时到期时条件为假 → 吞掉」的用例**作废**(KeyUp 的捕获值不随条件后续变化);改为「匹配时条件为假 → 抬起不触发、也不进 `waitTimeout`(带 `timeout` 也不补发)」与「匹配时条件为真、到期前转假 → 仍按捕获值触发一次」两条。
- ⑦ 测试新增 10 条 `when` 用例:条件为假仍建立 pending、KeyDown 无新匹配则永不触发、新匹配重新求值后触发、松键重按不补发、KeyUp 抬起不重新求值、KeyUp 新匹配后抬起触发、带 `timeout` 的假值不补发 / 真值按捕获值触发、`block` 只统计会触发的指令(顺序化条件区分同一次 `down` 内的两条指令)、`up` 换组合时重新求值。
- ⑧ 沿用上一版仍然有效的裁定:KeyUp 的 `context.holding` 是「**活引用 + 修饰键快照**」(`[Ctrl, KeyS]` 抬 S → `[Ctrl]`,`[KeyS]` 抬 S → `[]`,`createContext` 不改);Task 1 不导入 `@motajs/animate`、不写激励用例,Task 3 再加回 `createExcitation()`;修正 4 的承诺范围仅 `Once + timeout > 0` 与 `Continuous + timeout === 0`,`Continuous + timeout > 0` 按 **OI-3** 记录为未裁定、保持现状且不写断言;SUMMARY 须声明 `estimate` 为未校准估算(`confidence: low`),并注明修订前基线是已提交的 09-23 实现(`3fd339f`)。
**约束:** 只动 `files_modified` 内的 5 个文件;除 5 处修正外不改任何行为(含 `down` / `up` 返回值、`createContext()` 的 `holding` 活引用与修饰键快照、`priority` / `block` 的生效范围);不把疑似问题 OI-3 当成已确认缺陷;不跑全量构建。
@ -137,7 +142,7 @@ Phase 9 · 第 24 个计划 —— **修正用户重写后按键系统(`comman
<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>
<precondition>`packages/system/src/hotkey/system.ts` 仍含 `const [main, auxiliary] = matchArray(`、无参 `leaveScope` 无 `return`、`dt < command.timeout`,且 `IHotkeyCommandPending` 尚无 `matchedWhen`(`condition?.()` 出现 0 次,`when` 仍只写不读);`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`)
@ -156,7 +161,7 @@ Phase 9 · 第 24 个计划 —— **修正用户重写后按键系统(`comman
② **修正 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 待批准:取栈顶;**无作用域返回 `true`**,因为私有 `emit` 无作用域本就 `return`,这样 `down` / `up` 返回值不变;`condition` 为 `null` 为 `true`;否则取 `condition()`),并在三处设闸:`down` 在 `holding` 入栈后、遍历指令前;`up` 在清理循环后、`toEmit.sort(...)` 与优先级 / block 循环前;`emit` 在取到作用域后、取实现前(延时触发的唯一兜底)。条件不满足时:不建立待触发项、不算 block、不调实现;`waitTimeout` 中的项到期而条件不满足 → 本次吞掉并出队、不补发;`holdingCmds` 中的项保留,条件恢复后按自身规则继续。
③ **修正 2(`system.ts`,`when`):** `scope.condition` 只写不读。改为在**匹配时刻**求值并随 pending 保存:`IHotkeyCommandPending` 新增 `matchedWhen: boolean`(OI-1 待批准),表达式 `this.getScope()?.condition?.() ?? true`(无作用域 / `null` / 缺失 → `true`)落在与 `createContext()` 相同的三处捕获点 —— `addPending` 构造 pending 时、`down` 中既有 pending 被新按键再次匹配时、`up` 中非 KeyUp 指令换到另一组匹配组合时。条件为假**不阻止**建立 `holdingCmds`(`down` 不设条件闸口,pending 必须照常建立,`up` 依赖它)。触发闸口只有两处:`emit` 第一行 `if (!pending.matchedWhen) return;`;`up` 的 `toEmit` 循环在 `priority < blocking` 的 `break` 之后、`block` 赋值之前 `if (!pending.matchedWhen) continue;`(条件为假既不触发、也不抬高阻断线,`block` / 优先级只统计真的会触发的指令)。模式口径:KeyUp 在 `up` 中不重新求值,只用匹配时刻的捕获值;KeyDown(Once / Continuous)按下为假则不触发,只有新按键匹配才重新求值并可能触发,没有新匹配就永不触发。延时的 `waitTimeout` 项到期被跳过时按既有代码出队(不补发);KeyUp 批次中条件为假的项在 `toEmit` 循环里被 `continue` 跳过、不会进入 `waitTimeout`。**不新增私有方法。**
④ **修正 3(`system.ts`,`leaveScope`):** 无参分支在 `this.scopes.pop()` 与两次 `clear()` 之后补 `return;`。当前缺 `return` 会继续 `findIndex(v => v.symbol === undefined)` → 误发 205。空栈无参仍发 205,有参未知仍发 205,有参命中仍 `splice(index + 1)` 保留命中作用域。
@ -168,26 +173,26 @@ Phase 9 · 第 24 个计划 —— **修正用户重写后按键系统(`comman
⑧ **测试重写范围(两个文件,逐 `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 且永不触发、空组合不触发不告警;`[Ctrl, KeyS]` 抬 S 时 `info.context.holding` 等于 `[Ctrl]`、`[Ctrl]` 单辅助键抬 Ctrl 时等于 `[]`,注释写明「holding 是活引用,仅修饰键布尔量是匹配时刻快照」)、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` 为活引用(`up` 先 `splice` 再入 `waitTimeout`,断言 `[]`,不含被抬起的主键)、`excitedBy` 单源切换(切换后旧源不再驱动、新源驱动)与 `excitedBy(null)`、未绑定激励源时延时指令永不触发、`info.command` 为对象且含 `mode` / `command` id。
- `system.test.ts` Task 3 追加 —— `when` 在匹配时刻生效的 10 条:① KeyDown(`Once` / `timeout 0`)按下时条件为假 → 不触发,但 pending 仍建立(`down` 返回 `true`,`excite` 后仍 0);② KeyDown 条件为假且无新匹配 → 永不触发(含松键转入 `waitTimeout` 后 `excite` 仍为 0);③ KeyDown 条件为假按下、随后另一个按键匹配且条件为真 → 重新求值后由激励触发一次(`pending` 成员被更新);④ KeyDown 松键后条件恢复再按下 → 正常触发一次,且旧 `waitTimeout` 项不补发(总数仍 1);⑤ KeyUp 按下时条件为假、抬起时条件已为真但没有新匹配 → 不触发(`up` 不重新求值);⑥ KeyUp 按下为假、随后新按键匹配(条件已为真)→ 抬起触发一次且 `info.context.holding` 为 `[Ctrl]`;⑦ KeyUp 带 `timeout: 100`:匹配时条件为假 → 抬起不触发、`excite(200)` 也不补发(未进入 `waitTimeout`);⑧ KeyUp 带 `timeout: 100`:匹配时条件为真、到期前条件转假 → `excite(200)` 仍按捕获值触发一次;⑨ `block` / 优先级只统计会触发的指令:顺序化条件下优先级 10 + `block: true` 的 `high` 捕获假、优先级 5 的 `low` 捕获真 → 抬起时只有 `low` 触发;⑩ `up` 换组合时重新求值(第三处捕获点):顺序化条件真 / 真 / 假 → `Continuous` 换组合后不再触发。另加:`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` 为活引用(`up` 先 `splice` 再入 `waitTimeout`,断言 `[]`,不含被抬起的主键)、`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 口径备注:`context` 是「活引用 + 修饰键快照」(KeyUp 触发时 `[Ctrl, KeyS]` → `[Ctrl]`、`[KeyS]` → `[]`,不含被抬起的主键,`createContext` 不改;`up` 在 KeyUp 立即触发后返回 `false`,返回值反映仍存在的待触发项)。
⑪ **待批准命名与待裁决问题:** OI-1 `IHotkeyCommandPending` 新增成员名 `matchedWhen`(备选 `whenMatched` / `active` / `conditionMet`;本轮不新增私有方法,故无方法命名待批);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 口径备注:`context` 是「活引用 + 修饰键快照」(KeyUp 触发时 `[Ctrl, KeyS]` → `[Ctrl]`、`[KeyS]` → `[]`,不含被抬起的主键,`createContext` 不改;`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>
<decision>是否按本计划(09-24)执行:在 `packages/system/src/hotkey/system.ts` 修正 `matchKeyUp` 解构顺序、给 `leaveScope` 无参分支补 `return;`、把 `when` 的求值改为**匹配时刻**进行并随 pending 保存(`IHotkeyCommandPending` 新增 `matchedWhen`,三处捕获点与 `createContext()` 同步,`emit` 与 `up` 的 block 循环各设一处闸口,不新增私有方法)、把 `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 变更。
AGENTS.md 要求执行前先汇报并等待「可以执行」、命名先反馈、既有注释不得修改;`.planning/config.json` 为 `mode: yolo` 且 `auto_advance: false`,故本关卡显式 `blocking-human` 且 `autonomous: false`。已核验(HEAD `b79fc54`,代码基线仍是 `3fd339f`,工作树干净):`system.ts` 的 `const [main, auxiliary] = matchArray(...)`、无参 `leaveScope` 无 `return`、`dt < command.timeout`、`scope.condition` 只写不读;`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 行),审阅量较大;`down` / `up` 返回值仍是「是否仍有待触发项」而非 `type.ts` jsDoc 字面所述的「是否触发过」(按用户裁定保留);`Continuous + timeout > 0` 的时序仍留在现状(按 OI-3 记录)且无测试覆盖</cons>
<name>可以执行(按本计划默认口径;OI-1 采用 `matchedWhen`,OI-3 不修)</name>
<pros>一次修掉 5 处已确认缺陷并让两个测试重新成为可信回归基线;只改 4 个代码/测试文件与 1 个 SUMMARY;不引入依赖、不碰权威设计与旧实现,风险面最小;`when` 只在匹配时刻求值一次、`down` / `up` 返回值零变化,改动面比「派发时刻闸口」更小</pros>
<cons>测试整体重写(两个文件约 950–1150 行),审阅量较大;`down` / `up` 返回值仍是「是否仍有待触发项」而非 `type.ts` jsDoc 字面所述的「是否触发过」(按用户裁定保留);KeyDown 条件为假后要「新匹配才可触发」必须由激励源推进,相关用例需绑定假激励源;`Continuous + timeout > 0` 的时序仍留在现状(按 OI-3 记录)且无测试覆盖</cons>
</option>
<option id="revise">
<name>先修订 OI-1 命名 / 确认 OI-3 的处置,再重新汇报</name>
@ -202,18 +207,18 @@ Phase 9 · 第 24 个计划 —— **修正用户重写后按键系统(`comman
</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>
<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');const evals=(sys.match(/this\.getScope\(\)\?\.condition\?\.\(\)\s*\?\?\s*true/g)||[]).length;if(evals!==0||sys.indexOf('matchedWhen')>=0)bad.push('prestate changed: when is already captured on the pending');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`)与时序语义(延时触发到期而条件不满足 → 吞掉并出队、不补发)
- 已逐条说明修正 2 的求值时机与两处闸口(`emit` 第一行、`up` 的 block 循环)以及模式口径(KeyUp 用匹配时刻捕获值、`up` 不重新求值;KeyDown 只有新按键匹配才重新求值并可触发;条件为假不阻止建立 pending;`down` / `up` 返回值不变)
- 已说明 `count === 0` 变合法但 207 文案不改,以及构造器 `defaults: []` 不再误报 207 的附带效果
- 已说明 `down` / `up` 返回值仍按当前实现,且 `context` 是「活引用 + 修饰键快照」:KeyUp 触发时 `holding` 不含被抬起的主键(`[Ctrl, KeyS]` → `[Ctrl]`、`[KeyS]` → `[]`),`createContext` 不改
- 已说明 Task 1 / Task 3 的测试交付边界(Task 1 只写不依赖修正 2、4 的用例并跑绿)
- 已提出 OI-1(`isActive`)与 OI-2(测试 helper)命名,并**以提问方式**提出 OI-3(`continuous + timeout > 0`),明确「未确认为问题、本计划不修、不写进测试」
- 已提出 OI-1(`matchedWhen`)与 OI-2(测试 helper)命名,并**以提问方式**提出 OI-3(`continuous + timeout > 0`),明确「未确认为问题、本计划不修、不写进测试」
- 已说明修改由子代理执行、只写 `files_modified` 的 5 个文件
- 已运行 `<automated>` 前置校验并通过(`OK prestate: ...`)
</acceptance_criteria>
@ -349,11 +354,11 @@ Phase 9 · 第 24 个计划 —— **修正用户重写后按键系统(`comman
</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>
<name>Task 3: 让 `when` 在匹配时刻求值并随 pending 保存、修正 `excited` 首次触发比较符,并追加条件 / 时序 / `excitedBy` 用例与 SUMMARY</name>
<precondition>Task 1、Task 2 已通过(两个测试文件全绿,修正 1 / 3 / 5 已落地)。`system.ts` 的 `IHotkeyCommandPending` 尚无 `matchedWhen`、`scope.condition` 仍只写不读(`condition?.()` 出现 0 次)、`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/system.ts(Task 1 之后的版本:`IHotkeyCommandPending` 接口、`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`)
@ -364,27 +369,33 @@ Phase 9 · 第 24 个计划 —— **修正用户重写后按键系统(`comman
<action>
**本任务只落地修正 2 与修正 4,并只追加条件 / 时序 / `excitedBy` 用例;不改 Task 1 已通过的任何断言与实现分支。**
(1) **新增私有方法 `isActive()`(OI-1 命名)**:放在 `emit` 之前(私有方法置于调用者之前,dev.md)。实现为三段:取 `this.getScope()`;`if (!scope) return true;`(**无作用域返回 `true`** —— 私有 `emit` 在无作用域时本就 `return`,这样 `down` / `up` 的返回值与引入闸口前一致);`return scope.condition ? scope.condition() : true;`。只写一行 jsDoc 说明「当前栈顶作用域是否存在且其条件是否满足」。
(1) **`IHotkeyCommandPending` 新增 `matchedWhen`(OI-1 命名)**:在接口里紧跟 `context` 之后加 `matchedWhen: boolean;`,并新增一行 jsDoc「按键组合匹配时刻的条件判定结果」(既有成员注释逐字不动)。求值表达式统一为 `this.getScope()?.condition?.() ?? true`:无作用域、`condition` 为 `null` 或缺失 → `true`。**不新增私有方法**(表达式内联在三处捕获点,`condition?.()` 在文件中恰好出现 3 次)。
(2) **`emit` 闸口(修正 2 ③)**:在 `emit` 内、既有 `if (!scope) return;` 之后、`const impl = scope.implements.get(command);` 之前插入 `if (!this.isActive()) return;`。这样 `addPending` 的立即触发、`up` 的 KeyUp 触发、`excited` 的 `waitTimeout` 与 `holdingCmds` 触发全部受同一闸口约束。
(2) **三处捕获点(与 `createContext()` 同一次匹配、同一时机)**:① `addPending` 构造 pending 的对象字面量里,紧跟 `context: this.createContext(),` 之后加 `matchedWhen: this.getScope()?.condition?.() ?? true,`;② `down` 中既有 pending 被新按键再次匹配的分支,在 `pending.context = this.createContext();` 之后加 `pending.matchedWhen = this.getScope()?.condition?.() ?? true;`(`keyIndex` / `keys` 三行的既有顺序与写法不动);③ `up` 中非 KeyUp 指令换到另一组匹配组合的分支(`const [index, keys] = newMatch;` 之后),同样在 `pending.context = this.createContext();` 之后加 `pending.matchedWhen = ...`。`down` 内**不得**出现任何条件判断:条件为假也必须建立 / 更新 pending(`up` 依赖它做 KeyUp 匹配与 `waitTimeout` 转移);该键已在 `holdingSet` 中时的早退分支也不求值。
(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` 中则直接返回待触发项布尔值」早退分支不动。
(3) **`emit` 闸口(修正 2 触发闸口 ①)**:在 `private emit(pending: IHotkeyCommandPending)` 的第一行、`const command = pending.command;` 之前插入 `if (!pending.matchedWhen) return;`。这样 `addPending` 的立即触发、`up` 的 KeyUp 触发、`excited` 的 `waitTimeout` 与 `holdingCmds`(`Continuous`)触发全部经此一处;被拦下时不 `pending.emitted++`、不写 `lastEmit`。
(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` 删除的项就此丢弃)。
(4) **`up` 的 block / 优先级闸口(修正 2 触发闸口 ②)**:`toEmit.sort(...)` 之后的循环里保留首行 `if (pending.command.priority < blocking) break;`,紧随其后插入 `if (!pending.matchedWhen) continue;`(位置必须在 `if (pending.command.block) blocking = pending.command.priority;` 之前)。这样条件为假的指令既不触发、也不抬高 `blocking` —— `block` / 优先级只统计「真的会触发」的指令,且 `break` 的「严格更低才终止」语义不变。`emit` 内部的第一行闸口仍保留作兜底(两处都要有)。
(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 次(证明条件在派发时刻求值,未建立的待触发项不会补发)。
- 条件为假时两条指令都不触发(`when` 是**作用域级**条件,无法只抑制其中一条):两条 `[KeyA]` KeyUp 指令(`block: true` 优先级 10 的 `high`、优先级 5 的 `low`,同一作用域),`when(() => ok)` 且 `ok = false`;`down(KeyA)` / `up(KeyA)` → `high` / `low` 各 0 次。随后 `ok = true`,**重新按下**(`down(KeyA)` / `up(KeyA)`)→ `high` 1 次、`low` **0 次**。注释必须写明:`high` 的 `block: true` 与优先级 10 会在 `toEmit` 排序后把 `blocking` 抬到 10,`low` 的优先级 5 `< 10` 直接 `break`,所以 **`low` 无论条件真假都是 0**;本例只能用来证明「条件为假时连 `high` 都不触发」,不能当作「只有 block 指令被抑制」的证据。若需要「低优先级也触发」的对照组,必须另起用例改用**同优先级**或**去掉 `block`**。
- KeyDown 按下时条件为假仍建立 pending:`Once` / `[KeyA]`、`timeout: 0`,绑定假激励源,`let ok = false`、`when(() => ok)`;`down(KeyA)` 返回 `true`(pending 已建立)、`excite(10)` 后 handler 0 次(`emit` 被捕获值拦下)。
- KeyDown 条件为假且没有新匹配 → 永不触发:同上,`down(KeyA)` → 0;`excite(10)` / `excite(20)` → 0;`up(KeyA)`(转入 `waitTimeout`)后 `excite(30)` → 仍 0。
- KeyDown 条件为假后新按键匹配并重新求值 → 由激励触发一次:同上,`down(KeyA)` → 0;`excite(10)` → 0;`ok = true`;`down(Ctrl)`(对 `[KeyA]` 再次匹配,pending 的捕获值被更新)不立即触发;`excite(20)` → 1;`excite(30)` → 仍 1。
- KeyDown 松键后条件恢复再按下 → 可以再次触发且不重复:`Once` / `[KeyA]`、`timeout: 0`、`ok = false`;`down(KeyA)` → 0;`up(KeyA)`(`emitted === 0`,转投 `waitTimeout`,但仍留在 `holdingCmds`);`ok = true`;`down(KeyA)`(对 `[KeyA]` 再次匹配 → 捕获值更新为真,`down` 本身不 `emit`);`excite(10)` → 1 次(`waitTimeout` 循环按 `dt >= timeout(0)` 派发;随后 `holdingCmds` 循环因 `emitted === 1` 且 `Once` 不再派发,故不重复);`excite(20)` → 仍 1。
- KeyUp 抬起时不重新求值:`[KeyS]`(KeyUp 默认模式)、`ok = false`;`down(KeyS)` 返回 `true`(pending 已建立);`ok = true`;`up(KeyS)` → handler 0 次(`up` 只用匹配时刻的捕获值,不调用条件函数)。
- KeyUp 新按键匹配后抬起触发:同上,`down(KeyS)`(捕获假);`ok = true`;`down(Ctrl)`(对 `[KeyS]` 再次匹配 → 捕获值更新为真、`context` 同步更新);`up(KeyS)` → 1 次,且 `info.context.holding` 等于 `[Ctrl]`(活引用,被抬起的 `KeyS` 已被 `splice`)。
- `block` / 优先级只统计会触发的指令:两条 `[KeyA]` KeyUp 指令(`high` 优先级 10、`block: true`;`low` 优先级 5),`when` 传按求值次数返回的顺序化函数(`let calls = 0; when(() => { calls++; return calls > 1; })`)—— `commands` 按注册顺序遍历,故 `high` 捕获 `false`、`low` 捕获 `true`;`down(KeyA)` / `up(KeyA)` 后 `high` 0 次、`low` **1 次**(条件为假的 `high` 既不触发、也不把 `blocking` 抬到 10 去 `break` 掉 `low`),并断言条件被求值 2 次。注释写明:若 `up` 的循环在 `block` 赋值前不跳过假值项,`low` 会被 `break` 掉而变成 0 次,本用例即失败。
- `up` 换组合时重新求值:`Continuous` / `timeout: 0` / `interval: 0` / `dt: 0`,`defaults: [[KeyA, KeyB], [KeyA]]`,`when` 用顺序化函数(前 2 次为真、之后为假);`down(KeyA)` → 命中 `[KeyA]`(索引 1)并立即触发 1 次;`down(KeyB)` → 换到 `[KeyA, KeyB]`(索引 0)并重新求值(仍为真);`up(KeyB)` → 换回 `[KeyA]` 时第三处捕获点求值为假;`excite(10)` → 仍 1 次(漏掉第三处捕获点时会变成 2)。
- `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.holding` 是活引用(`up` 先 `splice` 再入 `waitTimeout`):`keyUp` 同 `timeout: 100`;`down(KeyS)`、`up(KeyS)` → 0;`excite(200)` → 1,且 `info.context.holding` 等于 `[]`(不含被抬起的主键;注释写明「holding 是活引用,仅修饰键布尔量是匹配时刻快照」)。
- 延时触发到期时条件为假 → 吞掉且不补发:`keyUp` 同 `timeout: 100`、`when(() => ok)`(初始 `true`);`down(KeyS)`、`up(KeyS)`;`ok = false`;`excite(200)` → 0;`ok = true`;`excite(300)` → 仍 0。
- KeyUp 带 `timeout` 且匹配时条件为假 → 抬起不触发、也不补发:`keyUp` 同 `timeout: 100`、`ok = false`;`down(KeyS)` / `up(KeyS)` → 0;`excite(200)` → 仍 0(该项在 `toEmit` 循环里被 `continue` 跳过,**没有**进入 `waitTimeout`)。
- KeyUp 带 `timeout` 且匹配时条件为真、到期前条件转假 → 仍按捕获值触发一次:`keyUp` 同 `timeout: 100`、`ok = true`;`down(KeyS)` / `up(KeyS)` → 0(进入 `waitTimeout`);`ok = false`;`excite(200)` → 1 次(`emit` 只读匹配时刻的捕获值,不重新求值)。
- `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`)。
@ -392,31 +403,34 @@ Phase 9 · 第 24 个计划 —— **修正用户重写后按键系统(`comman
(7) **假激励源与补加导入**:本任务在 `system.test.ts` 的 import 段补上 `IExcitable` / `IExcitation` from `@motajs/animate`(Task 1 未导入,避免未使用导入)。`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` 零改动)、`context.holding` 的活引用口径与 `createContext` 未被改动、`Continuous + timeout > 0` 仍按 OI-3 留在现状、以及 `estimate.tokens=70000` 为**未校准估算**(`confidence: low`,修订前基线为已提交的 09-23 实现 `3fd339f`)的声明。
(8) **`09-24-SUMMARY.md`**:记录 5 处修正的最终落点(文件 + 位置 + 改动前后写法)、按用户裁定保持现状的六项、两个测试文件的用例清单与旧用例的对应关系、OI-1 / OI-2 的批准情形、OI-3 的提问与用户回复(只有用户确认时才可写成「已确认缺陷」,否则只记录「已提问、按用户指示未修」)、四道闸门的实际结果、边界(未做浏览器 E2E、未接线 `packages-user/**`、未跑全量构建、旧实现与 `type.ts` 零改动)、`context.holding` 的活引用口径与 `createContext` 未被改动、`Continuous + timeout > 0` 仍按 OI-3 留在现状、以及 `estimate.tokens=70000` 为**未校准估算**(`confidence: low`,修订前基线为已提交的 09-23 实现 `3fd339f`)的声明。另须单独记录 `when` 的最终语义:匹配时刻求值、`matchedWhen` 的三处捕获点(`addPending` / `down` 再匹配 / `up` 换组合)、两处闸口(`emit` 第一行、`up` 的 block 循环)、OI-1 最终采用的成员名,以及「条件在触发时刻不会被重新求值」这一口径(含 KeyUp 的延时项按捕获值触发、KeyDown 只有新匹配才重新求值)。
(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()`:无作用域 → **`true`**(保持 `down` / `up` 返回值不变,`emit` 本就无作用域即返回);`condition === null` → `true`;`condition()` 返回 `false` → 触发路径全部不执行且不占用 block。
- `down` 在条件为假时不建立待触发项(`down` 返回 `false`);条件恢复后重新 `down` 可正常触发一次。
- `up` 在条件为假时不派发、不转投 `waitTimeout`;`emit` 在延时到期但条件为假时被抑制,且该 `waitTimeout` 项按既有代码出队(不补发)。
- `matchedWhen` 在**匹配时刻**求值(与 `createContext()` 同步),三处捕获点:`addPending`、`down` 的再匹配分支、`up` 的换组合分支;表达式 `this.getScope()?.condition?.() ?? true`(无作用域 / `null` / 缺失 → `true`)。
- 条件为假不阻止 pending 建立:`down` 仍返回 `true`,`up` 仍能匹配到 KeyUp 项,KeyDown 项仍会(在未触发时)转入 `waitTimeout`。
- `emit` 拒绝 `matchedWhen === false` 的 pending:`addPending` 的立即触发、`up` 的 KeyUp 触发、`waitTimeout` 到期、`Continuous` 激励四条路径都不派发,且不写 `emitted` / `lastEmit`。
- `up` 的 block 循环跳过 `matchedWhen === false` 的项且不抬高 `blocking`;`break` 仍只看优先级(严格更低才终止),因此条件为假的高优先级 `block` 指令不会误伤真正会触发的低优先级指令。
- KeyUp:`up` 不重新求值。匹配时条件为假 → 抬起不触发,带 `timeout` 时也不进入 `waitTimeout`(不补发);匹配时条件为真 → 即便到期前条件转假,仍按捕获值触发一次。
- KeyDown(`Once` / `Continuous`):按下时条件为假 → 不触发;只有新按键匹配才重新求值并可能触发(由激励源推进,`Once + timeout 0` 恰好一次);没有新匹配就永不触发。
- `excited`:`Once` + `timeout: 100` 时 `payload 99` 不触发、`payload 100` 触发;`Continuous` + `timeout: 0` 的第二次按 `interval`、其后按 `dt`;`Continuous + timeout > 0` 不在本行为承诺内(OI-3,未裁定)。
- `excitedBy` 只保留一个激励源;置 `null` 后旧源不再驱动、系统本身仍可用。
- `info.command` 是对象(`command.command` 为 id、`command.mode` 为模式),`info.keys` 为触发组合;`info.context.holding` 是活引用 —— KeyUp 触发时不含被抬起的主键(`[Ctrl, KeyS]` → `[Ctrl]`,`[KeyS]` → `[]`),只有修饰键布尔量是匹配时刻快照。
</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` 双源同时驱动 / KeyUp 的 `context.holding` 不是「活引用、不含被抬起的主键」/ 条件 + block 用例把低优先级被 `break` 当成「条件抑制」的证据</fails_when>
<fails_when>non-zero exit,或任一用例失败:`when` 未在匹配时刻捕获(条件为假仍触发 / KeyUp 抬起又去重新求值 / 新匹配后 pending 的捕获值未更新 / 条件为假的 `block` 指令把低优先级项 `break` 掉)/ `timeout` 边界(99 与 100)/ `interval` / `dt` 的触发次数 / 松键后仍触发 / `excitedBy` 双源同时驱动 / KeyUp 的 `context.holding` 不是「活引用、不含被抬起的主键」</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()');if(!/if \(!scope\) return true;/.test(sys))bad.push('isActive must return true when there is no scope (down/up return values must not change)');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()` 或无作用域未返回 `true` / 闸口少于 3 处 / `excited` 比较符未改或旧写法残留 / 未获批准就改了 OI-3 的分支 / 测试仍含旧设计 token / 缺少条件或激励覆盖 / 某 `it` 前无注释 / 用例少于 32 条 / SUMMARY 缺失</fails_when>
<automated>node -e "const fs=require('fs');const bad=[];const sys=fs.readFileSync('packages/system/src/hotkey/system.ts','utf8');if(!/interface IHotkeyCommandPending[\s\S]{0,800}matchedWhen: boolean/.test(sys))bad.push('IHotkeyCommandPending lacks the matchedWhen member');const evals=(sys.match(/this\.getScope\(\)\?\.condition\?\.\(\)\s*\?\?\s*true/g)||[]).length;if(evals!==3)bad.push('expected exactly 3 when-capture sites (addPending + down re-match + up re-match), found '+evals);if(!/private emit\(pending: IHotkeyCommandPending\)\s*\{\s*if \(!pending\.matchedWhen\) return;/.test(sys))bad.push('emit must skip pendings whose captured when is false');if(!/priority < blocking\) break;\s*if \(!pending\.matchedWhen\) continue;/.test(sys))bad.push('the block/priority loop must skip pendings whose captured when is false, after the break check');const downBody=sys.slice(sys.indexOf('down(key: KeyCode): boolean'), sys.indexOf('up(key: KeyCode): boolean'));if(/if\s*\([^)]*(condition|matchedWhen)/.test(downBody))bad.push('down must not gate on when (pendings must still be tracked for up)');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)}if(t.indexOf('matchedWhen')>=0)bad.push('system.test.ts must not depend on the private pending member name');for(const s of ['when(','excitedBy(','excite(','timeout']){if(t.indexOf(s)<0)bad.push('system.test.ts lacks coverage for '+s)}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<36)bad.push('system.test.ts has only '+its+' it() cases, expected at least 36 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: when captured at 3 match-time sites, emit/block honour it, timeout comparison fixed, '+its+' system cases, SUMMARY present')"</automated>
<fails_when>non-zero exit,或 stderr 含 "FINAL GATE FAILED":`matchedWhen` 成员缺失 / `condition?.()` 不是恰好 3 处(多了说明又在触发时刻现算,少了说明漏了捕获点)/ `emit` 首行闸口缺失 / `up` 的 block 循环未在 `break` 之后跳过假值项 / `down` 内出现条件判断 / `excited` 比较符未改或旧写法残留 / 未获批准就改了 OI-3 的分支 / 测试仍含旧设计 token 或依赖 `matchedWhen` 这个私有名 / 缺少条件或激励覆盖 / 某 `it` 前无注释 / 用例少于 36 条 / 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、不调实现;延时到期被抑制且不补发;`isActive()` 无作用域返回 `true`,`down` / `up` 返回值不变);`excited` 首次触发改为 `dt >= command.timeout`(`Once` 的 99 不触发 / 100 触发);`Continuous + timeout === 0` 的 `interval` / `dt` 行为被用例锁定,`Continuous + timeout > 0` 按 OI-3 保持现状且不写断言;`excitedBy` 单源切换与 `null` 被覆盖;KeyUp 的 `context.holding` 按活引用口径断言(不含被抬起的主键);`system.test.ts` 总计至少 32 条用例全绿;eslint、`FINAL GATE`、类型闸门(本计划文件 0 条诊断、legacy 恰 2 条)、范围闸门全部通过;`09-24-SUMMARY.md` 已生成并如实记录修正落点、保持现状的六项、OI-3 的裁定情形与边界
<done>`when` 在**匹配时刻**求值并随 pending 保存(`IHotkeyCommandPending.matchedWhen`,三处捕获点与 `createContext()` 同步;条件为假仍建立 pending,`down` / `up` 返回值不变);`emit` 首行与 `up` 的 block 循环各设闸口 —— 条件为假的指令不触发、不抬高 `blocking`;KeyUp 抬起不重新求值(匹配时假 → 抬起与延时都不触发、不补发;匹配时真 → 即便到期前转假仍按捕获值触发一次);KeyDown 按下为假则不触发、只有新按键匹配才重新求值并可能触发(由激励源推进);`excited` 首次触发改为 `dt >= command.timeout`(`Once` 的 99 不触发 / 100 触发);`Continuous + timeout === 0` 的 `interval` / `dt` 行为被用例锁定,`Continuous + timeout > 0` 按 OI-3 保持现状且不写断言;`excitedBy` 单源切换与 `null` 被覆盖;KeyUp 的 `context.holding` 按活引用口径断言(不含被抬起的主键);`system.test.ts` 总计至少 36 条用例全绿;eslint、`FINAL GATE`、类型闸门(本计划文件 0 条诊断、legacy 恰 2 条)、范围闸门全部通过;`09-24-SUMMARY.md` 已生成并如实记录修正落点、`when` 的最终语义、保持现状的六项、OI-3 的裁定情形与边界</done>
</task>
</tasks>
@ -427,7 +441,7 @@ Phase 9 · 第 24 个计划 —— **修正用户重写后按键系统(`comman
| Boundary | Description |
|----------|-------------|
| 浏览器事件 → 系统 | `keydown` / `keyup` 的 `keyCode` 经 `EVENT_KEY_CODE_MAP` 与本地 `91` / `92` / `224` 映射后进入 `down` / `up`(本计划只用 fake 事件验证) |
| 作用域 → 实现 | 只有栈顶作用域的实现与条件参与判定;`when` 条件是「是否允许触发」的闸门 |
| 作用域 → 实现 | 只有栈顶作用域的实现与条件参与判定;`when` 条件在按键组合匹配时刻求值并随 pending 保存,触发时只读该捕获值 |
| 激励源 → 系统 | `excited(payload)` 的 `payload` 决定全部时序;外部激励源可推进任意时间步 |
| 调用方 → 指令对象 | 调用方通过 `setKeys` / `setKeyCombination` / 模式方法改写指令状态 |
| 写入边界 | 本增量是写操作,只允许写 `files_modified` 白名单(5 个文件),无依赖 / lockfile 变更 |
@ -438,7 +452,7 @@ Phase 9 · 第 24 个计划 —— **修正用户重写后按键系统(`comman
| 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: true` 的高优先级指令也不触发;条件恢复后只有高优先级触发,低优先级是被 `block` 的 `break` 抑制(与条件无关)」 |
| T-09-24-01 | Elevation of privilege | `when` 条件(`matchedWhen` 在三处匹配时刻捕获 + `emit` / `up` 的 block 循环两处闸口) | high | mitigate | 捕获与闸口缺一不可:`down` 无条件建立 pending(条件为假也跟踪)、`emit` 首行按捕获值拦截全部触发路径、`up` 的 block 循环在 `break` 之后跳过假值项且不抬高 `blocking`;用例覆盖「条件为假仍建立 pending」「KeyDown 无新匹配则永不触发」「新按键匹配重新求值后由激励触发」「KeyUp 抬起不重新求值(假值不补发、真值按捕获值触发)」「条件为假的 `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` 派生白名单 |
@ -451,15 +465,15 @@ Phase 9 · 第 24 个计划 —— **修正用户重写后按键系统(`comman
- **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()` 读 + 无作用域返回 `true`、`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` 派生)。
- **Task 3**:两个测试文件全绿(`system.test.ts` ≥ 36 条);eslint;`FINAL GATE`(`matchedWhen` 成员存在、`condition?.()` 恰好 3 处、`emit` 首行闸口、`up` 的 block 循环在 `break` 之后跳过假值项、`down` 内无 `condition`、`dt >= timeout`、OI-3 分支未改、测试无旧设计 token 且不依赖 `matchedWhen` 私有名、含 `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`。
- `system.ts`:`matchKeyUp` 改为 `const [auxiliary, main] = matchArray(...)`,其余逻辑与注释不变;无参 `leaveScope` 在 `pop` + 两次 `clear` 后 `return;`;`IHotkeyCommandPending` 新增 `matchedWhen`(三处匹配时刻捕获 + `emit` / `up` 的 block 循环两处闸口,不新增私有方法);`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` 导入。
- `system.test.ts`:按重写后的设计整体重写并覆盖注册表 / 作用域 / KeyUp 四条规则 / holding 与子集 / 优先级与 block / `when` 在匹配时刻生效的 10 条 / `excited` 的 timeout·interval·dt / `excitedBy` / `listen` / 返回值,≥ 36 条用例全绿,每个 `it` 前一行注释,只从 `../system` 导入。
- `command.test.ts`:按新设计整体重写并覆盖构造缺省与拷贝 / `isAuxiliaryKey` / `setKeys` 0·负·非整数 / 206 三态 / 203 与模式守卫 / 读取与迭代 / 模式方法三参,≥ 12 条用例全绿,只从 `../command` 导入。
- 按用户裁定保持现状的六项(`down` / `up` 返回值、`context.holding` 的活引用与修饰键快照(`createContext` 不改)、`priority` / `block` 仅抬起生效、`waitTimeout` 计时起点、未实现指令的待触发项、`excited` 分支结构)未被改动,测试按现状断言并注明口径;KeyUp 的 `info.context.holding` 断言为 `[Ctrl]` / `[]`(不含被抬起的主键)。
- 修正 4 的承诺范围限于 `Once + timeout > 0` 与 `Continuous + timeout === 0`;`Continuous + timeout > 0` 按 OI-3 记录为未裁定、保持现状且无断言。