cowork-flow 1.5.0 → 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/CHANGELOG.md +49 -1
  2. package/README.md +24 -5
  3. package/package.json +2 -2
  4. package/presets/kimi-code/hooks/cowork-flow-inject.mjs +113 -0
  5. package/src/cli.js +6 -0
  6. package/src/commands/install-kimi-hook.js +343 -0
  7. package/src/lib/host-assets.js +1 -10
  8. package/template/.cowork-flow/.version +1 -1
  9. package/template/.cowork-flow/adapters/kimi-code/adapter.yaml +31 -0
  10. package/template/.cowork-flow/scripts/adapters/cli/spec_check.py +1 -1
  11. package/template/.cowork-flow/scripts/adapters/cli/subagent.py +38 -23
  12. package/template/.cowork-flow/scripts/adapters/cli/task.py +0 -3
  13. package/template/.cowork-flow/scripts/adapters/host/claude_code_policy.py +3 -14
  14. package/template/.cowork-flow/scripts/adapters/host/host_manifest.py +1 -2
  15. package/template/.cowork-flow/scripts/adapters/host/inject.py +14 -8
  16. package/template/.cowork-flow/scripts/adapters/host/kimi_code_policy.py +21 -0
  17. package/template/.cowork-flow/scripts/adapters/host/workflow_state_hook.py +19 -14
  18. package/template/.cowork-flow/scripts/adapters/host/zcode_policy.py +0 -17
  19. package/template/.cowork-flow/scripts/adapters/review/test_intent.py +0 -31
  20. package/template/.cowork-flow/scripts/infra/paths.py +0 -8
  21. package/template/.cowork-flow/scripts/runtime/host_identity.py +260 -0
  22. package/template/.cowork-flow/scripts/runtime/session_state.py +45 -51
  23. package/template/.cowork-flow/scripts/services/context_discovery.py +0 -35
  24. package/template/.cowork-flow/scripts/services/fact_view.py +0 -1
  25. package/template/.cowork-flow/scripts/services/lifecycle_checks.py +0 -20
  26. package/template/.cowork-flow/scripts/services/spec_check.py +1 -1
  27. package/template/.cowork-flow/scripts/services/task_context.py +0 -2
  28. package/template/.cowork-flow/spec/contracts/context-injection.md +88 -4
  29. package/template/.cowork-flow/spec/contracts/fact-layer-access.md +1 -0
  30. package/template/.cowork-flow/spec/contracts/spec-checks.md +2 -1
  31. package/template/.cowork-flow/spec/runtime/host-assets.json +48 -0
  32. package/template/.cowork-flow/spec/runtime/index.md +1 -1
  33. package/template/.cowork-flow/spec/schemas/host-assets.schema.json +1 -8
  34. package/template/.cowork-flow/spec/schemas/index.md +1 -0
  35. package/template/.kimi-code/agents/cowork-check.md +29 -0
  36. package/template/.kimi-code/agents/cowork-implement.md +30 -0
  37. package/template/.kimi-code/agents/cowork-research.md +23 -0
  38. package/template/.zcode/.zcode-plugin/plugin.json +1 -1
  39. package/template/skills/runtime-health/scripts/doctor.py +107 -0
