fix(09-23): address third-round checker warnings

- state that renaming any OI-1/OI-2/OI-3 identifier requires plan revision first
  (structure gate asserts isAuxiliaryKey, MODE gate asserts isPendingDue)
- pin IHotkeyPending field types: command is an IHotkeyCommand reference,
  last/fired explicit 0 at enqueue, no context; align enqueue literals in both tasks
- restrict prettier to the 5 TS files; never pass logger.json / package.json / pnpm-lock.yaml
- run the scope gate before Task 1 commit as well
- assert the 2 known legacy diagnostics in the type gate (anti false-green)
- add HotkeyListener type alias and extra test cases (when false, empty combo,
  getCommand null, setKeys negative/non-integer)
- exempt pre-existing user WIP src/client/misc/ in the scope allowlist
This commit is contained in:
unanmed 2026-10-21 20:55:05 +08:00
parent 04aae346ca
commit 6c9f8df8a5

View File

@ -40,8 +40,8 @@ must_haves:
- "**依赖与导出(用户决策 3 / 1)**:`packages/system/package.json` 的 `dependencies` 补 `@motajs/animate: workspace:*`(`@motajs/common` 保留),并据 `pnpm install --lockfile-only` 更新 `pnpm-lock.yaml`(`packages/system` 导入器出现 animate 链接);`hotkey/index.ts` 追加 `export * from './command';` 与 `export * from './system';`,其余导出行不动;旧实现 `hotkey.ts` / `keyboard.ts` **零改动**。"
- "**日志文案投递(OI-5)**:`packages/common/src/logger.json` 的 `warn` 段在 `\"200\"` 之后、`\"1001\"` 之前追加 **201 / 202 / 203 / 204 / 205** 五条,文本**逐字**采用 OI-5 的批准版本;既有条目(含 `200` 与 `1001`)一字不动、不新增其它码。这条是功能必需:`Logger.warn` 在 `info.warn[code]` 缺失时会退化成 `error 16`,缺了它 201–205 全部失效。结构闸门 `JSON.parse` 后逐条比对文本,并断言 `warn` 段键数为 206、`200` / `1001` 前缀不变。"
- "**测试是必需产物(用户决策 + AGENTS.md)**:`packages/system/src/hotkey/__test__/command.test.ts` 与 `__test__/system.test.ts`,**每个 `it` 前一行注释**说明该用例覆盖什么;Node 环境无真实 DOM,`listen` 用最小 fake 元素(记录注册的监听器并手动派发伪事件);断言用 `vi.spyOn(logger, 'warn')` 校验 201–205 的码与参数,不依赖控制台文本。**测试只从实现模块导入**:`import { HotkeyCommand, isAuxiliaryKey } from '../command'` 与 `import { HotkeySystem } from '../system'`;**禁止 `../index`**(`hotkey.ts` 在模块顶层执行 `document.addEventListener`,Node 环境下导入即炸),结构闸门对此做负向断言。"
- "**验证可运行且范围诚实(Nyquist)**:`cmd /c \"pnpm exec vitest run <两个测试文件>\"`、`cmd /c \"pnpm exec eslint <改动文件>\"`、按文件过滤的类型闸门(`vue-tsc --noEmit` 全仓现有 221 行错误中 `hotkey.ts` / `keyboard.ts` 各有 1 条 legacy 错误,属既定边界,闸门只对 `command.ts` / `system.ts` / `index.ts` / `__test__/**` 报错即失败;**闸门必须先校验 `spawnSync` 的 `out.error` / `out.status` / `out.signal`**,命令没正常跑完(超时、找不到 `cmd`、进程被杀)一律失败,不能只看过滤后的输出行)、结构闸门(导出形态、`logger.json` 的 201–205 逐字文案、注释剥离后的负向断言、测试导入路径、`it` 前注释、未改旧实现)、范围闸门(白名单由 `files_modified` 派生、含 `logger.json`,用户既有 WIP 显式豁免;禁改清单含 `hotkey.ts` / `keyboard.ts` / `type.ts` / `keyCodes.ts`);**不端到端跑 `build:game`**,不接线 `packages-user` 的 `IClientHotkey`。"
- "**命名与文案须先获批(AGENTS.md / D-04)**:OI-1(测试文件路径与文件名)、OI-2(导出的辅助函数名 `isAuxiliaryKey`)、OI-3(内部接口 / 类型别名 / 私有成员命名清单,含 `HotkeyCommand` 的 `combinations` 与私有方法 `resolveKeyCode` / `isPendingDue`)、OI-4(四处语义确认项)、OI-5(warn 201–205 的日志文案,逐字报备)。未获批前不得落码。"
- "**验证可运行且范围诚实(Nyquist)**:`cmd /c \"pnpm exec vitest run <两个测试文件>\"`、`cmd /c \"pnpm exec eslint <改动文件>\"`、按文件过滤的类型闸门(`vue-tsc --noEmit` 全仓现有 221 行错误中 `hotkey.ts` / `keyboard.ts` 各有 1 条 legacy 错误,属既定边界,闸门只对 `command.ts` / `system.ts` / `index.ts` / `__test__/**` 报错即失败;**闸门必须先校验 `spawnSync` 的 `out.error` / `out.status` / `out.signal`**,命令没正常跑完(超时、找不到 `cmd`、进程被杀)一律失败,不能只看过滤后的输出行;**并断言 `hotkey.ts` / `keyboard.ts` 的 legacy 诊断恰好 2 条**——输出若不是这个已知基线,说明 `vue-tsc` 没产出完整结果,按失败处理,避免假绿)、结构闸门(导出形态、`logger.json` 的 201–205 逐字文案、注释剥离后的负向断言、测试导入路径、`it` 前注释、未改旧实现)、范围闸门(白名单由 `files_modified` 派生、含 `logger.json`,用户既有 WIP 显式豁免;禁改清单含 `hotkey.ts` / `keyboard.ts` / `type.ts` / `keyCodes.ts`;**Task 1 提交前与 Task 2 末尾各跑一次**);prettier 只对 5 个 TS 文件执行,`logger.json` / `package.json` / `pnpm-lock.yaml` 不传给 prettier(它们不在 `.prettierignore` 中,会被整体重排);**不端到端跑 `build:game`**,不接线 `packages-user` 的 `IClientHotkey`。"
- "**命名与文案须先获批(AGENTS.md / D-04)**:OI-1(测试文件路径与文件名)、OI-2(导出的辅助函数名 `isAuxiliaryKey`)、OI-3(内部接口 / 类型别名 / 私有成员命名清单,含 `HotkeyCommand` 的 `combinations`、类型别名 `HotkeyListener` 与私有方法 `resolveKeyCode` / `isPendingDue`)、OI-4(四处语义确认项)、OI-5(warn 201–205 的日志文案,逐字报备)。未获批前不得落码。**门禁确实是按名字断言的**(结构闸门断言 `isAuxiliaryKey` / `HotkeyCommand` / `HotkeySystem` 与文件路径,MODE 闸门断言 `isPendingDue` / `flushPending` / `dropPending`,测试代码也直接引用这些名字),因此**用户一旦改动 OI-1 / OI-2 / OI-3 中的任一标识符(文件名、两个类名、`isAuxiliaryKey`、OI-3 的内部接口 / 类型别名 / 私有字段 / 私有方法名),必须先按新命名修订本计划(门禁字符串、`files_modified`、测试代码中的名字、SUMMARY 关键字同步)并重新获批,然后才能执行**;不存在「改名后照原计划落码」的路径。"
artifacts:
- path: packages/system/src/hotkey/command.ts
provides: "`export class HotkeyCommand implements IHotkeyCommand`:构造器接收指令 id 与 `IHotkeyCommandInit`(`priority` / `block` 只读,`mode` 缺省 `KeyUp`,`timeout` / `interval` / `dt` 缺省 0,`defaults` 深拷贝并经 `setKeys` + `setKeyCombination` 落地);按键组合数组 `combinations`(`length` 同步)、`setKeys` 扩容补空组合 / 缩容截断、`setKeyCombination`(越界索引忽略、写入副本、`mode` 为 `KeyUp` 时校验并发 warn 203)、`getKeyCombination`(越界返回空数组、返回副本)、`iterateKeyCombinations`(索引升序、产出副本);`keyUp` / `once` / `continuous` / `throttle` 设置 `mode` 与对应时间参数(未传参时保留现值),`keyUp()` 额外校验现有组合并在存在非法组合时发一次 warn 203;导出辅助函数 `isAuxiliaryKey`(`Ctrl` / `Shift` / `Alt` / `Meta` 判定)供 `system.ts` 复用(命名待批准,OI-2)"
@ -58,9 +58,9 @@ must_haves:
- path: packages/common/src/logger.json
provides: "`warn` 段在 \"200\" 与 \"1001\" 之间追加 201–205 五条,文本逐字采用 OI-5;既有条目(`200` / `1001` 等)零改动。**这是功能必需**:`Logger.warn` 在 `info.warn[code]` 缺失时退化为 `error 16`,不追加则 201–205 全部失效"
- path: packages/system/src/hotkey/__test__/command.test.ts
provides: "指令对象单测:缺省值 / defaults 深拷贝 / `setKeys` 扩缩容 / `setKeyCombination` 越界与副本 / `iterateKeyCombinations` / KeyUp 非法组合 warn 203(`setKeyCombination` 与 `keyUp()` 两个时机)/ 四个模式方法的字段与 `mode` 变更;每个 `it` 前一行注释"
provides: "指令对象单测:缺省值 / defaults 深拷贝 / `setKeys` 扩缩容与负值、非整数忽略(`length` 不变)/ `setKeyCombination` 越界与副本 / `iterateKeyCombinations` / KeyUp 非法组合 warn 203(`setKeyCombination` 与 `keyUp()` 两个时机)/ 四个模式方法的字段与 `mode` 变更;每个 `it` 前一行注释"
- path: packages/system/src/hotkey/__test__/system.test.ts
provides: "系统单测:warn 201 / 202 / 204 / 205、作用域进出与条件隔离、KeyUp 四条规则、KeyDown 子集与辅助键、holding 与上下文、优先级与 block(含无实现不阻断)、激励驱动的 timeout / interval / dt(含松键与作用域退出丢弃)、`down` / `up` 返回值、`excitedBy` 切换、`listen` 映射 / repeat / preventDefault / 解绑;每个 `it` 前一行注释"
provides: "系统单测:warn 201 / 202 / 204 / 205、作用域进出与条件隔离(含栈顶 `when(() => false)` 不触发)、KeyUp 四条规则(含空组合不触发且不 warn)、KeyDown 子集与辅助键、holding 与上下文、优先级与 block(含无实现不阻断)、激励驱动的 timeout / interval / dt(含松键与作用域退出丢弃)、`down` / `up` 返回值、`getCommand` 对未注册 id 返回 `null`、`excitedBy` 切换、`listen` 映射 / repeat / preventDefault / 解绑;每个 `it` 前一行注释"
- path: .planning/phases/09-minimum-browser-runnable/09-23-SUMMARY.md
provides: "执行摘要:最终语义口径(含 OI-4 的裁定结果)、命名获批情形、门禁结果与已知边界(未接线 `IClientHotkey`、未跑全量构建、`hotkey.ts` / `keyboard.ts` 的 legacy 类型错误不修)、`estimate.tokens=95000` 为未校准估算的声明"
key_links:
@ -68,7 +68,7 @@ must_haves:
- "`HotkeyCommand` 的 `mode`(可经 `keyUp` / `once` / `continuous` / `throttle` 变更)↔ `system.ts` 的 `down` / `up` 路由 —— 同一指令在 KeyUp 与 KeyDown 两类派发中不会重复触发"
- "`isAuxiliaryKey`(`command.ts`)↔ `system.ts` 的 KeyUp 组合拆分与 KeyDown 子集判定 —— 辅助键集合(含 `Meta`)只定义一次,避免两套口径"
- "`holding`(`system.ts`)↔ `IHotkeyEmitContext` 的 `holding` / `ctrl` / `shift` / `alt` / `meta` —— KeyDown 含触发键、KeyUp 不含被抬起键,四个布尔值全部由 `holding` 推导"
- "待触发项(入队时记录的快照:`mode` / `timeout` / `interval` / `dt` / 作用域 `symbol` / `type` / `key`,**不保存 `context`**)↔ `excited(payload)` 的 `payload` —— 时间推进、丢弃与首次 / 后续间隔判定的唯一入口;真正触发时用当时的 `holding` 重新构造 `context`"
- "待触发项(入队快照:`id` / `command`(`IHotkeyCommand` 对象引用)/ `index` / `keys` / `handler` / `type` / `key` / `scope` / `mode` / `timeout` / `interval` / `dt` / `time` / `last` / `fired`,**不保存 `context`**)↔ `excited(payload)` 的 `payload` —— 时间推进、丢弃与首次 / 后续间隔判定的唯一入口(`isPendingDue` 只读 `fired` / `time` / `last` / `timeout` / `interval` / `dt`;`flushPending` 用 `item.command.priority` 与 `item.last`);真正触发时用当时的 `holding` 重新构造 `context`"
- "`excitedBy` 持有的 `IExcitableController` ↔ `excitation.add(this)` —— 切换激励源时先 `revoke`,保证只有一个激励源在驱动本系统"
- "`listen` 注册的 `keydown` / `keyup`(含 `91` / `92` / `224` → `KeyCode.Meta` 的本地映射)与 `EVENT_KEY_CODE_MAP`(`keyCodes.ts`)↔ `down` / `up` 的布尔返回 —— 只有真正触发(含入队)时才 `preventDefault()`;`keyCodes.ts` 本身零改动"
- "`hotkey/index.ts` 的新增导出 ↔ `packages-user/client-base/src/hotkey/types.ts` 中用户 WIP 的 `IClientHotkey.system: IHotkeySystem` —— 本计划只提供实现,不接线"
@ -85,7 +85,7 @@ must_haves:
- "**`packages/system` 现有测试为零:** `packages/` 下此前没有任何 `__test__` 目录或 `*.test.ts`(实测),本计划是 `packages/system` 的第一个测试落点,故 `__test__/` 目录由 Task 1 创建。"
prohibitions:
- "不得在用户回复通过 Task 0 关卡之前执行任何任务或派发执行子代理。"
- "**未经批准的命名一律不得使用:** `isAuxiliaryKey`(OI-2)、测试文件路径与文件名(OI-1)、内部接口 / 类型别名 / 私有成员命名(OI-3)。若用户给出其它命名,按用户命名执行(门禁只绑定 `HotkeyCommand` / `HotkeySystem` 两个类名与文件路径,不绑定私有名与辅助函数名);若用户改的是**文件名或两个类名**,必须先按新命名修订本计划(含 `files_modified` 与门禁命令)并重新获批。"
- "**未经批准的命名一律不得使用:** `isAuxiliaryKey`(OI-2)、测试文件路径与文件名(OI-1)、内部接口 / 类型别名 / 私有成员命名(OI-3)。**门禁确实按名字断言**(结构闸门断言 `isAuxiliaryKey` / `HotkeyCommand` / `HotkeySystem` 与文件路径;MODE 闸门断言 `isPendingDue` / `flushPending` / `dropPending`;两个测试文件也直接引用这些名字),因此**任何 OI-1 / OI-2 / OI-3 标识符被改名,都必须先修订本计划(门禁字符串、`files_modified`、测试代码里的名字、SUMMARY 关键字同步)并重新获批,然后才能执行**——不存在「改完名仍按原计划落码」的路径。若用户只调整 OI-4 的语义口径或 OI-5 的日志文案,同样先修订本计划(结构闸门的逐字比对与测试断言同步)再执行。"
- "**不得改 `type.ts` 的既有定义:** 接口成员、`const enum`、jsDoc 一律逐字保留;除「`emit` 方法存在时删除该方法」外不得有任何改动,也不得因为 `IExcitable<number>` 而往 `IHotkeySystem` 加成员。"
- "**不得改旧实现:** `packages/system/src/hotkey/hotkey.ts` 与 `keyboard.ts` 零改动(含其 `document.addEventListener` 顶层副作用、`isAssist` 等导出),即使它们与新模式命名相近。"
- "**不得用定时器驱动时序:** 时间只能来自 `excited(payload)` 的 `payload`;不得引入任何浏览器定时器或 `Date.now()` 计时(`Date.now()` 只允许不出现)。"
@ -100,7 +100,7 @@ must_haves:
open_items:
- "**OI-1(测试文件路径与文件名):** 提议 `packages/system/src/hotkey/__test__/command.test.ts` 与 `packages/system/src/hotkey/__test__/system.test.ts`(与被测文件同名、camelCase、与 `packages-user/*/src/**/__test__` 既有约定一致)。备选:合并为 `__test__/hotkey.test.ts`,或按行为命名(`keyUp.test.ts` / `mode.test.ts`)。**报备待批准**。"
- "**OI-2(导出的辅助函数):** 提议 `export function isAuxiliaryKey(key: KeyCode): boolean`(`Ctrl` / `Shift` / `Alt` / `Meta`),供 `system.ts` 复用;备选:`isModifierKey`、`isAssistKey`(注意 `hotkey.ts` 已有 `isAssist`,仅覆盖三键、语义不同,不复用)。**待批准**。"
- "**OI-3(内部命名清单,均不导出):** 接口 `IHotkeyScope`(`symbol` / `condition` / `handlers`)、`IHotkeyMatch`(`id` / `command` / `index` / `keys` / `handler` / 可选 `entry`)、`IHotkeyPending`(`id` / `command` / `index` / `keys` / `handler` / `type` / `key` / `scope` / `mode` / `timeout` / `interval` / `dt` / `time` / `last` / `fired`,**不含 `context`**——触发时按当时 `holding` 重建);类型别名 `HotkeyHandler`;`HotkeyCommand` 私有字段 **`command`(指令 id,供 warn 203 的 `$1`)** 与 **`combinations`(二维 `KeyCode[][]` 组合数组,`length` 与它同步)**;`HotkeySystem` 私有字段 `commands` / `scopes` / `holding` / `pending` / `now` / `controller` / `keyDownListener` / `keyUpListener`,私有方法(Task 1)`getScope` / `createContext` / **`resolveKeyCode`** / `dispatch` / `runMatches` / `matchKeyDown` / `matchKeyUp` / `flushPending` / `dropPending`,Task 2 新增私有方法 **`isPendingDue`(单个待触发项的到期判定,供 `flushPending` 复用;不再新增其它私有方法)**。**待批准**(若不批准则按用户命名,门禁不受影响)。"
- "**OI-3(内部命名清单,均不导出):** 接口 `IHotkeyScope`(`symbol` / `condition` / `handlers`)、`IHotkeyMatch`(`id` / `command` / `index` / `keys` / `handler` / 可选 `entry`)、`IHotkeyPending`(**字段与类型固定**:`id: string`、`command: IHotkeyCommand`(**指令对象引用,不是 id 字符串**——`flushPending` 读 `item.command.priority`,`dropPending` 用 `item.command.mode` 与入队快照比对)、`index: number`、`keys: KeyCode[]`(入队时的组合拷贝)、`handler: HotkeyHandler`、`type: HotkeyEmitType`、`key: KeyCode`、`scope: symbol`、`mode: HotkeyEmitMode`(入队快照)、`timeout: number` / `interval: number` / `dt: number`(入队快照)、`time: number`(入队时的 `now`,首次到期判定用)、`last: number`(**入队时显式写 `0`**,每次真正触发后写当时的 `now`)、`fired: number`(**入队时显式写 `0`**,真正触发一次后置 `1`);**不含 `context`**——触发时按当时 `holding` 重建);类型别名 `HotkeyHandler` 与 `HotkeyListener`(`type HotkeyListener = (event: KeyboardEvent) => void`,用于 `keyDownListener` / `keyUpListener` 字段,避免内联函数类型——dev.md 要求非平凡函数类型单独开 `type` 别名);`HotkeyCommand` 私有字段 **`command`(指令 id,供 warn 203 的 `$1`)** 与 **`combinations`(二维 `KeyCode[][]` 组合数组,`length` 与它同步)**;`HotkeySystem` 私有字段 `commands` / `scopes` / `holding` / `pending` / `now` / `controller` / `keyDownListener`(`HotkeyListener`)/ `keyUpListener`(`HotkeyListener`),私有方法(Task 1)`getScope` / `createContext` / **`resolveKeyCode`** / `dispatch` / `runMatches` / `matchKeyDown` / `matchKeyUp` / `flushPending` / `dropPending`,Task 2 新增私有方法 **`isPendingDue`(单个待触发项的到期判定,供 `flushPending` 复用;不再新增其它私有方法)**。**待批准**(若不批准则须先按新命名修订本计划,见 prohibitions)。"
- "**OI-4(四处语义请确认,未回复即按提议执行):** ①**松键取消待触发项**:KeyDown 类模式(Once / Continuous / Throttle)的待触发项在其组合不再被 `holding` 满足时丢弃(含 `Once` 的 `timeout` 未到就松键的情形),KeyUp 类的延时项不受此限;②**同一事件同一指令只触发一次**(多组组合同时命中时取最小索引);③**空栈无参 `leaveScope()`** 也发 warn 205(复用同一条「不存在作用域」消息);④**延时项的 `context` 按触发时刻重算**:`flushPending` 触发时用当时的 `holding` 调 `createContext(pending.type, pending.key)`,**不使用入队时的 `context` 快照**(对齐 `type.ts` 第 29 行「当前所有正在按下的按键,按照按下的顺序排列」;因此延时触发能看到期间新按下的键,也不会把已松开的键算进去)。"
- "**OI-5(warn 201–205 的日志文案,逐字采用,待批准):** 下列五条必须**逐字符**写入 `packages/common/src/logger.json` 的 `warn` 段(`\"200\"` 之后、`\"1001\"` 之前):201 `Cannot set hotkey condition or implement command since no scope is active.`;202 `Cannot implement nonexistent hotkey command '$1'.`;203 `Hotkey command '$1' has an invalid KeyUp combination: at most one non-auxiliary key is allowed.`;204 `Duplicate hotkey command '$1', the existing command will be used.`;205 `Cannot leave nonexistent hotkey scope.`。201 / 205 无占位符;202 / 203 / 204 的 `$1` 就是指令 id(`HotkeyCommand` 的私有 `command` 字段 / `addCommand` 的 `command` 参数)。**待批准**;若用户改文案,先按新文案修订本计划(含结构闸门的逐字比对)再执行。"
- "**已知边界(不在本计划):** ①不接线 `packages-user/client-base/src/hotkey`(用户 WIP 的 `IClientHotkey.system` 只依赖接口,不依赖实现);②不端到端跑 `build:game`(09-21 / 09-22 已记录:渲染端仍有用户 WIP 的 stale import);③`hotkey.ts` / `keyboard.ts` 的 2 条 legacy 类型错误保持原状;④v1 不提供存档序列化(`type.ts` 第 4 行的 TODO 仍保留)。"
@ -185,7 +185,7 @@ Phase 9 · 第 23 个计划 —— **实现用户重新设计的按键系统**
⑧ **已知边界(原样告知):** ①不接线用户 WIP 的 `IClientHotkey`(只提供实现);②不跑全量构建(渲染端仍有用户 WIP 的 stale import,09-21 / 09-22 已记录);③`hotkey.ts` / `keyboard.ts` 的 2 条 legacy 类型错误保持原状、不修;④不提供存档序列化(`type.ts` 第 4 行 TODO 保留)。
⑨ **待批准命名与文案(须用户确认):** OI-1 测试文件(`__test__/command.test.ts` 与 `__test__/system.test.ts`);OI-2 导出辅助函数 `isAuxiliaryKey`;OI-3 内部接口 `IHotkeyScope` / `IHotkeyMatch` / `IHotkeyPending`、类型别名 `HotkeyHandler`、`HotkeyCommand` 私有字段 `command`(指令 id)与 **`combinations`(二维组合数组)**、`HotkeySystem` 私有字段 `commands` / `scopes` / `holding` / `pending` / `now` / `controller` / `keyDownListener` / `keyUpListener` 与私有方法 `getScope` / `createContext` / `resolveKeyCode` / `dispatch` / `runMatches` / `matchKeyDown` / `matchKeyUp` / `flushPending` / `dropPending`,Task 2 新增私有方法 **`isPendingDue`**;OI-4 四处语义确认(松键取消 KeyDown 类待触发项、同一事件同一指令只触发一次且取最小索引、空栈无参 `leaveScope()` 也发 205、延时项的 `context` 按触发时刻重算而非入队快照);**OI-5 五条日志文案逐字报备**:201 `Cannot set hotkey condition or implement command since no scope is active.`、202 `Cannot implement nonexistent hotkey command '$1'.`、203 `Hotkey command '$1' has an invalid KeyUp combination: at most one non-auxiliary key is allowed.`、204 `Duplicate hotkey command '$1', the existing command will be used.`、205 `Cannot leave nonexistent hotkey scope.`。**未获批前不落码**;若用户改的是文件名、两个类名或日志文案,先按新命名 / 新文案修订本计划(含 `files_modified`、门禁命令与结构闸门的逐字比对)再重新汇报。
⑨ **待批准命名与文案(须用户确认):** OI-1 测试文件(`__test__/command.test.ts` 与 `__test__/system.test.ts`);OI-2 导出辅助函数 `isAuxiliaryKey`;OI-3 内部接口 `IHotkeyScope` / `IHotkeyMatch` / `IHotkeyPending`(字段与类型见 OI-3 正文:`command` 是 `IHotkeyCommand` 对象引用,`last` / `fired` 入队时显式为 `0`,不含 `context`)、类型别名 `HotkeyHandler` / `HotkeyListener`、`HotkeyCommand` 私有字段 `command`(指令 id)与 **`combinations`(二维组合数组)**、`HotkeySystem` 私有字段 `commands` / `scopes` / `holding` / `pending` / `now` / `controller` / `keyDownListener` / `keyUpListener` 与私有方法 `getScope` / `createContext` / `resolveKeyCode` / `dispatch` / `runMatches` / `matchKeyDown` / `matchKeyUp` / `flushPending` / `dropPending`,Task 2 新增私有方法 **`isPendingDue`**;OI-4 四处语义确认(松键取消 KeyDown 类待触发项、同一事件同一指令只触发一次且取最小索引、空栈无参 `leaveScope()` 也发 205、延时项的 `context` 按触发时刻重算而非入队快照);**OI-5 五条日志文案逐字报备**:201 `Cannot set hotkey condition or implement command since no scope is active.`、202 `Cannot implement nonexistent hotkey command '$1'.`、203 `Hotkey command '$1' has an invalid KeyUp combination: at most one non-auxiliary key is allowed.`、204 `Duplicate hotkey command '$1', the existing command will be used.`、205 `Cannot leave nonexistent hotkey scope.`。**未获批前不落码**;**若用户改的是 OI-1 / OI-2 / OI-3 的任一标识符(文件名、`HotkeyCommand` / `HotkeySystem` 两个类名、导出函数 `isAuxiliaryKey`、OI-3 的内部接口 / 类型别名 / 私有字段 / 私有方法名)或 OI-5 的日志文案,一律先按新名字 / 新文案修订本计划**(`files_modified`、门禁命令里的字符串、测试代码中的名字、SUMMARY 关键字与结构闸门的逐字比对同步)**再重新汇报**;结构闸门断言 `isAuxiliaryKey`,MODE 闸门断言 `isPendingDue`,不修订就会在执行期被自家门禁拦下。
⑩ **不在本增量:** 不改旧实现、不改 `type.ts` 既有定义、不新增 201–205 以外的日志码、不改 `logger.json` 既有条目、不新增外部依赖、不新增 vitest 配置、不加存档序列化、不改构建脚本。
</action>
@ -214,18 +214,19 @@ Phase 9 · 第 23 个计划 —— **实现用户重新设计的按键系统**
<verify>
<automated>node -e "const fs=require('fs');const bad=[];const need=['packages/system/src/hotkey/type.ts','packages/system/src/hotkey/keyCodes.ts','packages/system/src/hotkey/hotkey.ts','packages/system/src/hotkey/keyboard.ts','packages/system/src/hotkey/index.ts','packages/system/package.json','packages/common/src/logger.json','packages/animate/src/types.ts','packages/animate/package.json','dev.md','AGENTS.md'];for(const p of need){if(!fs.existsSync(p))bad.push('missing '+p)}const type=fs.readFileSync('packages/system/src/hotkey/type.ts','utf8');if(/emit\s*\(/.test(type))bad.push('type.ts still declares an emit method - this plan expects it already removed at HEAD 6c19e1f');if(type.indexOf('interface IHotkeySystem')<0||type.indexOf('interface IHotkeyCommand')<0||type.indexOf('excitedBy')<0)bad.push('type.ts is not the expected hotkey design');const log=JSON.parse(fs.readFileSync('packages/common/src/logger.json','utf8'));for(const c of [201,202,203,204,205]){if(log.warn[c]!==undefined)bad.push('warn '+c+' already used')}if(!log.warn[200]||!log.warn[1001])bad.push('warn 200 or warn 1001 missing - logger.json is not the expected baseline');const pkg=JSON.parse(fs.readFileSync('packages/system/package.json','utf8'));if(pkg.dependencies&&pkg.dependencies['@motajs/animate'])bad.push('@motajs/animate already declared in packages/system');const anim=JSON.parse(fs.readFileSync('packages/animate/package.json','utf8'));if(anim.name!=='@motajs/animate')bad.push('animate package name mismatch');for(const p of ['packages/system/src/hotkey/command.ts','packages/system/src/hotkey/system.ts','packages/system/src/hotkey/__test__']){if(fs.existsSync(p))bad.push('must not exist yet: '+p)}const kc=fs.readFileSync('packages/system/src/hotkey/keyCodes.ts','utf8');if(kc.indexOf('EVENT_KEY_CODE_MAP')<0||kc.indexOf('export enum KeyCode')<0)bad.push('keyCodes.ts is not the expected baseline');if(bad.length){console.error('PRESTATE FAILED :: '+bad.join(' ; '));process.exit(1)}console.log('OK prestate: design present, emit already removed, warn 201-205 free, animate undeclared, new files absent')"</automated>
<fails_when>non-zero exit,或 stderr 含 "PRESTATE FAILED":必需文件缺失 / `type.ts` 含 `emit` 方法(说明用户又改了设计,需先核对本计划)/ `type.ts` 不是预期设计 / warn 201–205 已被占用 / warn 200 或 1001 缺失(`logger.json` 不是预期基线)/ `packages/system` 已声明 animate / animate 包名不符 / `command.ts`、`system.ts`、`__test__/` 已存在(说明实现已提前开始)/ `keyCodes.ts` 缺 `EVENT_KEY_CODE_MAP` 或 `KeyCode` 枚举</fails_when>
<human-check>用户明确回复「可以执行」;若就 OI-1 / OI-2 / OI-3 / OI-4 / OI-5 指定了不同命名、口径或文案,须先按新命名 / 新口径 / 新文案修订本计划(门禁命令、结构闸门的逐字比对、测试用例与 SUMMARY 关键字同步)并重新获批,未修订前不得执行</human-check>
<human-check>用户明确回复「可以执行」;若就 OI-1 / OI-2 / OI-3 / OI-4 / OI-5 指定了不同命名、口径或文案,须先按新命名 / 新口径 / 新文案修订本计划(门禁命令、结构闸门的逐字比对、测试用例与 SUMMARY 关键字同步)并重新获批,未修订前不得执行。**改名同样受此约束**:`isAuxiliaryKey`(OI-2)与 `isPendingDue` / `flushPending` / `dropPending`(OI-3)这些字符串写在结构闸门 / MODE 闸门与测试代码里,只改名不修计划会在执行期被自家门禁拦下</human-check>
</verify>
<acceptance_criteria>
- 用户回复中明确包含「可以执行」
- 汇报涵盖:要解决的问题、最小范围与「其余文件一律不动」清单、指令对象口径、系统口径(作用域 / 201 / 202 / 204 / 205)、触发口径(holding / KeyUp 四条规则 / KeyDown 子集 / 优先级与 block)、时序口径(激励驱动、无定时器、延时触发时重算 context)、验证方式、四项已知边界、OI-1 / OI-2 / OI-3 / OI-4 / OI-5
- 已逐字展示 OI-5 的五条文案(201–205),并说明它们写入 `logger.json` 的 `warn` 段 `"200"` 与 `"1001"` 之间、既有条目一字不动;说明缺少这些条目会让 `logger.warn` 退化成 `error 16`
- 已说明 `HotkeyCommand` 的私有组合数组命名为 `combinations`、Task 2 只新增 `isPendingDue` 一个私有方法(OI-3 清单已同步)
- 已说明 `IHotkeyPending` 的字段与类型(`command` 是 `IHotkeyCommand` 对象引用而非 id、`last` / `fired` 入队时显式为 `0`、不含 `context`),以及 `HotkeyListener` 类型别名用于 `keyDownListener` / `keyUpListener`
- 已说明 `type.ts` 当前**无 `emit` 成员**(HEAD `6c19e1f`),决策 2 以核验代替删除,核验后与 HEAD 逐字节一致
- 已说明 `IExcitable<number>` **只加在 `HotkeySystem` 类上**,`IHotkeySystem` 接口不新增成员
- 已说明旧实现 `hotkey.ts` / `keyboard.ts` **零改动**(含其模块顶层 `document` 监听)
- 已说明不接线用户 WIP 的 `IClientHotkey`、不跑全量 `build:game`、不修 legacy 类型错误、不做存档序列化
- 已说明若用户改文件名或两个类名,须先修订本计划再执行
- 已说明若用户改 OI-1 / OI-2 / OI-3 的任一标识符(含 `isAuxiliaryKey`、`isPendingDue`)或 OI-5 文案,须先修订本计划(门禁字符串、测试代码、SUMMARY 同步)再执行
- 已运行 `<automated>` 前置校验并通过(`OK prestate: ...`)
</acceptance_criteria>
<done>用户明确回复「可以执行」;在此之前未执行任何任务、未修改任何仓库文件、未派发任何执行子代理</done>
@ -272,26 +273,26 @@ Phase 9 · 第 23 个计划 —— **实现用户重新设计的按键系统**
- jsDoc:接口已声明的成员**不重复写**(dev.md 继承注释规则);只为新增的非接口成员与 `isAuxiliaryKey` 写注释。私有方法置于调用它的方法之前。
(5) **`packages/system/src/hotkey/system.ts`(新建):** 首行起连续 `import`(`@motajs/animate` 的 `IExcitable` / `IExcitableController` / `IExcitation`、`@motajs/common` 的 `logger`、`./command` 的 `HotkeyCommand` / `isAuxiliaryKey`、`./keyCodes` 的 `EVENT_KEY_CODE_MAP` / `KeyCode`、`./type` 的 `HotkeyEmitMode` / `HotkeyEmitType` / `IHotkeyCommand` / `IHotkeyCommandInit` / `IHotkeyEmitContext` / `IHotkeyEmitInfo` / `IHotkeySystem`)。
- 文件内声明内部 `interface IHotkeyScope`(`symbol` / `condition: (() => boolean) | null` / `handlers: Map<string, HotkeyHandler>`)、`interface IHotkeyMatch`(`id` / `command` / `index` / `keys` / `handler` / 可选 `entry`)、`interface IHotkeyPending`(字段见 frontmatter 的 OI-3:`id` / `command` / `index` / `keys` / `handler` / `type` / `key` / `scope` / `mode` / `timeout` / `interval` / `dt` / `time` / `last` / `fired`,**不含 `context`**)、类型别名 `HotkeyHandler`;**均不导出**。
- 导出 `class HotkeySystem implements IHotkeySystem, IExcitable<number>`:字段 `listening: HTMLElement | null = null`;私有 `commands: Map<string, IHotkeyCommand>`、`scopes: IHotkeyScope[]`、`holding: KeyCode[]`、`pending: IHotkeyPending[]`、`now: number`、`controller: IExcitableController<number> | null`、`keyDownListener` / `keyUpListener`(显式类型的箭头成员,内部调用统一的事件处理路径)。
- 文件内声明内部 `interface IHotkeyScope`(`symbol` / `condition: (() => boolean) | null` / `handlers: Map<string, HotkeyHandler>`)、`interface IHotkeyMatch`(`id` / `command` / `index` / `keys` / `handler` / 可选 `entry`)、`interface IHotkeyPending`(**字段与类型按 frontmatter 的 OI-3 逐字落地**:`id: string` / `command: IHotkeyCommand`(对象引用,不是 id)/ `index: number` / `keys: KeyCode[]` / `handler: HotkeyHandler` / `type: HotkeyEmitType` / `key: KeyCode` / `scope: symbol` / `mode: HotkeyEmitMode` / `timeout: number` / `interval: number` / `dt: number` / `time: number` / `last: number` / `fired: number`,**不含 `context`**)、类型别名 `HotkeyHandler` 与 `HotkeyListener`(`type HotkeyListener = (event: KeyboardEvent) => void`);**均不导出**。
- 导出 `class HotkeySystem implements IHotkeySystem, IExcitable<number>`:字段 `listening: HTMLElement | null = null`;私有 `commands: Map<string, IHotkeyCommand>`、`scopes: IHotkeyScope[]`、`holding: KeyCode[]`、`pending: IHotkeyPending[]`、`now: number`、`controller: IExcitableController<number> | null`、`keyDownListener` / `keyUpListener`(类型均为 `HotkeyListener`,内部调用统一的事件处理路径)。
- 指令注册表:`addCommand`(已存在 → warn 204 并返回已有对象;否则 `new HotkeyCommand` 存入并返回)、`getCommand`(缺失返回 `null`)、`deleteCommand`(删注册表条目,并清理各作用域 `handlers` 中该 id 与 `pending` 中该指令的项,不告警)、`iterateCommands`(生成器遍历注册表)。
- 作用域:`getScope()` 返回栈顶或 `null`(私有,置于调用它的方法之前);`enterScope()` 建新作用域(`condition` 为 `null`、空 `handlers`)压栈并返回其 `symbol`;`leaveScope(sym?)` 无参时弹栈顶(空栈 → warn 205)、有参时按 `symbol` 找到索引、把栈截到该索引 + 1(未找到 → warn 205),两种退出后都调用 `dropPending()`;`dropPending()` 丢弃 `pending` 中「作用域已不是当前栈顶」「指令当前 `mode` 与入队快照不同」「KeyDown 类模式组合已不被 `holding` 满足」的项(KeyUp 类延时项不受 `holding` 条件)。`when` / `implement` 无作用域 → warn 201(返回 `this`);`implement` 再校验 id 是否存在,缺失 → warn 202(返回 `this`)。
- `holding` 与上下文:`createContext(type, key)` 返回 `IHotkeyEmitContext`(`holding` 为当前数组拷贝,`ctrl` / `shift` / `alt` / `meta` 由 `holding` 推导)。
- 派发接缝:`dispatch(type, key)` 收集**当前栈顶作用域**内所有已注册、条件为真(`condition` 为 `null` 视为真)、且 `mode` 与 `type` 同类的匹配项(`matchKeyDown` / `matchKeyUp`,同一指令多组组合命中时取**最小索引**一条);按 `command.priority` 降序排序后交给 `runMatches`;`runMatches` 维护一个「已封锁优先级」值:某条指令执行(或入队)后若其 `block` 为真则记录其 `priority`,后续 `priority` 严格更低的匹配项直接结束循环(同优先级全部执行)。**匹配但无实现(不在栈顶作用域 `handlers` 中)的指令不得进入匹配集**,因此不阻断。`down(key)`:键已在 `holding` 中则返回 `false`;否则入 `holding` 后派发 KeyDown 类(本任务对 KeyDown 类返回 `false`,仅为 Task 2 预留);`up(key)`:键不在 `holding` 中直接返回 `false`;否则先从 `holding` 移除、再 `dropPending()`、再派发 KeyUp 类。
- `matchKeyUp(keys, key)`:按 `must_haves` 的四条规则(0 常规 + 恰好 1 辅助、1 常规且等于被抬起键且其余辅助键仍在 `holding`、≥2 常规非法、空组合不匹配)返回布尔。
- `matchKeyDown(keys)`:本任务先实现「空组合不匹配 + `keys` 每项都在 `holding` 中」的子集判定,供 Task 2 直接复用。
- `runMatches` 的执行分支本任务只需处理 `mode` 为 `KeyUp` 的情形:`timeout` 为 0 时当刻以 `{ command: id, index, keys, context: createContext(type, key) }` 调用 handler;`timeout` 大于 0 时入 `pending`(记录 `type` / `key` / `scope` / `mode` / `timeout` / `interval` / `dt` / `keys` / `index` / `handler`,`fired` 0、`time` 为当前 `now`,**不记录 `context`**),返回值计入「将要延时触发」。其余模式的执行分支留空(返回 `false`),不得写成会误触发的实现。
- `runMatches` 的执行分支本任务只需处理 `mode` 为 `KeyUp` 的情形:`timeout` 为 0 时当刻以 `{ command: id, index, keys, context: createContext(type, key) }` 调用 handler(此处 `command` 是 `IHotkeyEmitInfo.command`,即 id 字符串,不要与待触发项的 `command` 字段混淆);`timeout` 大于 0 时入 `pending`,**入队对象字面量必须恰好是这些字段**:`{ id: <指令 id 字符串>, command: <IHotkeyCommand 对象引用>, index, keys: <组合拷贝>, handler, type, key, scope: <当前栈顶 symbol>, mode: <入队时 command.mode 快照>, timeout: <快照>, interval: <快照>, dt: <快照>, time: this.now, last: 0, fired: 0 }`(`id` 与 `command` 都要写:`id` 供日志 / 匹配信息,`command` 供 `flushPending` 读 `priority` 与 `dropPending` 比对 `mode`;`last` / `fired` 显式写 `0`,禁止留 `undefined`;**不记录 `context`**),返回值计入「将要延时触发」。其余模式的执行分支留空(返回 `false`),不得写成会误触发的实现。
- `excitedBy(excitation)`:先 `revoke` 旧控制器(若有)再 `excitation.add(this)`,保存返回的控制器(可能为 `null`);不保存激励源对象本身。`excited(payload)`:记录 `now`(本任务不做待触发处理,函数体只更新时间;Task 2 在同一位置扩展)。
- `listen(target)`:与 `listening` 相同则空操作;否则先解绑旧元素的两个监听、再设置 `listening`、再为新元素(非 `null` 时)注册;两个处理函数共用一个 `resolveKeyCode(event.keyCode)` 私有判定:**先按 `91` / `92` / `224` → `KeyCode.Meta` 做本地映射**(`EVENT_KEY_CODE_MAP` 没有 Meta 的事件码,且 `keyCodes.ts` 零改动),否则取 `EVENT_KEY_CODE_MAP[event.keyCode]`;`keydown` 处理跳过 `event.repeat`、无映射(`undefined` 与 `0`)跳过、`down` 返回真时 `preventDefault()`;`keyup` 同理调用 `up`。(`resolveKeyCode` 属 OI-3 清单内的 Task 1 私有方法。)
- jsDoc:只为新增的非接口成员写注释;接口成员不重复注释。不得出现循环引用(`command.ts` 不引入 `system.ts`)。
(6) **`packages/system/src/hotkey/index.ts`:** 在既有 4 行导出中按字母序插入 `export * from './command';`(首位)与 `export * from './system';`(`./keyCodes` 之后),其余行不动。
(7) **`packages/system/src/hotkey/__test__/command.test.ts`(新建):** 文件头一行注释说明覆盖范围;**`import { HotkeyCommand, isAuxiliaryKey } from '../command';`——只从实现模块导入,禁止 `../index`**(`hotkey.ts` 在模块顶层执行 `document.addEventListener`,Node 下导入即抛错);`vi.hoisted` 中 stub `main` / `location`(沿用既有测试约定);`afterEach` 恢复 spy、`afterAll` 还原全局。用例(每个 `it` 前一行注释):`isAuxiliaryKey` 对 `Ctrl` / `Shift` / `Alt` / `Meta` 返回真、对常规键返回假;缺省值与 `defaults` 深拷贝;`setKeys` 扩容与缩容;`setKeyCombination` 越界忽略与返回副本;`iterateKeyCombinations` 顺序与拷贝;`mode` 为 `KeyUp` 时非法组合 warn 203 一次且组合仍可读;`mode` 为 `KeyUp` 时「单常规键 + 辅助键」不告警;`keyUp()` 切换 `mode` 并对已有非法组合发一次 203;四个模式方法分别设置 `mode` 与 `timeout` / `interval` / `dt`、未传参时保留现值。断言一律 `vi.spyOn(logger, 'warn')` 校验码与参数。
(7) **`packages/system/src/hotkey/__test__/command.test.ts`(新建):** 文件头一行注释说明覆盖范围;**`import { HotkeyCommand, isAuxiliaryKey } from '../command';`——只从实现模块导入,禁止 `../index`**(`hotkey.ts` 在模块顶层执行 `document.addEventListener`,Node 下导入即抛错);`vi.hoisted` 中 stub `main` / `location`(沿用既有测试约定);`afterEach` 恢复 spy、`afterAll` 还原全局。用例(每个 `it` 前一行注释):`isAuxiliaryKey` 对 `Ctrl` / `Shift` / `Alt` / `Meta` 返回真、对常规键返回假;缺省值与 `defaults` 深拷贝;`setKeys` 扩容与缩容、负值 / 非整数被忽略(`length` 不变);`setKeyCombination` 越界忽略与返回副本;`iterateKeyCombinations` 顺序与拷贝;`mode` 为 `KeyUp` 时非法组合 warn 203 一次且组合仍可读;`mode` 为 `KeyUp` 时「单常规键 + 辅助键」不告警;`keyUp()` 切换 `mode` 并对已有非法组合发一次 203;四个模式方法分别设置 `mode` 与 `timeout` / `interval` / `dt`、未传参时保留现值。断言一律 `vi.spyOn(logger, 'warn')` 校验码与参数。
(8) **`packages/system/src/hotkey/__test__/system.test.ts`(新建):** 文件头一行注释;**`import { HotkeySystem } from '../system';`——禁止 `../index`**;构造 fake 元素(`addEventListener` / `removeEventListener` 记录处理器)与伪事件(`keyCode` / `repeat` / `preventDefault`)。用例(每个 `it` 前一行注释):无作用域时 `when` / `implement` 各发 201 且 `when` 返回 `this`;`implement` 未注册指令发 202;重复 `addCommand` 发 204 且返回已有对象、注册表大小不变;`deleteCommand` 后 `getCommand` 返回 `null` 且实现被清理;`iterateCommands` 遍历;`enterScope` 返回 `symbol`、`leaveScope()` 回上一级、`leaveScope(sym)` 保留 `sym` 并删除其上方作用域、未知 `symbol` 发 205、空栈无参发 205;子作用域激活时父作用域的条件与实现都不生效、退出后恢复;KeyUp 四条规则(单常规 + 辅助、0 常规 + 1 辅助、0 常规 + 2 辅助不触发、≥2 常规永不触发);`ctx` 的 `type` / `key` / `holding`(KeyUp 不含被抬起键)/ `ctrl` 等布尔;优先级降序、`block` 阻断严格更低、同优先级全部执行、匹配但无实现不阻断;`down` / `up` 的布尔返回;`listen` 的映射、`repeat` 跳过、`preventDefault` 只在触发时调用、`listen(null)` 解绑;**`listen` 把 `keyCode` `91` / `92` / `224` 映射为 `KeyCode.Meta`(`holding` 含 Meta、`ctx.meta` 为真,且不依赖改 `keyCodes.ts`)**。**KeyDown 类模式的触发与时序不在本任务断言范围内。**
(8) **`packages/system/src/hotkey/__test__/system.test.ts`(新建):** 文件头一行注释;**`import { HotkeySystem } from '../system';`——禁止 `../index`**;构造 fake 元素(`addEventListener` / `removeEventListener` 记录处理器)与伪事件(`keyCode` / `repeat` / `preventDefault`)。用例(每个 `it` 前一行注释):无作用域时 `when` / `implement` 各发 201 且 `when` 返回 `this`;`implement` 未注册指令发 202;重复 `addCommand` 发 204 且返回已有对象、注册表大小不变;`deleteCommand` 后 `getCommand` 返回 `null` 且实现被清理;`iterateCommands` 遍历;`enterScope` 返回 `symbol`、`leaveScope()` 回上一级、`leaveScope(sym)` 保留 `sym` 并删除其上方作用域、未知 `symbol` 发 205、空栈无参发 205;子作用域激活时父作用域的条件与实现都不生效、退出后恢复;KeyUp 四条规则(单常规 + 辅助、0 常规 + 1 辅助、0 常规 + 2 辅助不触发、≥2 常规永不触发);`ctx` 的 `type` / `key` / `holding`(KeyUp 不含被抬起键)/ `ctrl` 等布尔;优先级降序、`block` 阻断严格更低、同优先级全部执行、匹配但无实现不阻断;`down` / `up` 的布尔返回;`listen` 的映射、`repeat` 跳过、`preventDefault` 只在触发时调用、`listen(null)` 解绑;**`listen` 把 `keyCode` `91` / `92` / `224` 映射为 `KeyCode.Meta`(`holding` 含 Meta、`ctx.meta` 为真,且不依赖改 `keyCodes.ts`)**;**`getCommand` 对未注册 id 返回 `null`**;**栈顶作用域 `when(() => false)` 时按下 / 抬起均不触发实现**;**空组合(`setKeyCombination(0, [])`)在 KeyUp 时不触发且不产生 warn 203**。**KeyDown 类模式的触发与时序不在本任务断言范围内。**
(9) 全部新文件以 CRLF 保存(`.prettierrc` 的 `endOfLine`),并在写完后对改动文件执行一次 `cmd /c "pnpm exec prettier --write <文件>"`。
(9) 全部新文件以 CRLF 保存(`.prettierrc` 的 `endOfLine`),写完后**只对下列 5 个 TS 文件**执行一次 `cmd /c "pnpm exec prettier --write packages/system/src/hotkey/command.ts packages/system/src/hotkey/system.ts packages/system/src/hotkey/index.ts packages/system/src/hotkey/__test__/command.test.ts packages/system/src/hotkey/__test__/system.test.ts"`。**不得把 `packages/common/src/logger.json`、`packages/system/package.json`、`pnpm-lock.yaml` 传给 prettier**(三者都不在 `.prettierignore` 里:prettier 会按 `tabWidth: 4` 重排 `logger.json` 的缩进 / 键序,把 lockfile 整体重写,从而违反「既有条目一字不动」与「lockfile 只由 pnpm 生成」两条约束)。
</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>
@ -300,12 +301,14 @@ Phase 9 · 第 23 个计划 —— **实现用户重新设计的按键系统**
<fails_when>non-zero exit:存在 eslint 报告(含未使用变量、格式、`no-console`)</fails_when>
<automated>node -e "const fs=require('fs');const bad=[];const strip=p=>fs.readFileSync(p,'utf8').split(/\r?\n/).filter(l=>!/^\s*(\/\/|\*|\/\*)/.test(l)).join('\n');const cmdSrc=fs.readFileSync('packages/system/src/hotkey/command.ts','utf8');const sysSrc=fs.readFileSync('packages/system/src/hotkey/system.ts','utf8');if(!/class HotkeyCommand[\s\S]{0,40}implements IHotkeyCommand/.test(cmdSrc))bad.push('command.ts must declare class HotkeyCommand implementing IHotkeyCommand');if(!/class HotkeySystem[\s\S]{0,80}implements IHotkeySystem,\s*IExcitable<number>/.test(sysSrc))bad.push('system.ts must declare class HotkeySystem implementing IHotkeySystem and IExcitable<number> on the class');const idx=fs.readFileSync('packages/system/src/hotkey/index.ts','utf8');for(const m of ['./command','./system','./hotkey','./keyboard','./keyCodes','./type']){if(idx.indexOf(m)<0)bad.push('index.ts missing export for '+m)}const pkg=JSON.parse(fs.readFileSync('packages/system/package.json','utf8'));if(!pkg.dependencies||pkg.dependencies['@motajs/animate']!=='workspace:*')bad.push('packages/system must depend on @motajs/animate workspace');const lock=fs.readFileSync('pnpm-lock.yaml','utf8');const seg=lock.slice(lock.indexOf('packages/system:'),lock.indexOf('packages/types:'));if(seg.indexOf('@motajs/animate')<0)bad.push('pnpm-lock.yaml packages/system importer lacks animate');for(const p of ['packages/system/src/hotkey/command.ts','packages/system/src/hotkey/system.ts']){const c=strip(p);if(/(^|[^A-Za-z])import\s+type\s/.test(c))bad.push('type-only import in '+p);if(/(^|[^A-Za-z])(set)(Timeout|Interval)\s*\(/.test(c))bad.push('browser timer used in '+p);if(c.indexOf('Date.now(')>=0)bad.push('Date.now used in '+p)}for(const c of [201,202,203,204,205]){if(cmdSrc.indexOf('warn('+c)<0&&sysSrc.indexOf('warn('+c)<0)bad.push('warn '+c+' not referenced by command.ts or system.ts')}const log=JSON.parse(fs.readFileSync('packages/common/src/logger.json','utf8'));const D=String.fromCharCode(36),Q=String.fromCharCode(39);const want={201:'Cannot set hotkey condition or implement command since no scope is active.',202:'Cannot implement nonexistent hotkey command '+Q+D+'1'+Q+'.',203:'Hotkey command '+Q+D+'1'+Q+' has an invalid KeyUp combination: at most one non-auxiliary key is allowed.',204:'Duplicate hotkey command '+Q+D+'1'+Q+', the existing command will be used.',205:'Cannot leave nonexistent hotkey scope.'};for(const c of [201,202,203,204,205]){if(log.warn[c]!==want[c])bad.push('logger.json warn '+c+' missing or text mismatch')}if(typeof log.warn[200]!=='string'||log.warn[200].indexOf('Cannot buy or sell item since target item')!==0)bad.push('logger.json warn 200 was changed');if(typeof log.warn[1001]!=='string'||log.warn[1001].indexOf('Event(setBlock): Unknown tile')!==0)bad.push('logger.json warn 1001 was changed');if(Object.keys(log.warn).length!==206)bad.push('logger.json warn key count is '+Object.keys(log.warn).length+', expected 206');if(sysSrc.indexOf('KeyCode.Meta')<0)bad.push('system.ts must map keyCode 91/92/224 to KeyCode.Meta inside listen');const tests=['packages/system/src/hotkey/__test__/command.test.ts','packages/system/src/hotkey/__test__/system.test.ts'];const cmdTest=fs.existsSync(tests[0])?fs.readFileSync(tests[0],'utf8'):'';const sysTest=fs.existsSync(tests[1])?fs.readFileSync(tests[1],'utf8'):'';if(cmdTest&&(cmdTest.indexOf('../command')<0||cmdTest.indexOf('HotkeyCommand')<0||cmdTest.indexOf('isAuxiliaryKey')<0))bad.push('command.test.ts must import HotkeyCommand and isAuxiliaryKey from ../command');if(sysTest&&(sysTest.indexOf('../system')<0||sysTest.indexOf('HotkeySystem')<0))bad.push('system.test.ts must import HotkeySystem from ../system');if(cmdTest&&sysTest&&(cmdTest.indexOf('../index')>=0||sysTest.indexOf('../index')>=0))bad.push('tests must not import ../index (hotkey.ts registers document listeners at module scope)');for(const p of tests){if(!fs.existsSync(p)){bad.push('missing '+p);continue}const lines=fs.readFileSync(p,'utf8').split(/\r?\n/);lines.forEach((l,i)=>{if(/(^|[^A-Za-z.])it\(/.test(l)){let j=i-1;while(j>=0&&lines[j].trim()==='')j--;if(j<0||!lines[j].trim().startsWith('//'))bad.push(p+':'+(i+1)+' it() without a preceding comment line')}})}if(bad.length){console.error('STRUCTURE GATE FAILED :: '+bad.join(' ; '));process.exit(1)}console.log('OK structure: classes/exports/dependency/logger-201-205/tests');"</automated>
<fails_when>non-zero exit,或 stderr 含 "STRUCTURE GATE FAILED":两个类的 `implements` 形态不符 / `index.ts` 导出缺失 / `package.json` 未声明 animate / lockfile 未更新 / 类型专用导入 / 浏览器定时器 / `Date.now` / `logger.json` 的 201–205 文案不符或缺条 / `200` / `1001` 被改 / warn 键数不是 206 / `system.ts` 没有 `KeyCode.Meta` 映射 / 201–205 未全部被代码引用 / 测试文件缺失 / 测试未从 `../command` / `../system` 导入或误用 `../index` / 某 `it` 前没有注释行</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.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);if(bad.length){console.error('TYPECHECK FAILED :: '+bad.join(' ; '));process.exit(1)}console.log('OK hotkey files type-check clean (vue-tsc exit '+out.status+'); 2 pre-existing legacy errors in hotkey.ts/keyboard.ts are out of scope')"</automated>
<fails_when>non-zero exit,或 stderr 含 "TYPECHECK FAILED":`vue-tsc` 没能正常跑完(`spawnSync` 报 `out.error` / `out.status === null` / 被信号终止),或非零退出且没有任何输出(命令没真正执行),或 `command.ts` / `system.ts` / `index.ts` / `__test__/**` 存在类型错误(`hotkey.ts` / `keyboard.ts` 的 2 条既有 legacy 错误不在此列)</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.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 files type-check clean (vue-tsc exit '+out.status+'); 2 pre-existing legacy errors in hotkey.ts/keyboard.ts are out of scope')"</automated>
<fails_when>non-zero exit,或 stderr 含 "TYPECHECK FAILED":`vue-tsc` 没能正常跑完(`spawnSync` 报 `out.error` / `out.status === null` / 被信号终止),或非零退出且没有任何输出(命令没真正执行),或 `command.ts` / `system.ts` / `index.ts` / `__test__/**` 存在类型错误(`hotkey.ts` / `keyboard.ts` 的 2 条既有 legacy 错误不在此列),**或 `hotkey.ts` / `keyboard.ts` 的 legacy 诊断数不等于 2(输出不是已知基线,说明 `vue-tsc` 没有真正产出完整结果,属假绿)**</fails_when>
<automated>node -e "const cp=require('child_process');const r=cp.spawnSync('git',['status','--porcelain','--','packages/system/src/hotkey/hotkey.ts','packages/system/src/hotkey/keyboard.ts','packages/system/src/hotkey/type.ts'],{encoding:'utf8'});const out=(r.stdout||'').trim();if(r.status!==0||out.length>0){console.error('LEGACY GATE FAILED :: status='+r.status+' dirty='+out);process.exit(1)}console.log('OK legacy hotkey.ts/keyboard.ts/type.ts unchanged vs HEAD')"</automated>
<fails_when>non-zero exit,或 stderr 含 "LEGACY GATE FAILED":旧实现或 `type.ts` 在工作树中被改动(`type.ts` 预期零改动,因接口已无 `emit`);本闸门刻意针对「未提交工作树 vs HEAD」,退出码由 `spawnSync` 显式捕获,不依赖管道最后一段状态</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-23-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/common/src/logger.json')<0)bad.push('allowlist derived from files_modified must include logger.json');const wipAny=['packages-user/','docs/','.planning/','src/client/misc/'];const lines=cp.execSync('git status --porcelain',{encoding:'utf8'}).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)))continue;if(wipAny.some(a=>p.startsWith(a)))continue;bad.push('out-of-scope change: '+p)}for(const p of ['packages/system/src/hotkey/hotkey.ts','packages/system/src/hotkey/keyboard.ts','packages/system/src/hotkey/type.ts','packages/system/src/hotkey/keyCodes.ts']){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 (Task 1, before commit): every change matches files_modified ('+allow.length+' entries, user WIP excepted)')"</automated>
<fails_when>non-zero exit,或 stderr 含 "SCOPE GATE FAILED":Task 1 提交前工作树出现 `files_modified` 之外的改动(白名单由本计划 `files_modified` 派生、含 `logger.json`;例外只有用户既有 WIP 目录 `packages-user/`、`docs/`、`src/client/misc/` 与流程产物 `.planning/`),或旧实现 / `type.ts` / `keyCodes.ts` 被修改。此闸门与 Task 2 末尾的 `SCOPE GATE` 相同,用于把「Task 1 的提交」也覆盖进范围校验</fails_when>
</verify>
<done>两个测试文件全绿;eslint、结构闸门、按文件过滤的类型闸门与「旧实现 / `type.ts` 零改动」闸门全部通过;`HotkeyCommand` 与 `HotkeySystem` 已从 `hotkey/index.ts` 导出,`@motajs/animate` 已声明且 lockfile 已更新;`logger.json` 已按 OI-5 追加 201–205 且 `200` / `1001` 原样;KeyUp 热键端到端可用(含作用域、201 / 202 / 203 / 204 / 205、优先级与 block、`listen` 与 Meta 映射)</done>
<done>两个测试文件全绿;eslint、结构闸门、按文件过滤的类型闸门、「旧实现 / `type.ts` 零改动」闸门与提交前的范围闸门全部通过;`HotkeyCommand` 与 `HotkeySystem` 已从 `hotkey/index.ts` 导出,`@motajs/animate` 已声明且 lockfile 已更新;`logger.json` 已按 OI-5 追加 201–205 且 `200` / `1001` 原样;KeyUp 热键端到端可用(含作用域、201 / 202 / 203 / 204 / 205、优先级与 block、`listen` 与 Meta 映射);prettier 只作用于 5 个 TS 文件,`logger.json` / `package.json` / `pnpm-lock.yaml` 未被 prettier 重排</done>
</task>
<task type="auto">
@ -325,11 +328,11 @@ Phase 9 · 第 23 个计划 —— **实现用户重新设计的按键系统**
(2) **`runMatches` 的执行分支(按模式与 `timeout`):**
- `timeout` 为 0:当刻以 `{ command: id, index, keys, context: createContext(type, key) }` 调用 handler;计数为「已触发」。
- `timeout` 大于 0:入 `pending`(`fired` 0、`time` 为当前 `now`,记录 `mode` / `timeout` / `interval` / `dt` / 作用域 `symbol` / `handler` / `type` / `key` / `keys` / `index` / `id`,**不记录 `context`**),计数为「将要延时触发」。返回值因此为真。
- 立即触发的后续项:`Once` 不入队(只此一次);`KeyUp` 不入队(Task 1 行为不变);`Continuous` / `Throttle` 入队且 `fired` 置 1、`last` 置当前 `now`,以便第二次及之后由激励推进。
- `timeout` 大于 0:入 `pending`,**入队对象字面量与 Task 1 完全一致**:`{ id: <指令 id 字符串>, command: <IHotkeyCommand 对象引用>, index, keys: <组合拷贝>, handler, type, key, scope: <当前栈顶 symbol>, mode: <入队时 command.mode 快照>, timeout: <快照>, interval: <快照>, dt: <快照>, time: this.now, last: 0, fired: 0 }`(`last` / `fired` 显式写 `0`,**不记录 `context`**),计数为「将要延时触发」。返回值因此为真。
- 立即触发的后续项:`Once` 不入队(只此一次);`KeyUp` 不入队(Task 1 行为不变);`Continuous` / `Throttle` 用**同一个对象字面量形状**入队,但 `fired: 1`、`time: this.now`、`last: this.now`(`time` 保留入队时刻,`isPendingDue` 在 `fired === 1` 时只看 `last`),以便第二次及之后由激励推进。
- `block` 记录逻辑不变(同一批次里执行或入队都算「实际触发」)。
(3) **`excited(payload)`:** 更新 `now` 后调用 `flushPending()`;`flushPending` 先 `dropPending()`,再把到期项按其 `command.priority` 降序组成一批(同优先级全部执行,`block` 阻断严格更低优先级,规则与 `dispatch` 一致)。**执行时用「当前 `holding`」重新构造 `IHotkeyEmitInfo.context`(`createContext(item.type, item.key)`),绝不使用入队时的 `context` 快照**——对齐 `type.ts` 第 29 行「当前所有正在按下的按键,按照按下的顺序排列」:延时期间新按下的键会出现在 `holding` 里,已松开的键不会。到期判定抽成私有方法 **`isPendingDue(item, payload)`**(OI-3):`fired` 为 0 → `payload - time >= timeout`;`fired` 为 1 → `payload - last >= interval`;`fired` 大于等于 1 时 `Continuous` 之后每个 `excited` 都触发、`Throttle` 需 `payload - last >= dt`。执行后更新 `last` 与 `fired`;`Once` 与 `KeyUp` 模式的项在触发后从 `pending` 移除,`Continuous` / `Throttle` 保留直到被 `dropPending` 丢弃。**时间只能来自 `payload`,不得引入浏览器定时器或 `Date.now()`。**
(3) **`excited(payload)`:** 更新 `now` 后调用 `flushPending()`;`flushPending` 先 `dropPending()`,再把到期项按其 `item.command.priority` 降序组成一批(`command` 是 `IHotkeyCommand` 对象引用,不是 id 字符串;同优先级全部执行,`block` 阻断严格更低优先级,规则与 `dispatch` 一致)。**执行时用「当前 `holding`」重新构造 `IHotkeyEmitInfo.context`(`createContext(item.type, item.key)`),绝不使用入队时的 `context` 快照**——对齐 `type.ts` 第 29 行「当前所有正在按下的按键,按照按下的顺序排列」:延时期间新按下的键会出现在 `holding` 里,已松开的键不会。到期判定抽成私有方法 **`isPendingDue(item, payload)`**(OI-3):`fired` 为 0 → `payload - time >= timeout`;`fired` 为 1 → `payload - last >= interval`;`fired` 大于等于 1 时 `Continuous` 之后每个 `excited` 都触发、`Throttle` 需 `payload - last >= dt`。执行后更新 `last` 与 `fired`;`Once` 与 `KeyUp` 模式的项在触发后从 `pending` 移除,`Continuous` / `Throttle` 保留直到被 `dropPending` 丢弃。**时间只能来自 `payload`,不得引入浏览器定时器或 `Date.now()`。**
(4) **`dropPending` 扩展:** 在 Task 1 的三条丢弃条件之外,确保 `up` 时对整个 `holding` 重新评估(KeyDown 类项的组合必须仍被 `holding` 满足;KeyUp 类延时项不受 `holding` 限制),并在 `enterScope` / `leaveScope` 之后同样调用(栈顶变化即丢弃旧作用域项)。
@ -348,10 +351,10 @@ Phase 9 · 第 23 个计划 —— **实现用户重新设计的按键系统**
<fails_when>non-zero exit:存在 eslint 报告</fails_when>
<automated>node -e "const fs=require('fs');const bad=[];const strip=p=>fs.readFileSync(p,'utf8').split(/\r?\n/).filter(l=>!/^\s*(\/\/|\*|\/\*)/.test(l)).join('\n');const c=strip('packages/system/src/hotkey/system.ts');for(const m of ['HotkeyEmitMode.Once','HotkeyEmitMode.Continuous','HotkeyEmitMode.Throttle']){if(c.indexOf(m)<0)bad.push('system.ts does not reference '+m)}if(/(^|[^A-Za-z])(set)(Timeout|Interval)\s*\(/.test(c))bad.push('browser timer used in system.ts');if(c.indexOf('Date.now(')>=0)bad.push('Date.now used in system.ts');if(/(^|[^A-Za-z])import\s+type\s/.test(c))bad.push('type-only import in system.ts');if(c.indexOf('flushPending')<0||c.indexOf('dropPending')<0||c.indexOf('isPendingDue')<0)bad.push('pending lifecycle helpers missing (flushPending/dropPending/isPendingDue)');const lines=fs.readFileSync('packages/system/src/hotkey/__test__/system.test.ts','utf8').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<20)bad.push('system.test.ts has only '+its+' it() cases, expected at least 20 covering both tasks');if(bad.length){console.error('MODE GATE FAILED :: '+bad.join(' ; '));process.exit(1)}console.log('OK mode gate: three KeyDown modes wired, excitation-driven timing, '+its+' system test cases')"</automated>
<fails_when>non-zero exit,或 stderr 含 "MODE GATE FAILED":三类 KeyDown 模式未被引用 / 出现浏览器定时器或 `Date.now` / 类型专用导入 / 待触发生命周期方法(`flushPending` / `dropPending` / `isPendingDue`)缺失 / `it` 前无注释 / 用例少于 20 条</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.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);if(bad.length){console.error('TYPECHECK FAILED :: '+bad.join(' ; '));process.exit(1)}console.log('OK hotkey files type-check clean (vue-tsc exit '+out.status+')')"</automated>
<fails_when>non-zero exit,或 stderr 含 "TYPECHECK FAILED":`vue-tsc` 没能正常跑完(`out.error` / `out.status === null` / 信号终止),或非零退出且无任何输出(命令未真正执行),或本计划文件出现类型错误</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-23-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/common/src/logger.json')<0)bad.push('allowlist derived from files_modified must include logger.json');const wipAny=['packages-user/','docs/','.planning/'];const lines=cp.execSync('git status --porcelain',{encoding:'utf8'}).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)))continue;if(wipAny.some(a=>p.startsWith(a)))continue;bad.push('out-of-scope change: '+p)}for(const p of ['packages/system/src/hotkey/hotkey.ts','packages/system/src/hotkey/keyboard.ts','packages/system/src/hotkey/type.ts','packages/system/src/hotkey/keyCodes.ts']){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, user WIP excepted)')"</automated>
<fails_when>non-zero exit,或 stderr 含 "SCOPE GATE FAILED":工作树出现 `files_modified` 之外的改动(白名单从本计划 `files_modified` 派生,含 `logger.json`;显式例外为用户既有 WIP 目录 `packages-user/`、`docs/`,以及流程产物 `.planning/`),或旧实现 / `type.ts` / `keyCodes.ts` 被修改,或白名单里缺 `logger.json`。`packages/**` 与仓库根文件不在任何例外内,因此计划外的包改动一定会被拦下</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.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 files type-check clean (vue-tsc exit '+out.status+')')"</automated>
<fails_when>non-zero exit,或 stderr 含 "TYPECHECK FAILED":`vue-tsc` 没能正常跑完(`out.error` / `out.status === null` / 信号终止),或非零退出且无任何输出(命令未真正执行),或本计划文件出现类型错误,**或 `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-23-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/common/src/logger.json')<0)bad.push('allowlist derived from files_modified must include logger.json');const wipAny=['packages-user/','docs/','.planning/','src/client/misc/'];const lines=cp.execSync('git status --porcelain',{encoding:'utf8'}).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)))continue;if(wipAny.some(a=>p.startsWith(a)))continue;bad.push('out-of-scope change: '+p)}for(const p of ['packages/system/src/hotkey/hotkey.ts','packages/system/src/hotkey/keyboard.ts','packages/system/src/hotkey/type.ts','packages/system/src/hotkey/keyCodes.ts']){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, user WIP excepted)')"</automated>
<fails_when>non-zero exit,或 stderr 含 "SCOPE GATE FAILED":工作树出现 `files_modified` 之外的改动(白名单从本计划 `files_modified` 派生,含 `logger.json`;显式例外为用户既有 WIP 目录 `packages-user/`、`docs/`、`src/client/misc/`(用户尚未提交的热键 WIP,本计划不接线),以及流程产物 `.planning/`),或旧实现 / `type.ts` / `keyCodes.ts` 被修改,或白名单里缺 `logger.json`。`packages/**`、`src/**`(除上述 WIP 目录)与仓库根文件不在任何例外内,因此计划外的包改动一定会被拦下</fails_when>
</verify>
<done>`Once` / `Continuous` / `Throttle` 三类按下模式与 `timeout` / `interval` / `dt` 全部由激励源 payload 驱动且无定时器;待触发项在松键、作用域退出、模式切换时被正确丢弃;**延时触发时 `context` 按当时 `holding` 重算(无入队快照)**;`down` / `up` 返回值含入队场景;`system.test.ts` 至少 20 条用例全绿;eslint、`MODE GATE`、类型闸门与范围闸门全部通过</done>
</task>
@ -388,8 +391,8 @@ Phase 9 · 第 23 个计划 —— **实现用户重新设计的按键系统**
<verification>
- **Task 0**:`PRESTATE` 闸门通过(含 `warn 200` / `warn 1001` 均在、201–205 空闲);用户明确回复「可以执行」(含 OI-1 / OI-2 / OI-3 / OI-4 / OI-5 的裁定)。
- **Task 1**:`command.test.ts` + `system.test.ts` 全绿;eslint;结构闸门(类 `implements` 形态、`index.ts` 六个导出、animate 依赖与 lockfile、无类型专用导入 / 无定时器 / 无 `Date.now`、**`logger.json` 201–205 逐字文案 + `200` / `1001` 完好 + warn 键数 206**、`system.ts` 含 `KeyCode.Meta` 映射、测试只从 `../command` / `../system` 导入且禁 `../index`、`it` 前注释);按文件过滤的类型闸门(先校验 `spawnSync` 的 `out.error` / `out.status` / `out.signal`);`hotkey.ts` / `keyboard.ts` / `type.ts` 零改动。
- **Task 2**:两个测试文件全绿(`system.test.ts` 不少于 20 条用例);eslint;`MODE GATE`(三类 KeyDown 模式接线、激励驱动、`flushPending` / `dropPending` / `isPendingDue`);类型闸门(同样校验命令是否正常执行);`SCOPE GATE`(白名单由 `files_modified` 派生、含 `logger.json`,用户既有 WIP 例外)。
- **Task 1**:`command.test.ts` + `system.test.ts` 全绿;eslint;结构闸门(类 `implements` 形态、`index.ts` 六个导出、animate 依赖与 lockfile、无类型专用导入 / 无定时器 / 无 `Date.now`、**`logger.json` 201–205 逐字文案 + `200` / `1001` 完好 + warn 键数 206**、`system.ts` 含 `KeyCode.Meta` 映射、测试只从 `../command` / `../system` 导入且禁 `../index`、`it` 前注释);按文件过滤的类型闸门(先校验 `spawnSync` 的 `out.error` / `out.status` / `out.signal`,并断言 `hotkey.ts` / `keyboard.ts` 的 legacy 诊断恰好 2 条,防假绿);`hotkey.ts` / `keyboard.ts` / `type.ts` 零改动;**Task 1 提交前的 `SCOPE GATE`(与 Task 2 同一份、白名单由 `files_modified` 派生)**;prettier 只作用于 5 个 TS 文件,`logger.json` / `package.json` / `pnpm-lock.yaml` 绝不传给 prettier。
- **Task 2**:两个测试文件全绿(`system.test.ts` 不少于 20 条用例);eslint;`MODE GATE`(三类 KeyDown 模式接线、激励驱动、`flushPending` / `dropPending` / `isPendingDue`);类型闸门(同样校验命令是否正常执行、legacy 诊断恰好 2 条);`SCOPE GATE`(白名单由 `files_modified` 派生、含 `logger.json`,用户既有 WIP 例外)。
- **范围诚实**:不跑 `build:game` / `build:packages` / `check:type` 的修复动作;不修 `hotkey.ts` / `keyboard.ts` 的 legacy 类型错误;不接线 `packages-user/**`;SUMMARY 须如实记录这三条边界,不得表述为「浏览器端已可用」;**`estimate.tokens=95000` 是未校准估算(`confidence: low`),SUMMARY 须显式声明**。
- **既有注释**:`type.ts` / `hotkey.ts` / `keyboard.ts` / `keyCodes.ts` 的既有 jsDoc 与注释零修改。
</verification>