package/CHANGELOG.md CHANGED
@@ -1,5 +1,53 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.6.0 - 2026-09-20
4
+
5
+ ### 门禁诚实化(收集一致 / 平台 skip / CI 同门禁)
6
+
7
+ - **收集一致**:`tests/test_agents_managed_block.py` 的两条断言原为模块级函数,`python -m unittest`(发布门禁与 `test:template` 用的收集器)收集不到它们(实测 `Ran 0 tests`),只有 pytest 能看到。现移入 `unittest.TestCase`,并进入 `CORE_TEMPLATE_TEST_MODULES`(core 套件 176 → 178)。新增 `tests/test_test_collection.py` 守卫:`tests/` 下再出现对 unittest 不可见的模块级 `def test_*` 即失败。
8
+ - **Windows 静默通过**:`test/dsh-home-patch.test.js` 此前在 win32 顶层 `process.exit(0)`,`node --test` 把整个文件记为**通过文件**(不进 skipped、不进 fail)。现改为 7 个用例带 `{ skip: <具体原因> }`,Windows 上如实计入 skipped。
9
+ - **恒真断言**:`tests/test_host_adapters.py` 三处「断言自己刚写死的字面量长度」(`assertEqual(2, len(surfaces))` 等)改为非空断言——不再恒真,也不引入需要同步维护的数字。
10
+ - **CI 与发布同门禁**:ubuntu job 不再分步跑 fast / integration / core,合并为一步 `npm run release:check`(与 publish.yml 完全相同的命令,`setup-python` 前置到它之前);`test/package.test.js` 新增断言锁定该一致性。此前 `test:node:full` 与 `test:template:full` 两层只在发布时首次执行,PR 侧从不运行。
11
+
12
+ ### 宿主必需集合单源化
13
+
14
+ - 删除 Python `host_manifest.REQUIRED_CAPABILITY_MATRIX_HOSTS` 与 JS 镜像常量:`capabilityMatrix.hosts` 的必需宿主集合改由 manifest 的 `platforms` 派生(`set(platform_ids)` / `new Set([...platformIds])`)。
15
+ - 删除 `host-assets.schema.json` 中第三处宿主名枚举——它处于漂移状态(要求 5 家、含 `dsh`、缺 `kimi-code`,而数据是 6 家)。schema 只约束结构,宿主覆盖由运行时按数据校验。
16
+ - 新增共享 fixture `tests/fixtures/host-manifest/invalid-missing-matrix-host.json`(平台已声明但矩阵缺条目),Python 与 JS 两侧都拒绝(`capability matrix missing host: codex`),证明常量删除后该规则仍生效。
17
+ - `spec/runtime/index.md` 记录派生规则。
18
+
19
+ ### 死代码与文档漂移清理
20
+
21
+ - 删除仅测试可达的 skill-path 链:`context_discovery` 的 `detect_installed_platforms`(手写 3 宿主)、`SKILL_REPLICA_DIR_BY_PLATFORM`、`skill_root`、`skill_path`,以及 `adapters/cli/task.py` 的三个未调用别名。该链在生产代码零调用,真实契约由 `infra/skill_manifest` 与 manifest 的 `skillTarget` 承担。
22
+ - 删除 `infra/paths.py` 中 `return` 之后的孤儿 try 块(引用未定义的 `file_path`,为 2026-08-05 移除 journal 流程时遗留)。
23
+ - 删除 `fact_view.STAGE_CONTRACT_STATES`(文件内零引用;活定义在 `workflow_state_hook`)、`lifecycle_checks` 两个零调用的 `*_blockers` 包装、`adapters/review/test_intent` 的恒空校验器 `validate_test_intent` 与三个未被读取的常量,以及断言其返回空的同义用例。
24
+ - 文档对齐:README 保护文件描述与 `syncPolicy` 实际一致(两个 spec 例外文件 + `.developer`);两处 spec-check 文案 3s → 2.5s;`spec/schemas/index.md` 补列 `host-assets.schema.json`;清理指向已 untrack `docs/` 的历史死链。
25
+
26
+ ### DSH 注入接线覆盖
27
+
28
+ - `presets/dsh/plugins/workflow-state.js` 的 `apply(ctx)` 此前零测试。新增用例覆盖:四个刷新事件的接线、section 注册(name/order)、无缓存时返回空文本、非编辑调用下 `tools/post-execute` 原样透传 downstream、非 cowork-flow 根目录静默降级——全部在 Windows 上真实执行。「刷新替换而非累积」用例按既有惯例在 Windows skip(宿主解释器限制)。
29
+
30
+ ### Windows 检出与行尾契约
31
+
32
+ - 根因:`presets/kimi-code/hooks/cowork-flow-inject.mjs` 落在 `* text=auto` 下且未被 `.gitattributes` 钉行尾,Windows 全新检出(Git for Windows 默认 `core.autocrlf=true`,与 `windows-latest` 一致)把它写成 CRLF;`install-kimi-hook` 原样拷贝为全局 hook shim,于是 `test/kimi-hook.test.js` 的 `/^#!\/usr\/bin\/env node\n/` 断言失败,`test:fast` → `windows-core` 确定性红。
33
+ - 修复:`.gitattributes` 为 `*.js`/`*.mjs` 声明 `text eol=lf`(与已钉的 `.py`/`.md`/`.json` 等一致);`test/package.test.js` 新增行尾契约断言锁定该声明。
34
+ - 为什么只在 Windows CI 可见:开发者工作区与 ubuntu 检出都是 LF,只有 Windows 全新检出是 CRLF。断言保持严格——CRLF shebang 的脚本在 POSIX 上无法直接执行,要修的是检出契约而不是判据。
35
+ - 证据:全新检出实测踩中同一断言失败(`test:fast`);仅把该文件规范化为 LF → 整条 `npm run test:windows:core` exit 0;Node 20.12.0 / 20.20.2 / 24.14.1 结果一致,与 Node 版本无关。
36
+
37
+ ### 净变化
38
+
39
+ - 运行时代码(`template/.cowork-flow/scripts` + `src`):**+4 / −114,净 −110 行**;runtime 模块数 80 不变(行尾修复不涉及运行时代码)。
40
+ - 本次门禁工作:`e262196` **+365 / −200**(含 2 个新文件 +162 行)、`7fa3f8b` +3 / −2,行尾修复为小改动(行尾声明 + 契约守卫 + 记账);新增集中在 `tests/` 与 `test/`。**AC-006「净行数 ≤ 0」未达成**——三条口径都是新增大于删除。
41
+ - 版本口径参考:自 1.5.0 发版提交 `a4b0bfc` 起,1.6.0 全部提交的净变化由本版本更早的宿主特性主导,不属本次门禁工作。
42
+ - 契约指纹:registry 登记的契约文件与 `spec/runtime/host-assets.json` 均未改动,指纹不变。
43
+
44
+ ### 平台差异与远端确认
45
+
46
+ - Windows 上 `test:node:full` 的 skipped 明细新增 `dsh-home-patch` 的 7 条;这些用例在 ubuntu 发布门禁中真实执行。
47
+ - POSIX-only:`a session refresh replaces the cached block instead of accumulating` 与既有 DSH 内容用例在本机(Windows)skip,未在本机执行。
48
+ - 远端确认(提交 `e262196`,CI run 35503939861):ubuntu job 首次执行 `release:check` **通过**——17 条此前只在发布时运行的 `release.test.js` POSIX 用例在 CI 上真实执行并全部通过。
49
+ - 同一 run 的 `windows-core` 失败(步骤 `Run Windows core verification`,exit 1)。该 job 在改动前的 `aa0e34e` 上即为同样的失败,不是本版本的功能改动引入;根因是行尾契约缺失,已在本版本修复(见「Windows 检出与行尾契约」),远端日志无需仓库权限即可复现——全新 `git clone` 即可稳定踩中。
50
+
3
51
  ## 1.5.0 - 2026-09-18
4
52
 
5
53
  ### 发版开关 `--no-publish`
@@ -139,7 +187,7 @@
139
187
 
140
188
  > **版本内容错位说明**:npm registry 上的 1.0.0 tarball 发布于 2026-08-26(仅含发版脚本修复之前的代码)。1.0.0 段下述的里程碑描述以本 1.1.0 为其实际发布载体——阶段 0-3 的全部内容自本版本起进入 npm 分发。
141
189
 
142
- ### 方向落地(阶段 0-3,详见 1.0.0 段与 docs/direction.md)
190
+ ### 方向落地(阶段 0-3,详见 1.0.0 段)
143
191
 
144
192
  - 阶段 0:README 定位改写为「运行时上下文与协作事实层」;注入协议契约 `spec/contracts/context-injection.md`;契约指纹序列化三线统一 + slim 全覆盖 + 跨 host 一致性测试。
145
193
  - 阶段 1:`run state [task] --json` 事实视图;`<workflow-state>` 属性事实头 + `<decision-anchor>` 决策要点注入(三线一致)。
package/README.md CHANGED
@@ -14,7 +14,7 @@
14
14
  | 事实接入 | `run state [task] --json` 事实视图;`run mcp-state` 无依赖 MCP 只读服务(`task_state` / `task_list` / `task_scope` / `task_specs`)供任何 MCP 客户端查询;`task scope` / `task specs` 为 CLI 同源事实命令。 |
15
15
  | 规范挂命令 | spec 首部 frontmatter 声明 `checks:` 命令,按编辑期/收口期执行;`run spec-check` 输出三态门禁(pass / violation / unchecked)。 |
16
16
  | 运行健康 | `doctor` 诊断 runtime、host assets、Skill replica 和任务 hygiene,不推进生命周期。 |
17
- | Host 分发 | Host Asset Manifest 驱动 Codex / OpenCode / Claude Code / ZCode / DeepSeek Harness 资产和 obsolete 清理。 |
17
+ | Host 分发 | Host Asset Manifest 驱动 Codex / OpenCode / Claude Code / ZCode / Kimi Code / DeepSeek Harness 资产和 obsolete 清理。 |
18
18
  | 批处理与讨论 | Batch 发布 Host action;Party Mode 只输出 advisory final facts。 |
19
19
  | 发布准备 | `release:check`、`CHANGELOG.md`、`pack:check` 固定发布前证据。 |
20
20
 
@@ -76,7 +76,7 @@ cowork-flow mcp-state
76
76
  npm run release:check
77
77
  ```
78
78
 
79
- 平台选项:`codex` / `opencode` / `claude-code` / `dsh` / `zcode` / `all`(逗号分隔)
79
+ 平台选项:`codex` / `opencode` / `claude-code` / `dsh` / `zcode` / `kimi-code` / `all`(逗号分隔)
80
80
 
81
81
  ## 仓库结构
82
82
 
@@ -90,6 +90,7 @@ template/
90
90
  ├── .opencode/ # OpenCode agents / commands / plugins
91
91
  ├── .dsh/ # DeepSeek Harness 标记(sync 检测 + 说明)
92
92
  ├── .zcode/ # ⭐ ZCode 插件(hooks + skills + agents + scaffold instructions)
93
+ ├── .kimi-code/ # Kimi Code fixed agents(cowork-implement / check / research)
93
94
  └── .cowork-flow/
94
95
  ├── config.yaml # 项目配置
95
96
  ├── scripts/ # Python 运行时
@@ -116,6 +117,7 @@ Skills 维护在 `template/skills/` 唯一源码,`init` / `sync` 时按目录
116
117
  |---|---|
117
118
  | `codex` / `opencode` | `.agents/skills/` |
118
119
  | `dsh` | `.agents/skills/` |
120
+ | `kimi-code` | `.agents/skills/` |
119
121
  | `claude-code` | `.claude/skills/` |
120
122
  | `zcode` | `.cowork-flow/skills/`(内核 owner 解析用;系统提示层仍由 ZCode 插件单源提供,不重复加载) |
121
123
 
@@ -131,6 +133,7 @@ Skills 维护在 `template/skills/` 唯一源码,`init` / `sync` 时按目录
131
133
  | `install-zcode-plugin` | 安装 ZCode 插件到全局缓存 |
132
134
  | `install-dsh-preset` | 安装 DSH agent 预设到 `~/.dsh/.agent-presets/cowork-flow/`(整套 agent,可选) |
133
135
  | `install-dsh-hook` | 机器级注册 workflow-state hook 组合行到 `$DSH_HOME/cordis.patch.yml`(当前 DSH 的 agent 提示不收集 host 层 section,实时注入请用预设方式) |
136
+ | `install-kimi-hook [--dry-run] [--force] [--uninstall]` | 机器级注册 UserPromptSubmit hook 到 `$KIMI_CODE_HOME/config.toml`(默认 `~/.kimi-code/`),向每个 Kimi Code 会话实时注入工作流上下文 |
134
137
  | `update [--dry-run]` | 升级 CLI 本身 |
135
138
  | `mcp-state` | 全局 MCP 事实入口:从 cwd 向上定位项目运行时并透传 `run mcp-state`(全局注册一次,所有 cowork-flow 项目通用) |
136
139
 
@@ -138,7 +141,7 @@ Skills 维护在 `template/skills/` 唯一源码,`init` / `sync` 时按目录
138
141
 
139
142
  | 选项 | 说明 |
140
143
  |---|---|
141
- | `--platform <p>` | 平台:`codex` / `opencode` / `claude-code` / `dsh` / `zcode` / `all` |
144
+ | `--platform <p>` | 平台:`codex` / `opencode` / `claude-code` / `dsh` / `zcode` / `kimi-code` / `all` |
142
145
  | `--developer <n>` | 开发者名称 |
143
146
  | `--force` | 覆盖已有文件 |
144
147
  | `--dry-run` | 预览不写入 |
@@ -147,7 +150,7 @@ Skills 维护在 `template/skills/` 唯一源码,`init` / `sync` 时按目录
147
150
 
148
151
  - **自动识别**已安装 host 目录,只同步对应平台资产
149
152
  - **Skills** 从 `template/skills/` 按平台分发
150
- - **保护文件**:`config.yaml`、`spec/`(除 `workflow-state-templates.md`)、任务、计划、变更
153
+ - **保护文件**:`config.yaml`、`.developer`、`spec/`(例外:`spec/contracts/workflow-state-templates.md` 与 `spec/contracts/spec-checks.md` 两个契约文件)、任务、计划
151
154
  - **正式版旧资产清理**:旧脚本位置、旧 adapter 资产和已废弃文件按 Host Asset Manifest 的 `obsoleteFiles` 清理;用户保护文件保持不变
152
155
  - **事务恢复**:上次未完成事务会在新一轮 sync 前恢复;事务元数据缺失或损坏时 fail-closed,不在未知状态上继续写入
153
156
  - **Dry-run readiness**:`sync --dry-run` 只构建计划并输出 `readiness=<json>`,不写文件或事务状态;字段包含 `wouldCopy`、`wouldSkipProtected`、`wouldRemoveObsolete`、`hostAssetRefresh`、`pendingRecovery`、`warnings`
@@ -246,6 +249,22 @@ cowork-flow install-dsh-preset --dry-run # 预览不写入
246
249
 
247
250
  预设内置 **workflow-state hook**(`plugins/workflow-state.js`):DSH 原生等效于 Codex/Claude hook,向系统提示末尾注入与其它宿主同构的 `<workflow-state>` 块,每条用户消息刷新一次,并在生命周期命令(`task`/`subagent`/`resume`)执行完成后立即轮内刷新(替换语义,不累积)。项目无 `.cowork-flow` 根、缺少 Python 或设 `COWORK_FLOW_HOOKS=0` / `COWORK_FLOW_DISABLE_HOOKS=1` 时静默降级,由 AGENTS.md 门禁的运行导航器兜底。
248
251
 
252
+ ## Kimi Code hook
253
+
254
+ ```bash
255
+ cowork-flow install-kimi-hook # 安装(无条件覆盖);--force 与不带旗标同义
256
+ cowork-flow install-kimi-hook --dry-run # 预览将写入的托管块,不写文件
257
+ cowork-flow install-kimi-hook --uninstall # 卸载托管块、shim 与版本标记
258
+ ```
259
+
260
+ 安装写入用户级 Kimi Code home(`KIMI_CODE_HOME`,未设置时默认 `~/.kimi-code/`):hook 脚本 `hooks/cowork-flow-inject.mjs`、版本标记 `hooks/.cowork-flow-kimi-hook.json`,并在 `config.toml` 追加一段由注释标记包裹的托管块——一条 `[[hooks]]`,`event = "UserPromptSubmit"`、`command`、`timeout = 30`,不写 `matcher`(即匹配每条提交的提示)。配置文件按文本编辑、不做 TOML 重排,托管块以外的用户内容原样保留;卸载只移除这段托管块和上面两个文件,`config.toml` 因此变空时一并删除。
261
+
262
+ Kimi Code 只注册 `UserPromptSubmit` 一个事件:`SessionStart` / `PostToolUse` 等观察型事件的 stdout 会被宿主丢弃,无法注入内容。hook 的 stdout 就是注入正文,宿主把它追加进提示上下文;shim 在项目根目录预检失败(非 cowork-flow 项目)时直接短路,不启动 Python 进程,全局开关为 `COWORK_FLOW_HOOKS=0` / `COWORK_FLOW_DISABLE_HOOKS=1`。
263
+
264
+ > 安装或更新后需要**重启 Kimi Code 会话**(或重新加载配置)才会加载 hook。
265
+
266
+ Kimi Code 的 Bash 工具不导出会话标识环境变量,CLI 侧身份只能取自注入头里的 `session="kimi_<id>"`,需要显式传 `COWORK_FLOW_CONTEXT_ID`(或 `COWORK_FLOW_HOST=kimi-code`)。`./.cowork-flow/run doctor` 把该 hook 的注册情况作为 warning 级项报告(`HOOK-NOT-INSTALLED` / `HOOK-SHIM-MISSING` / `HOOK-UNKNOWN-VERSION` / `HOOK-STALE`),不计入 errors。
267
+
249
268
  ## 任务流程
250
269
 
251
270
  ```
@@ -300,7 +319,7 @@ checks:
300
319
  唯一执行器是 `./.cowork-flow/run spec-check`:
301
320
 
302
321
  - **三态语义**:`pass`(退出码 0);`violation` 阻断 `task complete`;`unchecked`(命令缺失、解释器缺失、超时)同样阻断,需显式 `--allow-unchecked` 放行,豁免留痕进 `task.json`。unchecked 永不冒充 pass。
303
- - **两个相位**:`when: edit` 在编辑期就地执行(超时钳制 2.5 秒,违规输出单行提示;五个宿主均已覆盖,其中 zcode 仅主会话),是 best-effort 提示不是门禁;收口期全量执行,未过项阻断状态推进。
322
+ - **两个相位**:`when: edit` 在编辑期就地执行(超时钳制 2.5 秒,违规输出单行提示;具备编辑期 hook 的宿主均已覆盖,其中 zcode 仅主会话),是 best-effort 提示不是门禁;收口期全量执行,未过项阻断状态推进。
304
323
  - **扫描范围**:`.cowork-flow/spec/` 下的 markdown;`contracts/`、`runtime/`、`schemas/` 三个机器自有子树不参与。
305
324
  - **模板不带生效声明**:模板无法预知项目命令,而命令缺失会归 `unchecked` 并阻断收口;请把声明写进自建 spec 文件(如 `spec/team-xxx.md`)。
306
325
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cowork-flow",
3
- "version": "1.5.0",
3
+ "version": "1.6.0",
4
4
  "description": "CLI for installing and maintaining cowork-flow collaboration templates.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -19,7 +19,7 @@
19
19
  ],
20
20
  "scripts": {
21
21
  "test": "npm run test:fast",
22
- "test:fast": "node --test test/asset-plan.test.js test/cli.test.js test/host-assets.test.js test/opencode-plugin.test.js test/package.test.js test/template-runner.test.js test/update.test.js test/zcode-plugin.test.js",
22
+ "test:fast": "node --test test/asset-plan.test.js test/cli.test.js test/host-assets.test.js test/kimi-hook.test.js test/opencode-plugin.test.js test/package.test.js test/template-runner.test.js test/update.test.js test/zcode-plugin.test.js",
23
23
  "test:integration": "node --test --test-name-pattern=\"^(init copies the template into a new target directory|sync updates safe template files and preserves protected files)$\" test/init.test.js test/sync.test.js",
24
24
  "test:node:full": "node --test",
25
25
  "test:template": "node scripts/run-template-tests.js --suite core",
@@ -0,0 +1,113 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * cowork-flow Kimi Code hook — transport shim.
4
+ *
5
+ * Kimi Code appends a UserPromptSubmit hook's stdout to the prompt context
6
+ * verbatim, so all workflow facts are rendered by the single Python source
7
+ * (inject.py --host kimi-code, emit_text policy). This shim keeps only
8
+ * transport duties:
9
+ * 1. cheap project-root pre-check — a cwd outside any cowork-flow project
10
+ * exits 0 without spawning anything, so the user-level hook never
11
+ * touches unrelated repositories;
12
+ * 2. interpreter location (COWORK_FLOW_PYTHON → python3 → python → py -3);
13
+ * 3. stdin passthrough to inject.py, stdout/exit forwarding.
14
+ *
15
+ * Fail-open by construction: a missing interpreter, a missing runtime, or a
16
+ * broken project copy all exit 0 with empty stdout instead of blocking the
17
+ * user's prompt (a non-zero exit on UserPromptSubmit aborts the submit).
18
+ */
19
+
20
+ import { existsSync, readFileSync } from "node:fs";
21
+ import { dirname, join } from "node:path";
22
+ import { spawnSync } from "node:child_process";
23
+
24
+ const WORKFLOW_DIR = ".cowork-flow";
25
+ const INJECT_RELATIVE = join(
26
+ WORKFLOW_DIR,
27
+ "scripts",
28
+ "adapters",
29
+ "host",
30
+ "inject.py"
31
+ );
32
+ const HOST = "kimi-code";
33
+ const SPAWN_TIMEOUT_MS = 20000; // config.toml grants the hook 30s
34
+
35
+ function readHookInput() {
36
+ if (process.stdin.isTTY) return { raw: "", parsed: {} };
37
+ let raw = "";
38
+ try {
39
+ raw = readFileSync(0, "utf8").trim();
40
+ } catch {
41
+ return { raw: "", parsed: {} };
42
+ }
43
+ try {
44
+ const parsed = JSON.parse(raw);
45
+ return { raw, parsed: parsed && typeof parsed === "object" ? parsed : {} };
46
+ } catch {
47
+ return { raw: "", parsed: {} };
48
+ }
49
+ }
50
+
51
+ function findProjectRoot(startDir) {
52
+ if (typeof startDir !== "string" || !startDir.trim()) return null;
53
+ let current = startDir.trim();
54
+ const root = dirname(current);
55
+ while (current !== root) {
56
+ if (existsSync(join(current, INJECT_RELATIVE))) return current;
57
+ current = dirname(current);
58
+ }
59
+ return existsSync(join(current, INJECT_RELATIVE)) ? current : null;
60
+ }
61
+
62
+ function pythonCandidates() {
63
+ const list = [];
64
+ const env = (process.env.COWORK_FLOW_PYTHON || "").trim();
65
+ if (env) list.push([env]);
66
+ list.push(["python3"], ["python"]);
67
+ if (process.platform === "win32") list.push(["py", "-3"]);
68
+ return list;
69
+ }
70
+
71
+ function main() {
72
+ if (
73
+ process.env.COWORK_FLOW_HOOKS === "0" ||
74
+ process.env.COWORK_FLOW_DISABLE_HOOKS === "1"
75
+ ) {
76
+ process.exit(0);
77
+ }
78
+
79
+ const { raw, parsed } = readHookInput();
80
+ const cwd =
81
+ typeof parsed?.cwd === "string" && parsed.cwd.trim()
82
+ ? parsed.cwd
83
+ : process.cwd();
84
+ const root = findProjectRoot(cwd);
85
+ if (!root) process.exit(0);
86
+
87
+ const injectScript = join(root, INJECT_RELATIVE);
88
+ for (const candidate of pythonCandidates()) {
89
+ let result;
90
+ try {
91
+ result = spawnSync(
92
+ candidate[0],
93
+ [...candidate.slice(1), injectScript, "--host", HOST],
94
+ {
95
+ input: raw,
96
+ timeout: SPAWN_TIMEOUT_MS,
97
+ encoding: "utf8",
98
+ windowsHide: true,
99
+ }
100
+ );
101
+ } catch {
102
+ continue;
103
+ }
104
+ if (result.error || result.status === null) continue;
105
+ if (result.stdout) process.stdout.write(result.stdout);
106
+ process.exit(result.status);
107
+ }
108
+ // No interpreter could run the entry: stay silent rather than break the
109
+ // user's submit (fail-open, same contract as the other host shims).
110
+ process.exit(0);
111
+ }
112
+
113
+ main();
package/src/cli.js CHANGED
@@ -5,6 +5,7 @@ import { createInterface } from 'node:readline/promises';
5
5
  import { runInit } from './commands/init.js';
6
6
  import { runInstallDshHook } from './commands/install-dsh-hook.js';
7
7
  import { runInstallDshPreset } from './commands/install-dsh-preset.js';
8
+ import { runInstallKimiHook } from './commands/install-kimi-hook.js';
8
9
  import { runInstallZCodePlugin } from './commands/install-zcode-plugin.js';
9
10
  import { runMcpState } from './commands/mcp-state.js';
10
11
  import { runSourceRefresh } from './commands/source-refresh.js';
@@ -19,6 +20,7 @@ Usage:
19
20
  cowork-flow install-zcode-plugin [--dry-run] [--force] [--prune-old]
20
21
  cowork-flow install-dsh-preset [--dry-run] [--force]
21
22
  cowork-flow install-dsh-hook [--dry-run] [--force] [--uninstall]
23
+ cowork-flow install-kimi-hook [--dry-run] [--force] [--uninstall]
22
24
  cowork-flow update
23
25
  cowork-flow sync [target] [--dry-run] [--force]
24
26
  cowork-flow source-refresh [target] [--dry-run]
@@ -201,6 +203,10 @@ export async function main(argv = process.argv.slice(2), options = {}) {
201
203
  return await runInstallDshHook(args);
202
204
  }
203
205
 
206
+ if (command === 'install-kimi-hook') {
207
+ return await runInstallKimiHook(args);
208
+ }
209
+
204
210
  if (command === 'mcp-state') {
205
211
  return await runMcpState(args);
206
212
  }
@@ -0,0 +1,343 @@
1
+ import { access, copyFile, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
2
+ import { dirname, join } from 'node:path';
3
+ import { homedir } from 'node:os';
4
+
5
+ import { packageRoot } from '../lib/paths.js';
6
+ import { readPackageInfo } from '../lib/package-info.js';
7
+
8
+ const SHIM_NAME = 'cowork-flow-inject.mjs';
9
+ const SHIM_SRC = join(packageRoot, 'presets', 'kimi-code', 'hooks', SHIM_NAME);
10
+ const MARKER_FILE = '.cowork-flow-kimi-hook.json';
11
+ const HOOK_EVENT = 'UserPromptSubmit';
12
+ // Kimi Code's own hook default; kept explicit so an edit is visible here.
13
+ const HOOK_TIMEOUT = 30;
14
+ const MANAGED_START = '# cowork-flow: kimi hook start. Managed by "cowork-flow install-kimi-hook"; edits inside this block are replaced.';
15
+ const MANAGED_END = '# cowork-flow: kimi hook end.';
16
+
17
+
18
+ function parseArgs(args) {
19
+ return {
20
+ dryRun: args.includes('--dry-run'),
21
+ force: args.includes('--force'),
22
+ uninstall: args.includes('--uninstall')
23
+ };
24
+ }
25
+
26
+
27
+ function getKimiHome() {
28
+ return process.env.KIMI_CODE_HOME || join(homedir(), '.kimi-code');
29
+ }
30
+
31
+
32
+ async function pathExists(target) {
33
+ try {
34
+ await access(target);
35
+ return true;
36
+ } catch {
37
+ return false;
38
+ }
39
+ }
40
+
41
+
42
+ async function readIfExists(file) {
43
+ try {
44
+ return await readFile(file, 'utf8');
45
+ } catch {
46
+ return null;
47
+ }
48
+ }
49
+
50
+
51
+ /**
52
+ * Kimi Code's user-level config.toml, holding the `[[hooks]]` rows.
53
+ */
54
+ function configFile(home) {
55
+ return join(home, 'config.toml');
56
+ }
57
+
58
+
59
+ /**
60
+ * Transport shim installed beside the config, spawned as
61
+ * `node <home>/hooks/cowork-flow-inject.mjs` from every session.
62
+ */
63
+ function shimDest(home) {
64
+ return join(home, 'hooks', SHIM_NAME);
65
+ }
66
+
67
+
68
+ /**
69
+ * Version marker for the installed hook, written next to the shim. The
70
+ * installed files live outside any project, so nothing else records which
71
+ * release put them there — doctor reads this to tell an up-to-date install
72
+ * from one that predates the project runtime.
73
+ */
74
+ function markerDest(home) {
75
+ return join(home, 'hooks', MARKER_FILE);
76
+ }
77
+
78
+
79
+ /**
80
+ * One `[[hooks]]` row that pipes every UserPromptSubmit payload into the
81
+ * shim. Kimi Code allows exactly four hook fields — event, matcher, command,
82
+ * timeout — and rejects the whole config when a row carries anything else,
83
+ * so the row is deliberately minimal. `matcher` is omitted on purpose:
84
+ * omitted means "match every prompt". Paths are quoted because a Kimi Code
85
+ * home may contain spaces (Windows user profiles).
86
+ */
87
+ function hookBlock(home) {
88
+ const command = 'node "' + shimDest(home) + '"';
89
+ return [
90
+ MANAGED_START,
91
+ '[[hooks]]',
92
+ 'event = ' + JSON.stringify(HOOK_EVENT),
93
+ 'command = ' + JSON.stringify(command),
94
+ 'timeout = ' + HOOK_TIMEOUT,
95
+ MANAGED_END
96
+ ].join('\n');
97
+ }
98
+
99
+
100
+ /**
101
+ * End of the `[[hooks]]` row starting at `start`: the next table header, the
102
+ * next row of the same array, or the end of file. Trailing blank lines stay
103
+ * outside the range so removal leaves them for the surrounding layout.
104
+ */
105
+ function hookRowEnd(lines, start) {
106
+ let end = start + 1;
107
+ while (end < lines.length) {
108
+ const trimmed = lines[end].trim();
109
+ if (trimmed === '[[hooks]]' || (trimmed.startsWith('[') && trimmed.endsWith(']'))) {
110
+ break;
111
+ }
112
+ end += 1;
113
+ }
114
+ while (end > start + 1 && lines[end - 1].trim() === '') {
115
+ end -= 1;
116
+ }
117
+ return end;
118
+ }
119
+
120
+
121
+ /**
122
+ * Locate the managed block inside the config text.
123
+ *
124
+ * The config is edited as text on purpose: a TOML round-trip would rewrite
125
+ * every pretty-printed entry of the user's own config (providers, models,
126
+ * permissions), and the file must stay byte-stable outside the block. The
127
+ * marker comments take precedence so the block is removed even when the row
128
+ * no longer resembles ours; without markers, the only ownership signal left
129
+ * is a `[[hooks]]` row whose command names this shim. Returns null when
130
+ * neither exists.
131
+ */
132
+ function findManagedBlock(text, home) {
133
+ const lines = text.split('\n');
134
+ const marked = lines.findIndex((line) => line.trim() === MANAGED_START);
135
+ if (marked !== -1) {
136
+ let end = marked + 1;
137
+ while (end < lines.length && lines[end].trim() !== MANAGED_END) {
138
+ end += 1;
139
+ }
140
+ return { start: marked, end: Math.min(end + 1, lines.length) };
141
+ }
142
+ const owns = shimDest(home).replace(/\\/g, '/');
143
+ for (let index = 0; index < lines.length; index += 1) {
144
+ if (lines[index].trim() !== '[[hooks]]') {
145
+ continue;
146
+ }
147
+ const end = hookRowEnd(lines, index);
148
+ const row = lines.slice(index, end).join('\n');
149
+ if (row.includes(MANAGED_START) || row.replace(/\\/g, '/').includes(owns)) {
150
+ return { start: index, end };
151
+ }
152
+ }
153
+ return null;
154
+ }
155
+
156
+
157
+ function withBlock(text, home) {
158
+ const block = hookBlock(home).split('\n');
159
+ const range = findManagedBlock(text, home);
160
+ if (range !== null) {
161
+ const lines = text.split('\n');
162
+ if (lines.slice(range.start, range.end).join('\n') === block.join('\n')) {
163
+ return { text, changed: false, existed: true };
164
+ }
165
+ return {
166
+ text: lines.slice(0, range.start).concat(block).concat(lines.slice(range.end)).join('\n'),
167
+ changed: true,
168
+ existed: true
169
+ };
170
+ }
171
+ const base = text.replace(/\s+$/, '');
172
+ const appended = base === '' ? block.join('\n') : base + '\n\n' + block.join('\n');
173
+ return { text: appended + '\n', changed: true, existed: false };
174
+ }
175
+
176
+
177
+ function withoutBlock(text, home) {
178
+ const range = findManagedBlock(text, home);
179
+ if (range === null) {
180
+ return { text, changed: false };
181
+ }
182
+ const lines = text.split('\n');
183
+ const kept = lines.slice(0, range.start).concat(lines.slice(range.end));
184
+ const body = kept.join('\n').replace(/^\n+/, '').replace(/\s+$/, '');
185
+ if (body === '') {
186
+ // Nothing of the user's own config remains: an empty file left behind
187
+ // would be a config that only ever held this hook.
188
+ return { text: '', changed: true };
189
+ }
190
+ return { text: body + '\n', changed: true };
191
+ }
192
+
193
+
194
+ /**
195
+ * Compact line preview for --dry-run: every line that differs plus its
196
+ * neighbours, so the operator sees where the block lands without a full-file
197
+ * dump.
198
+ */
199
+ function previewDiff(before, after) {
200
+ // A trailing newline is a line terminator, not an extra empty line; without
201
+ // this the preview would always report a phantom last-line change.
202
+ const toLines = (text) => {
203
+ if (text === '') {
204
+ return [];
205
+ }
206
+ return (text.endsWith('\n') ? text.slice(0, -1) : text).split('\n');
207
+ };
208
+ const left = toLines(before);
209
+ const right = toLines(after);
210
+ const total = Math.max(left.length, right.length);
211
+ const shown = new Set();
212
+ for (let index = 0; index < total; index += 1) {
213
+ if (left[index] !== right[index]) {
214
+ for (let offset = -2; offset <= 2; offset += 1) {
215
+ if (index + offset >= 0 && index + offset < total) {
216
+ shown.add(index + offset);
217
+ }
218
+ }
219
+ }
220
+ }
221
+ const lines = [];
222
+ let previous = -1;
223
+ for (const index of [...shown].sort((a, b) => a - b)) {
224
+ if (previous !== -1 && index > previous + 1) {
225
+ lines.push(' ...');
226
+ }
227
+ const marker = left[index] === right[index] ? ' ' : '+ ';
228
+ lines.push(' ' + (index + 1) + ' ' + marker + (right[index] ?? ''));
229
+ previous = index;
230
+ }
231
+ return lines;
232
+ }
233
+
234
+
235
+ /**
236
+ * Install (or uninstall) the user-level Kimi Code context-injection hook.
237
+ *
238
+ * Kimi Code reads hooks only from $KIMI_CODE_HOME/config.toml, so the shim is
239
+ * installed under the same home and every session — in every project —
240
+ * invokes it; the shim itself exits silently outside cowork-flow projects.
241
+ * Only the Kimi Code home is written: no project file is touched.
242
+ */
243
+ export async function runInstallKimiHook(args = []) {
244
+ const { dryRun, uninstall } = parseArgs(args);
245
+
246
+ if (!(await pathExists(SHIM_SRC))) {
247
+ throw new Error('Kimi Code hook shim missing at ' + SHIM_SRC + '. Reinstall cowork-flow.');
248
+ }
249
+
250
+ const home = getKimiHome();
251
+ const config = configFile(home);
252
+ const shim = shimDest(home);
253
+ const marker = markerDest(home);
254
+
255
+ if (dryRun) {
256
+ const before = await readIfExists(config);
257
+ if (uninstall) {
258
+ const result = before === null ? { changed: false } : withoutBlock(before, home);
259
+ console.log('[dry-run] Would uninstall Kimi Code context hook:');
260
+ console.log(
261
+ ' Config: ' + config
262
+ + (before === null
263
+ ? ' (absent; nothing to remove)'
264
+ : (result.changed ? ' (managed block found)' : ' (no managed block)'))
265
+ );
266
+ console.log(' Remove shim: ' + shim);
267
+ console.log(' Remove marker: ' + marker);
268
+ return;
269
+ }
270
+ console.log('[dry-run] Would install Kimi Code context hook:');
271
+ console.log(' Shim: ' + SHIM_SRC + ' -> ' + shim);
272
+ console.log(' Marker: ' + marker);
273
+ if (before === null) {
274
+ console.log(' Create config: ' + config);
275
+ for (const line of previewDiff('', hookBlock(home))) {
276
+ console.log(line);
277
+ }
278
+ return;
279
+ }
280
+ const result = withBlock(before, home);
281
+ if (!result.changed) {
282
+ console.log(' Config: ' + config + ' (managed block already up to date)');
283
+ return;
284
+ }
285
+ console.log(' Config: ' + config + (result.existed ? ' (update managed block)' : ' (append managed block)'));
286
+ for (const line of previewDiff(before, result.text)) {
287
+ console.log(line);
288
+ }
289
+ return;
290
+ }
291
+
292
+ if (uninstall) {
293
+ const before = await readIfExists(config);
294
+ if (before === null) {
295
+ console.log('No Kimi Code config at ' + config + '; nothing to uninstall.');
296
+ } else {
297
+ const result = withoutBlock(before, home);
298
+ if (!result.changed) {
299
+ console.log('No managed hook block found in ' + config + '.');
300
+ } else if (result.text === '') {
301
+ await rm(config, { force: true });
302
+ console.log('✓ Kimi Code context hook removed; ' + config + ' held nothing else and was deleted.');
303
+ } else {
304
+ await writeFile(config, result.text, 'utf8');
305
+ console.log('✓ Kimi Code context hook removed from ' + config);
306
+ }
307
+ }
308
+ const hadShim = await pathExists(shim);
309
+ await rm(shim, { force: true });
310
+ if (hadShim) {
311
+ console.log('✓ Removed hook shim ' + shim);
312
+ }
313
+ const hadMarker = await pathExists(marker);
314
+ await rm(marker, { force: true });
315
+ if (hadMarker) {
316
+ console.log('✓ Removed version marker ' + marker);
317
+ }
318
+ return;
319
+ }
320
+
321
+ const { version } = await readPackageInfo();
322
+ await mkdir(dirname(shim), { recursive: true });
323
+ await copyFile(SHIM_SRC, shim);
324
+ await writeFile(
325
+ marker,
326
+ `${JSON.stringify({ version, installedAt: new Date().toISOString() }, null, 2)}\n`,
327
+ 'utf8'
328
+ );
329
+ const before = (await readIfExists(config)) ?? '';
330
+ const result = withBlock(before, home);
331
+ if (result.changed) {
332
+ await writeFile(config, result.text, 'utf8');
333
+ }
334
+
335
+ console.log('✓ cowork-flow Kimi Code hook shim installed to ' + shim);
336
+ console.log(' Recorded version ' + version + ' in ' + marker);
337
+ console.log(
338
+ ' Registered UserPromptSubmit hook in ' + config
339
+ + (result.changed ? (result.existed ? ' (block updated)' : ' (block appended)') : ' (already up to date)')
340
+ );
341
+ console.log(' Start a new Kimi Code session (or reload the config) for the hook to load.');
342
+ console.log(' Note: injection happens once per submitted prompt; other Kimi Code events cannot inject.');
343
+ }