dsh-tower 1.1.1 → 1.2.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,29 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.2.0 — 2026-10-18
4
+
5
+ - **`tower_merge` 对“提交已落地但调用报错”的恢复**:合并命令失败/超时后检查 `HEAD`,若已是
6
+ `[baseTip, branchTip]` 双父合并提交则视为成功并记录;失败时清理 `MERGE_HEAD` 与陈旧锁文件,
7
+ 只回滚与本 mission 相关的路径,避免 index/工作树残留。
8
+ - **区分失败类型**:真实内容冲突返回 `merge-conflict`(保留 blocked 语义);hook/进程失败返回
9
+ `merge-hook-failed`,附带 `timeout: true`(超时)与 `cleanup` 字段,mission 保持 `completed`
10
+ 以便重试。
11
+ - **合并超时可配置**:`tower_merge` 新增 `timeoutMs` 参数(默认 15 分钟),并把合并输出缓冲提升到
12
+ 64 MB,慢速 `pre-merge-commit` 钩子不再被 15 秒默认超时误杀。
13
+ - **自动对账已落地合并**:`tower_merge` 检测 branch/reviewed commit 已包含在 base 中时,找到对应
14
+ merge commit(或落地 tip)并直接记录为 merged(`reconciled: true`),无需手工改 Git 历史。
15
+ - **`tower_rebase`**:branch 已包含于 base 时返回 `alreadyMerged`/`skipped` 且不再把分支
16
+ fast-forward 到 base tip,保留已审查的 tip 供 `tower_merge` 记录。
17
+ - **状态机**:允许 `blocked → completed`;`missionSummary` 暴露 `mergedCommit`/`mergedAt`。
18
+ - 测试:22 项(新增 reconcile、rebase skip、hook 失败、hook 超时四个协议用例)。
19
+ - 修复 GitHub issues #1 / #2。
20
+
21
+ ## 1.1.2 — 2026-10-18
22
+
23
+ - README 文件更名:默认 `README.md` 改为中文版,英文版移到 `README.en.md`;两边的语言切换链接、
24
+ `package.json` 的 `files`、`CONTRIBUTING.md` 与 dsh-plugin 提交信息同步更新。
25
+ - 无运行时行为变化。
26
+
3
27
  ## 1.1.1 — 2026-10-18
4
28
 
5
29
  - README(英文/中文)与 dsh-plugin 提交信息中注明:Tower 模式的灵感来自 **Kimi Code**
package/README.en.md ADDED
@@ -0,0 +1,168 @@
1
+ # dsh-tower
2
+
3
+ [![CI](https://github.com/louifinn/dsh-tower/actions/workflows/test.yml/badge.svg?branch=main)](https://github.com/louifinn/dsh-tower/actions/workflows/test.yml)
4
+ [![npm](https://img.shields.io/npm/v/dsh-tower.svg)](https://www.npmjs.com/package/dsh-tower)
5
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
6
+ [中文说明](README.md)
7
+
8
+ **Tower** is a multi-agent orchestration plugin for [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) (DSH). It turns one agent session into a small engineering team: the session acts as the *control tower*, worker agents do the implementation inside isolated Git worktrees, and nothing reaches your base branch without an independent review and a hard merge gate.
9
+
10
+ Tower mode and its mission/review workflow are inspired by the experimental **Tower mode in [Kimi Code](https://www.kimi.com/code)** by Moonshot AI. This is an independent implementation for DSH and is not affiliated with Moonshot AI; see [Acknowledgements](#acknowledgements).
11
+
12
+ The repository root **is** the DSH bundle: `package.json` declares `dsh.bundle.patch` and `cordis.patch.yml` lives at the root, as the DSH plugin specification expects.
13
+
14
+ ## Features
15
+
16
+ - **Human-owned Tower mode.** `/tower` toggles a session-level stance that narrows the tool surface and injects the Tower protocol. `/tower on|off <instruction>` also forwards your instruction to the model in the same step.
17
+ - **Missions with real isolation.** Every mission gets its own branch and Git worktree; workers never touch your checkout.
18
+ - **Review-gated merges.** A reviewer bound to the current branch tip must submit a `clean` / `merge` verdict; the merge gate re-checks scope, staleness, task completion, review history, and worktree ownership before a `--no-ff` merge.
19
+ - **Durable coordination.** Roster, inbox, findings, reviews, and mission state are persisted under `.git/dsh-tower/`, so a restart does not lose the plan.
20
+ - **Safe by default.** While Tower mode is active, `create_goal`, `update_goal(resume)`, `todo_write`, the Team task board, and `spawn_teammate` are refused; a paused goal is never resumed automatically.
21
+
22
+ ## Requirements
23
+
24
+ - DeepSeek Harness `0.2.1-rc.2` (or a compatible newer build)
25
+ - Node.js 22 or newer
26
+ - Git (worktrees are created locally; no network access is required)
27
+
28
+ ## Install
29
+
30
+ Install the published package into a DSH profile:
31
+
32
+ ```bash
33
+ dsh plugin --profile web add dsh-tower
34
+ ```
35
+
36
+ Inside DSH you can use the plugin manager with the same target:
37
+
38
+ ```text
39
+ plugin_manager({ action: "install_bundle", target: "dsh-tower" })
40
+ ```
41
+
42
+ For development, install from GitHub or link a local checkout:
43
+
44
+ ```bash
45
+ dsh plugin --profile web add github:louifinn/dsh-tower
46
+ dsh plugin --profile <profile> add <absolute path to this repository>
47
+ ```
48
+
49
+ The Host plugin is loaded when the DSH process starts, so **restart DSH after installing or updating**. Remove it with `remove_bundle` and the package name `dsh-tower`.
50
+
51
+ ## Quick start
52
+
53
+ ```
54
+ /tower on
55
+
56
+ tower_init <repoRoot>
57
+ tower_plan <repoRoot> missions=[...]
58
+ tower_spawn <repoRoot> missionId=M1 role=worker
59
+ # worker implements and reports completion
60
+ tower_preflight <repoRoot> missionId=M1 # must be green before merging
61
+ tower_spawn <repoRoot> missionId=M1 role=reviewer
62
+ tower_review ... # reviewer submits verdict
63
+ tower_preflight <repoRoot> missionId=M1
64
+ tower_merge <repoRoot> missionId=M1
65
+ tower_teardown <repoRoot>
66
+
67
+ /tower off
68
+ ```
69
+
70
+ A typical run takes one worker mission and one reviewer round for a small change.
71
+
72
+ ## Commands
73
+
74
+ | Input | Behavior |
75
+ |---|---|
76
+ | `/tower` | Enter Tower mode (stays on if already on) |
77
+ | `/tower on` | Enter or stay |
78
+ | `/tower on <instruction>` | Enter/stay **and** forward `<instruction>` as the next user message |
79
+ | `/tower off` | Leave Tower mode |
80
+ | `/tower off <instruction>` | Leave **and** forward `<instruction>` |
81
+ | `/tower <instruction>` | Ensure on, then forward `<instruction>` |
82
+
83
+ Image and file attachments are forwarded together with the instruction as one complete `UserMessage`.
84
+
85
+ ## Configuration
86
+
87
+ ```yaml
88
+ - id: tower
89
+ name: 'dsh-tower'
90
+ config:
91
+ section: 'Optional replacement for the Tower-mode system prompt text'
92
+ allowAgentControl: true # true registers tower_control; false makes the mode human-only
93
+ ```
94
+
95
+ - `allowAgentControl` defaults to `false` in code. The bundled `cordis.patch.yml` enables it (`true`) so the model may enter/leave Tower mode when the user intent or task state clearly warrants it, always stating a reason.
96
+ - When disabled, only the human can change the mode.
97
+
98
+ ## Tools (17)
99
+
100
+ `tower_init` · `tower_plan` · `tower_mission` · `tower_status` · `tower_spawn` · `tower_roster` · `tower_adopt` · `tower_send` · `tower_inbox` · `tower_complete` · `tower_finding` · `tower_review` · `tower_preflight` · `tower_merge` · `tower_rebase` · `tower_teardown` · `tower_mode`
101
+
102
+ `tower_control` exists only when `allowAgentControl: true`.
103
+
104
+ ## Safety and guardrails
105
+
106
+ - **No merge without a green gate.** `tower_preflight` is read-only and reports stable blocker codes; `tower_merge` refuses when any blocker remains.
107
+ - **Review is bound to a commit.** If the branch tip moves after a review, the gate reports `tip-moved`; re-review is required (a clean rebase waiver is the only exception).
108
+ - **Scope enforcement.** A mission may only change files matched by its declared scope globs; `out-of-scope` blocks the merge.
109
+ - **Worktree ownership.** Dirty worktrees, a running worker, or a branch owned by a closed mission block teardown/merge.
110
+ - **Goal isolation.** Entering Tower mode pauses the active goal and records it; leaving does not resume it. Use `/tower off` and resume the goal yourself.
111
+
112
+ The 13 stable blocker codes are: `branch-missing`, `deps-unmerged`, `read-only-survey`, `stale-base`, `no-review`, `review-hold`, `not-clean`, `review-not-approved`, `tip-moved`, `review-mission-mismatch`, `out-of-scope`, `base-dirty`, `branch-owned-by-closed-missions` (plus mission/worker guards).
113
+
114
+ ## How it works
115
+
116
+ A mission moves through `planned → active → completed → merged` (or `blocked` / `paused` / `abandoned`). State lives in `<git-common-dir>/dsh-tower/`:
117
+
118
+ | Path | Contents |
119
+ |---|---|
120
+ | `.git/dsh-tower/state.json` | missions, roster, reviews, findings, inbox |
121
+ | `.git/dsh-tower/activity.jsonl` | append-only activity log |
122
+ | `<checkout parent>/.dsh-tower-worktrees/<hash>/<missionId>` | per-mission worktree |
123
+ | `$DSH_HOME/tower-mode/modes.json` | Tower mode state per agent/session |
124
+
125
+ Workers and reviewers are ordinary DSH subagents started with the bundled `tower-worker` preset; their tools are restricted to the mission surface (filesystem, shell, and Tower protocol tools), never merge/spawn.
126
+
127
+ ## Project layout
128
+
129
+ | Path | Contents |
130
+ |---|---|
131
+ | `package.json`, `cordis.patch.yml` | bundle manifest and loader patch (inserts the Host plugin and the `tower-worker` preset) |
132
+ | `index.js`, `lib/`, `worker.js` | Host entry, protocol implementation, worker preset policy |
133
+ | `locale/`, `icon.svg` | localization and icon |
134
+ | `tests/` | pure-function, protocol end-to-end, and plugin-level tests; optional isolated Web-runtime scripts |
135
+ | `ui-bundle/` | optional Client widget (static `/tower` hint), not installed by default |
136
+ | `.github/workflows/test.yml` | CI running the offline suite on Node 22/24 |
137
+
138
+ ## Development and testing
139
+
140
+ ```bash
141
+ npm test # node --test tests/*.test.mjs
142
+ ```
143
+
144
+ The offline suite needs no DSH process and creates throwaway Git repositories in the OS temp directory. `tests/README.md` documents the isolated Web-runtime checks, model-driven acceptance, and the session-diagnostic scripts used while hardening the plugin.
145
+
146
+ ## Troubleshooting
147
+
148
+ - **The plugin does not appear after install/update.** Host plugins load at DSH startup; restart DSH. Confirm the entry point targets this repository root (the folder with `package.json` and `cordis.patch.yml`), not a subdirectory.
149
+ - **`tower_control` is missing.** The config `allowAgentControl` is `false`; set it to `true` and restart.
150
+ - **Merge is blocked.** Run `tower_preflight` and read the blocker codes above; `stale-base` means run `tower_rebase` first.
151
+ - **A mission branch is stale after base moved.** `tower_rebase` rebases inside the worktree; a clean conflict-free rebase waives re-review for the previous verdict.
152
+ - **A merge commit exists but `tower_merge` reported a failure.** Re-run `tower_merge`: it detects the landed merge commit (or a branch already contained in the base) and records it as reconciled instead of merging twice.
153
+ - **The repository runs a slow `pre-merge-commit` hook.** The merge timeout defaults to 15 minutes and can be raised per call with `tower_merge({ ..., timeoutMs: 900000 })`; hook output is buffered up to 64 MB. A hook failure returns `merge-hook-failed` (with `timeout: true` when it timed out) and leaves the repository clean for a retry.
154
+
155
+ ## Contributing
156
+
157
+ Issues and pull requests are welcome. Please run `npm test` before opening a PR; see [CONTRIBUTING.md](CONTRIBUTING.md) for details. Security reports: please avoid opening a public issue with exploit details; open a minimal issue asking for a private channel.
158
+
159
+ ## Acknowledgements
160
+
161
+ - **Kimi Code (Moonshot AI)** — the Tower mode concept, including human-owned activation, an isolated Git worktree per mission, and the review-gated merge protocol, is inspired by the experimental Tower mode in [Kimi Code](https://www.kimi.com/code) ([source](https://github.com/MoonshotAI/kimi-code)). This project re-implements those ideas on top of DeepSeek Harness services; it contains no Kimi Code source code and is not affiliated with or endorsed by Moonshot AI.
162
+ - **DeepSeek Harness** — for the plugin, tool, session, and subagent APIs this project is built on.
163
+
164
+ ## License
165
+
166
+ MIT © Loui Finn
167
+
168
+ [![](https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white)](https://github.com/deepseek-ai/deepseek-harness)
package/README.md CHANGED
@@ -3,52 +3,52 @@
3
3
  [![CI](https://github.com/louifinn/dsh-tower/actions/workflows/test.yml/badge.svg?branch=main)](https://github.com/louifinn/dsh-tower/actions/workflows/test.yml)
4
4
  [![npm](https://img.shields.io/npm/v/dsh-tower.svg)](https://www.npmjs.com/package/dsh-tower)
5
5
  [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
6
- [中文说明](README.zh.md)
6
+ [English](README.en.md)
7
7
 
8
- **Tower** is a multi-agent orchestration plugin for [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) (DSH). It turns one agent session into a small engineering team: the session acts as the *control tower*, worker agents do the implementation inside isolated Git worktrees, and nothing reaches your base branch without an independent review and a hard merge gate.
8
+ **Tower** 是 [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness)(DSH)的多代理编排插件。它把一个 agent 会话变成一个小型工程团队:会话本身是*控制塔*,worker agent 在隔离的 Git worktree 里实现任务,任何改动都必须经过独立审查和硬门禁才能合入基线分支。
9
9
 
10
- Tower mode and its mission/review workflow are inspired by the experimental **Tower mode in [Kimi Code](https://www.kimi.com/code)** by Moonshot AI. This is an independent implementation for DSH and is not affiliated with Moonshot AI; see [Acknowledgements](#acknowledgements).
10
+ Tower 模式与 mission/审查工作流的灵感来自 Moonshot AI 的 **Kimi Code** 中实验性的 Tower 模式([Kimi Code](https://www.kimi.com/code))。本项目是面向 DSH 的独立实现,与 Moonshot AI 无隶属关系;详见[致谢](#致谢)。
11
11
 
12
- The repository root **is** the DSH bundle: `package.json` declares `dsh.bundle.patch` and `cordis.patch.yml` lives at the root, as the DSH plugin specification expects.
12
+ 仓库根目录**就是** DSH bundle 包根:`package.json` 声明 `dsh.bundle.patch`,`cordis.patch.yml` 位于根目录,符合 DSH 插件规范。
13
13
 
14
- ## Features
14
+ ## 特性
15
15
 
16
- - **Human-owned Tower mode.** `/tower` toggles a session-level stance that narrows the tool surface and injects the Tower protocol. `/tower on|off <instruction>` also forwards your instruction to the model in the same step.
17
- - **Missions with real isolation.** Every mission gets its own branch and Git worktree; workers never touch your checkout.
18
- - **Review-gated merges.** A reviewer bound to the current branch tip must submit a `clean` / `merge` verdict; the merge gate re-checks scope, staleness, task completion, review history, and worktree ownership before a `--no-ff` merge.
19
- - **Durable coordination.** Roster, inbox, findings, reviews, and mission state are persisted under `.git/dsh-tower/`, so a restart does not lose the plan.
20
- - **Safe by default.** While Tower mode is active, `create_goal`, `update_goal(resume)`, `todo_write`, the Team task board, and `spawn_teammate` are refused; a paused goal is never resumed automatically.
16
+ - **由人类掌握的 Tower 模式。** `/tower` 切换会话级模式:收窄工具面并注入 Tower 协议;`/tower on|off <指令>` 还能把指令作为下一步用户消息转发给模型。
17
+ - **任务级隔离。** 每个 mission 使用独立分支和 Git worktree,worker 不会碰到你的主检出。
18
+ - **审查门禁合并。** 审查者必须针对当前分支 tip 给出 `clean` / `merge` 结论;合并门禁重新校验 scope、分支新鲜度、任务完成度、审查记录与 worktree 归属,通过后才执行 `--no-ff` 合并。
19
+ - **可恢复的协作状态。** roster、inbox、findings、reviews 与 mission 状态持久化在 `.git/dsh-tower/` 下,重启不会丢计划。
20
+ - **默认安全。** Tower 模式开启期间拒绝 `create_goal`、`update_goal(resume)`、`todo_write`、Team 任务板与 `spawn_teammate`;被暂停的 goal 不会被自动恢复。
21
21
 
22
- ## Requirements
22
+ ## 环境要求
23
23
 
24
- - DeepSeek Harness `0.2.1-rc.2` (or a compatible newer build)
25
- - Node.js 22 or newer
26
- - Git (worktrees are created locally; no network access is required)
24
+ - DeepSeek Harness `0.2.1-rc.2`(或兼容的新版本)
25
+ - Node.js 22+
26
+ - Git(worktree 在本地创建,不需要联网)
27
27
 
28
- ## Install
28
+ ## 安装
29
29
 
30
- Install the published package into a DSH profile:
30
+ 把已发布的包安装到 DSH profile:
31
31
 
32
32
  ```bash
33
33
  dsh plugin --profile web add dsh-tower
34
34
  ```
35
35
 
36
- Inside DSH you can use the plugin manager with the same target:
36
+ 在 DSH 中也可以用插件管理器指定同一目标:
37
37
 
38
38
  ```text
39
39
  plugin_manager({ action: "install_bundle", target: "dsh-tower" })
40
40
  ```
41
41
 
42
- For development, install from GitHub or link a local checkout:
42
+ 开发时改为从 GitHub 安装或链接本地 checkout:
43
43
 
44
44
  ```bash
45
45
  dsh plugin --profile web add github:louifinn/dsh-tower
46
- dsh plugin --profile <profile> add <absolute path to this repository>
46
+ dsh plugin --profile <profile> add <本仓库的绝对路径>
47
47
  ```
48
48
 
49
- The Host plugin is loaded when the DSH process starts, so **restart DSH after installing or updating**. Remove it with `remove_bundle` and the package name `dsh-tower`.
49
+ Host 插件在 DSH 进程启动时加载,**安装或更新后需重启 DSH**。卸载使用 `remove_bundle`,包名 `dsh-tower`。
50
50
 
51
- ## Quick start
51
+ ## 快速开始
52
52
 
53
53
  ```
54
54
  /tower on
@@ -56,10 +56,10 @@ The Host plugin is loaded when the DSH process starts, so **restart DSH after in
56
56
  tower_init <repoRoot>
57
57
  tower_plan <repoRoot> missions=[...]
58
58
  tower_spawn <repoRoot> missionId=M1 role=worker
59
- # worker implements and reports completion
60
- tower_preflight <repoRoot> missionId=M1 # must be green before merging
59
+ # worker 实现并汇报完成
60
+ tower_preflight <repoRoot> missionId=M1 # 必须全绿才能合并
61
61
  tower_spawn <repoRoot> missionId=M1 role=reviewer
62
- tower_review ... # reviewer submits verdict
62
+ tower_review ... # 审查者提交结论
63
63
  tower_preflight <repoRoot> missionId=M1
64
64
  tower_merge <repoRoot> missionId=M1
65
65
  tower_teardown <repoRoot>
@@ -67,97 +67,99 @@ tower_teardown <repoRoot>
67
67
  /tower off
68
68
  ```
69
69
 
70
- A typical run takes one worker mission and one reviewer round for a small change.
70
+ 小改动通常一轮 worker + 一轮 reviewer 即可完成。
71
71
 
72
- ## Commands
72
+ ## 命令
73
73
 
74
- | Input | Behavior |
74
+ | 输入 | 行为 |
75
75
  |---|---|
76
- | `/tower` | Enter Tower mode (stays on if already on) |
77
- | `/tower on` | Enter or stay |
78
- | `/tower on <instruction>` | Enter/stay **and** forward `<instruction>` as the next user message |
79
- | `/tower off` | Leave Tower mode |
80
- | `/tower off <instruction>` | Leave **and** forward `<instruction>` |
81
- | `/tower <instruction>` | Ensure on, then forward `<instruction>` |
76
+ | `/tower` | 开启 Tower 模式(已开启则保持) |
77
+ | `/tower on` | 开启或保持 |
78
+ | `/tower on <指令>` | 开启/保持,并把 `<指令>` 作为下一条用户消息转发 |
79
+ | `/tower off` | 退出 Tower 模式 |
80
+ | `/tower off <指令>` | 退出并转发 `<指令>` |
81
+ | `/tower <指令>` | 确保开启,然后转发 `<指令>` |
82
82
 
83
- Image and file attachments are forwarded together with the instruction as one complete `UserMessage`.
83
+ 图片与文件附件会随指令一起作为一条完整的 `UserMessage` 转发。
84
84
 
85
- ## Configuration
85
+ ## 配置
86
86
 
87
87
  ```yaml
88
88
  - id: tower
89
89
  name: 'dsh-tower'
90
90
  config:
91
- section: 'Optional replacement for the Tower-mode system prompt text'
92
- allowAgentControl: true # true registers tower_control; false makes the mode human-only
91
+ section: '(可选)覆盖 Tower 模式系统提示文案'
92
+ allowAgentControl: true # true 时注册 tower_control;false 时只有人类能切换
93
93
  ```
94
94
 
95
- - `allowAgentControl` defaults to `false` in code. The bundled `cordis.patch.yml` enables it (`true`) so the model may enter/leave Tower mode when the user intent or task state clearly warrants it, always stating a reason.
96
- - When disabled, only the human can change the mode.
95
+ - 代码中 `allowAgentControl` 默认 `false`;仓库自带的 `cordis.patch.yml` 把它设为 `true`:当用户意图或任务状态明确时,模型可以自行进出 Tower 模式,但必须给出理由。
96
+ - 设为 `false` 时只有人类能切换模式。
97
97
 
98
- ## Tools (17)
98
+ ## 工具(17 个)
99
99
 
100
100
  `tower_init` · `tower_plan` · `tower_mission` · `tower_status` · `tower_spawn` · `tower_roster` · `tower_adopt` · `tower_send` · `tower_inbox` · `tower_complete` · `tower_finding` · `tower_review` · `tower_preflight` · `tower_merge` · `tower_rebase` · `tower_teardown` · `tower_mode`
101
101
 
102
- `tower_control` exists only when `allowAgentControl: true`.
102
+ `tower_control` 仅在 `allowAgentControl: true` 时存在。
103
103
 
104
- ## Safety and guardrails
104
+ ## 安全与约束
105
105
 
106
- - **No merge without a green gate.** `tower_preflight` is read-only and reports stable blocker codes; `tower_merge` refuses when any blocker remains.
107
- - **Review is bound to a commit.** If the branch tip moves after a review, the gate reports `tip-moved`; re-review is required (a clean rebase waiver is the only exception).
108
- - **Scope enforcement.** A mission may only change files matched by its declared scope globs; `out-of-scope` blocks the merge.
109
- - **Worktree ownership.** Dirty worktrees, a running worker, or a branch owned by a closed mission block teardown/merge.
110
- - **Goal isolation.** Entering Tower mode pauses the active goal and records it; leaving does not resume it. Use `/tower off` and resume the goal yourself.
106
+ - **门禁不全绿不合并。** `tower_preflight` 是只读检查,返回稳定的 blocker code;存在 blocker 时 `tower_merge` 拒绝合并。
107
+ - **审查绑定提交。** 审查后分支 tip 变化会报 `tip-moved`,必须重新审查;唯一的例外是干净 rebase 的免审豁免。
108
+ - **scope 强制。** mission 只能修改其 scope glob 匹配的文件,越界会以 `out-of-scope` 阻止合并。
109
+ - **worktree 归属。** 脏工作树、运行中的 worker、已关闭 mission 的分支都会阻止合并或 teardown。
110
+ - **goal 隔离。** 进入 Tower 模式会暂停活跃 goal 并记录;退出不会自动恢复,需要你自行恢复。
111
111
 
112
- The 13 stable blocker codes are: `branch-missing`, `deps-unmerged`, `read-only-survey`, `stale-base`, `no-review`, `review-hold`, `not-clean`, `review-not-approved`, `tip-moved`, `review-mission-mismatch`, `out-of-scope`, `base-dirty`, `branch-owned-by-closed-missions` (plus mission/worker guards).
112
+ 13 个稳定 blocker code:`branch-missing`、`deps-unmerged`、`read-only-survey`、`stale-base`、`no-review`、`review-hold`、`not-clean`、`review-not-approved`、`tip-moved`、`review-mission-mismatch`、`out-of-scope`、`base-dirty`、`branch-owned-by-closed-missions`(另有 mission/worker 守卫)。
113
113
 
114
- ## How it works
114
+ ## 工作原理
115
115
 
116
- A mission moves through `planned → active → completed → merged` (or `blocked` / `paused` / `abandoned`). State lives in `<git-common-dir>/dsh-tower/`:
116
+ mission 生命周期:`planned → active → completed → merged`(或 `blocked` / `paused` / `abandoned`)。状态位置:
117
117
 
118
- | Path | Contents |
118
+ | 路径 | 内容 |
119
119
  |---|---|
120
- | `.git/dsh-tower/state.json` | missions, roster, reviews, findings, inbox |
121
- | `.git/dsh-tower/activity.jsonl` | append-only activity log |
122
- | `<checkout parent>/.dsh-tower-worktrees/<hash>/<missionId>` | per-mission worktree |
123
- | `$DSH_HOME/tower-mode/modes.json` | Tower mode state per agent/session |
120
+ | `.git/dsh-tower/state.json` | missions、roster、reviews、findings、inbox |
121
+ | `.git/dsh-tower/activity.jsonl` | 只追加的活动日志 |
122
+ | `<主检出父目录>/.dsh-tower-worktrees/<hash>/<missionId>` | 每个 mission 的 worktree |
123
+ | `$DSH_HOME/tower-mode/modes.json` | 每个 agent/session 的 Tower 模式状态 |
124
124
 
125
- Workers and reviewers are ordinary DSH subagents started with the bundled `tower-worker` preset; their tools are restricted to the mission surface (filesystem, shell, and Tower protocol tools), never merge/spawn.
125
+ worker 与 reviewer 是使用内置 `tower-worker` preset 的普通 DSH 子代理;它们的工具被限制在 mission 允许面(文件系统、shell、Tower 协议工具),不包含 merge/spawn。
126
126
 
127
- ## Project layout
127
+ ## 项目结构
128
128
 
129
- | Path | Contents |
129
+ | 路径 | 内容 |
130
130
  |---|---|
131
- | `package.json`, `cordis.patch.yml` | bundle manifest and loader patch (inserts the Host plugin and the `tower-worker` preset) |
132
- | `index.js`, `lib/`, `worker.js` | Host entry, protocol implementation, worker preset policy |
133
- | `locale/`, `icon.svg` | localization and icon |
134
- | `tests/` | pure-function, protocol end-to-end, and plugin-level tests; optional isolated Web-runtime scripts |
135
- | `ui-bundle/` | optional Client widget (static `/tower` hint), not installed by default |
136
- | `.github/workflows/test.yml` | CI running the offline suite on Node 22/24 |
131
+ | `package.json`、`cordis.patch.yml` | bundle 清单与 loader patch(插入 Host 插件和 `tower-worker` preset) |
132
+ | `index.js`、`lib/`、`worker.js` | Host 入口、协议实现、worker preset 策略 |
133
+ | `locale/`、`icon.svg` | 本地化与图标 |
134
+ | `tests/` | 纯函数、协议 e2e、插件级测试;可选的真实 Web 运行时脚本 |
135
+ | `ui-bundle/` | 可选 Client 小挂件(静态 `/tower` 提示),默认不安装 |
136
+ | `.github/workflows/test.yml` | CI:Node 22/24 上跑离线套件 |
137
137
 
138
- ## Development and testing
138
+ ## 开发与测试
139
139
 
140
140
  ```bash
141
141
  npm test # node --test tests/*.test.mjs
142
142
  ```
143
143
 
144
- The offline suite needs no DSH process and creates throwaway Git repositories in the OS temp directory. `tests/README.md` documents the isolated Web-runtime checks, model-driven acceptance, and the session-diagnostic scripts used while hardening the plugin.
144
+ 离线套件不需要 DSH 进程,只在系统临时目录创建一次性 Git 仓库。隔离 Web 运行时检查、模型驱动验收和会话诊断脚本见 `tests/README.md`。
145
145
 
146
- ## Troubleshooting
146
+ ## 常见问题
147
147
 
148
- - **The plugin does not appear after install/update.** Host plugins load at DSH startup; restart DSH. Confirm the entry point targets this repository root (the folder with `package.json` and `cordis.patch.yml`), not a subdirectory.
149
- - **`tower_control` is missing.** The config `allowAgentControl` is `false`; set it to `true` and restart.
150
- - **Merge is blocked.** Run `tower_preflight` and read the blocker codes above; `stale-base` means run `tower_rebase` first.
151
- - **A mission branch is stale after base moved.** `tower_rebase` rebases inside the worktree; a clean conflict-free rebase waives re-review for the previous verdict.
148
+ - **安装/更新后插件不出现。** Host 插件在 DSH 启动时加载,请重启 DSH;并确认安装目标指向本仓库根目录(含 `package.json` 与 `cordis.patch.yml` 的那一层),而不是子目录。
149
+ - **没有 `tower_control`。** 配置 `allowAgentControl` 为 `false`;改为 `true` 并重启。
150
+ - **合并被阻止。** 运行 `tower_preflight` 查看 blocker code;`stale-base` 表示先执行 `tower_rebase`。
151
+ - **基线前进后分支过期。** `tower_rebase` 在 worktree 内完成 rebase;干净且无冲突的 rebase 可免去上一轮审查的重审。
152
+ - **合并提交已经存在,但 `tower_merge` 报了失败。** 重新执行 `tower_merge` 即可:它会检测已落地的合并提交(或分支已包含在 base 中)并记录为 reconciled,不会重复合并。
153
+ - **仓库的 `pre-merge-commit` 钩子很慢。** 合并超时默认 15 分钟,可按次通过 `tower_merge({ ..., timeoutMs: 900000 })` 调大;钩子输出缓冲上限 64 MB。钩子失败返回 `merge-hook-failed`(超时带 `timeout: true`),并保持仓库干净以便重试。
152
154
 
153
- ## Contributing
155
+ ## 贡献
154
156
 
155
- Issues and pull requests are welcome. Please run `npm test` before opening a PR; see [CONTRIBUTING.md](CONTRIBUTING.md) for details. Security reports: please avoid opening a public issue with exploit details; open a minimal issue asking for a private channel.
157
+ 欢迎 issue 与 pull request。提交 PR 前请先运行 `npm test`,细节见 [CONTRIBUTING.md](CONTRIBUTING.md)。安全相关问题请不要在公开 issue 里贴利用细节,先开一个最小化 issue 申请私下渠道。
156
158
 
157
- ## Acknowledgements
159
+ ## 致谢
158
160
 
159
- - **Kimi Code (Moonshot AI)** — the Tower mode concept, including human-owned activation, an isolated Git worktree per mission, and the review-gated merge protocol, is inspired by the experimental Tower mode in [Kimi Code](https://www.kimi.com/code) ([source](https://github.com/MoonshotAI/kimi-code)). This project re-implements those ideas on top of DeepSeek Harness services; it contains no Kimi Code source code and is not affiliated with or endorsed by Moonshot AI.
160
- - **DeepSeek Harness** — for the plugin, tool, session, and subagent APIs this project is built on.
161
+ - **Kimi Code(Moonshot AI)**:Tower 模式的整体概念——由人类开启的模式、每个 mission 独立 Git worktree、审查门禁合并协议——灵感来自 Kimi Code 的实验性 Tower 模式([官网](https://www.kimi.com/code)、[源码](https://github.com/MoonshotAI/kimi-code))。本项目在 DeepSeek Harness 的服务之上重新实现这些想法,不包含 Kimi Code 源码,也与 Moonshot AI 无隶属关系。
162
+ - **DeepSeek Harness**:本插件构建所依赖的插件、工具、会话与子代理 API。
161
163
 
162
164
  ## License
163
165
 
package/lib/git.js CHANGED
@@ -6,10 +6,13 @@ const GIT_TIMEOUT_MS = 15000
6
6
  const MAX_BUFFER = 16 * 1024 * 1024
7
7
 
8
8
  export class GitError extends Error {
9
- constructor(args, detail, code) {
9
+ constructor(args, detail, code, meta = {}) {
10
10
  super(`git ${args.join(' ')} failed: ${detail}`)
11
11
  this.name = 'GitError'
12
12
  this.code = code
13
+ this.timedOut = meta.timedOut === true
14
+ this.bufferOverflow = meta.bufferOverflow === true
15
+ this.stdout = meta.stdout ?? ''
13
16
  }
14
17
  }
15
18
 
@@ -27,11 +30,14 @@ export function git(cwd, args, options = {}) {
27
30
  },
28
31
  (error, stdout, stderr) => {
29
32
  if (error) {
33
+ const timedOut = error.killed === true || error.signal === 'SIGTERM' || error.code === 'ETIMEDOUT'
34
+ const bufferOverflow = error.code === 'ERR_CHILD_PROCESS_STDIO_MAXBUFFER'
30
35
  reject(
31
36
  new GitError(
32
37
  args,
33
38
  String(stderr || error.message || '').trim(),
34
39
  typeof error.code === 'number' ? error.code : undefined,
40
+ { timedOut, bufferOverflow, stdout: String(stdout ?? '').slice(0, 64 * 1024) },
35
41
  ),
36
42
  )
37
43
  return
package/lib/tools.js CHANGED
@@ -732,12 +732,17 @@ export function registerTowerTools(ctx) {
732
732
  define(ctx, {
733
733
  name: 'tower_merge',
734
734
  description:
735
- 'Merge a mission branch into the recorded base branch with --no-ff, after the hard gate passes. Verifies the two merge parents and records the mission as merged. A failed gate returns stable blocker codes instead of merging; a Git conflict aborts the merge and marks the mission blocked.',
735
+ 'Merge a mission branch into the recorded base branch with --no-ff, after the hard gate passes. Verifies the two merge parents and records the mission as merged. If the merge commit already landed (manual merge, crashed or slow pre-merge hook), it detects the merge commit and records it as reconciled instead of merging again. A failed gate returns stable blocker codes without merging; a hook failure aborts cleanly and returns "merge-hook-failed" while a real content conflict returns "merge-conflict".',
736
736
  parameters: {
737
737
  type: 'object',
738
738
  properties: {
739
739
  repoRoot: { type: 'string', description: 'Absolute path inside the target Git repository.' },
740
740
  missionId: { type: 'string', description: 'Mission id, e.g. M1.' },
741
+ timeoutMs: {
742
+ type: 'integer',
743
+ minimum: 1000,
744
+ description: 'Optional timeout for the merge command in milliseconds, including pre-merge hooks (default 15 minutes).',
745
+ },
741
746
  },
742
747
  required: ['repoRoot', 'missionId'],
743
748
  additionalProperties: false,
@@ -746,14 +751,14 @@ export function registerTowerTools(ctx) {
746
751
  async execute(args) {
747
752
  const tower = await Tower.open(args.repoRoot)
748
753
  const { activity } = await observeActivity(ctx, tower)
749
- return tower.merge({ missionId: args.missionId, activity })
754
+ return tower.merge({ missionId: args.missionId, activity, timeoutMs: args.timeoutMs })
750
755
  },
751
756
  })
752
757
 
753
758
  define(ctx, {
754
759
  name: 'tower_rebase',
755
760
  description:
756
- 'Rebase a mission branch onto the current base tip inside its own worktree. Refuses while its worker is running or the worktree is dirty. A clean conflict-free rebase waives re-review for the old review; a conflict is aborted and the mission is marked blocked.',
761
+ 'Rebase a mission branch onto the current base tip inside its own worktree. Refuses while its worker is running or the worktree is dirty. A clean conflict-free rebase waives re-review for the old review; a conflict is aborted and the mission is marked blocked. If the branch is already contained in the base, it returns alreadyMerged and leaves the branch untouched so tower_merge can record the landed merge.',
757
762
  parameters: {
758
763
  type: 'object',
759
764
  properties: {
package/lib/tower.js CHANGED
@@ -1,5 +1,6 @@
1
1
  // Tower domain: repo-scoped mission state machine, validation, and gates.
2
2
  import path from 'node:path'
3
+ import fsp from 'node:fs/promises'
3
4
  import {
4
5
  git,
5
6
  resolveRepo,
@@ -14,6 +15,7 @@ import {
14
15
  countCommits,
15
16
  isClean,
16
17
  isAncestor,
18
+ tryGit,
17
19
  } from './git.js'
18
20
  import {
19
21
  pathsFor,
@@ -41,7 +43,7 @@ const TERMINAL = new Set(['merged', 'abandoned'])
41
43
  const TRANSITIONS = {
42
44
  planned: ['active', 'abandoned'],
43
45
  active: ['completed', 'blocked', 'paused', 'abandoned'],
44
- blocked: ['active', 'paused', 'abandoned'],
46
+ blocked: ['active', 'paused', 'completed', 'abandoned'],
45
47
  paused: ['active', 'abandoned'],
46
48
  completed: ['active', 'abandoned'],
47
49
  merged: [],
@@ -63,6 +65,46 @@ export const FINDING_SEVERITIES = ['low', 'medium', 'high', 'critical']
63
65
  export const MERGE_VERDICTS = ['merge', 'fix-then-merge', 'hold']
64
66
  const MAX_REVIEW_ROUNDS = 5
65
67
  const REVIEW_STATUS_RE = /^(clean|p1-\d+items|p2-\d+items)$/
68
+ const DEFAULT_MERGE_TIMEOUT_MS = 15 * 60 * 1000
69
+ const MERGE_MAX_BUFFER = 64 * 1024 * 1024
70
+
71
+ function mergeTimeoutMs(value) {
72
+ if (value === undefined || value === null) return DEFAULT_MERGE_TIMEOUT_MS
73
+ const ms = Number(value)
74
+ if (!Number.isInteger(ms) || ms < 1000) fail('timeoutMs must be an integer of at least 1000 milliseconds')
75
+ return ms
76
+ }
77
+
78
+ async function commitParents(cwd, sha) {
79
+ const line = await tryGit(cwd, ['rev-list', '--parents', '-n', '1', sha])
80
+ return line ? line.trim().split(/\s+/).slice(1) : []
81
+ }
82
+
83
+ /**
84
+ * Find a reachable merge commit whose second parent is one of the known tips.
85
+ * `--no-ff` merges put the integrated branch on the second parent, so this is
86
+ * the concrete evidence that a mission's work already landed in the base.
87
+ */
88
+ async function findLandedMerge(cwd, baseTip, tips) {
89
+ const revs = (await tryGit(cwd, ['rev-list', '--merges', '-n', '100', baseTip])) ?? ''
90
+ for (const sha of revs.split('\n').filter(Boolean)) {
91
+ const parents = await commitParents(cwd, sha)
92
+ if (parents.length >= 2 && tips.has(parents[1])) return { commit: sha, parents }
93
+ }
94
+ return null
95
+ }
96
+
97
+ function describeGitFailure(error) {
98
+ const prefix = error?.timedOut ? 'timed out: ' : error?.bufferOverflow ? 'output buffer overflow: ' : ''
99
+ return `${prefix}${String(error?.message ?? error)}`
100
+ }
101
+
102
+ /** A killed git process can leave lock files behind; only used after a failed merge. */
103
+ async function removeStaleGitLocks(commonDir) {
104
+ for (const name of ['index.lock', 'HEAD.lock', 'MERGE_HEAD.lock', 'ORIG_HEAD.lock']) {
105
+ await fsp.unlink(path.join(commonDir, name)).catch(() => {})
106
+ }
107
+ }
66
108
 
67
109
  function globToRegExp(pattern) {
68
110
  let out = '^'
@@ -361,6 +403,8 @@ export function missionSummary(mission) {
361
403
  blockers: mission.blockers ?? [],
362
404
  tasks: taskCounts(mission.tasks ?? []),
363
405
  scopeGeneration: mission.scopeGeneration ?? 1,
406
+ mergedCommit: mission.mergedCommit ?? null,
407
+ mergedAt: mission.mergedAt ?? null,
364
408
  updatedAt: mission.updatedAt,
365
409
  }
366
410
  }
@@ -1286,35 +1330,197 @@ export class Tower {
1286
1330
  }
1287
1331
  }
1288
1332
 
1333
+ /**
1334
+ * Record a merge that already landed in Git without being recorded here:
1335
+ * a crashed/timed-out merge, a manual merge, or a rebase that fast-forwarded
1336
+ * the branch to the base tip. The evidence is concrete: a clean merge review
1337
+ * exists and either the branch tip or the reviewed commit is already an
1338
+ * ancestor of the base.
1339
+ */
1340
+ async reconcileLandedMerge(state, mission) {
1341
+ if (!['completed', 'blocked'].includes(mission.status)) return null
1342
+ if (!mission.worktree) return null
1343
+ const branch = mission.worktree.branch
1344
+ const reviews = (state.reviews ?? []).filter((review) => review.branch === branch)
1345
+ if (reviews.length === 0) return null
1346
+ const latest = reviews.reduce((a, b) => (a.round >= b.round ? a : b))
1347
+ if (latest.status !== 'clean' || latest.mergeVerdict !== 'merge') return null
1348
+ let baseTip
1349
+ let branchTip
1350
+ try {
1351
+ baseTip = await revParse(this.root, state.base)
1352
+ branchTip = await revParse(mission.worktree.path, 'HEAD')
1353
+ } catch {
1354
+ return null
1355
+ }
1356
+ const reviewedTip = latest.reviewedCommit
1357
+ const branchLanded = await isAncestor(this.root, branchTip, baseTip)
1358
+ const reviewedLanded = reviewedTip ? await isAncestor(this.root, reviewedTip, baseTip) : false
1359
+ if (!branchLanded && !reviewedLanded) return null
1360
+ const tips = new Set([branchTip, reviewedTip].filter(Boolean))
1361
+ const found = await findLandedMerge(this.root, baseTip, tips)
1362
+ const mergedCommit = found ? found.commit : reviewedLanded ? reviewedTip : branchTip
1363
+ const now = Date.now()
1364
+ mission.status = 'merged'
1365
+ mission.mergedAt = now
1366
+ mission.mergedCommit = mergedCommit
1367
+ mission.blockers = []
1368
+ mission.notes.push(
1369
+ found
1370
+ ? `reconciled already-landed merge ${mergedCommit.slice(0, 7)} with parents [${found.parents.map((parent) => parent.slice(0, 7)).join(', ')}]`
1371
+ : `reconciled already-landed merge: ${mergedCommit.slice(0, 7)} is contained in ${state.base} (no merge commit found)`,
1372
+ )
1373
+ mission.updatedAt = now
1374
+ state.revision += 1
1375
+ state.updatedAt = now
1376
+ validateState(state, this.paths.state)
1377
+ await writeJsonAtomic(this.paths.state, state)
1378
+ await appendJsonl(this.paths.activity, {
1379
+ at: now,
1380
+ type: 'merge',
1381
+ missionId: mission.id,
1382
+ text: `reconciled already-landed ${mergedCommit.slice(0, 7)} on ${state.base}`,
1383
+ })
1384
+ return {
1385
+ ok: true,
1386
+ reconciled: true,
1387
+ alreadyLanded: true,
1388
+ mergedCommit,
1389
+ baseTip,
1390
+ branchTip,
1391
+ parents: found ? found.parents : null,
1392
+ review: {
1393
+ round: latest.round,
1394
+ status: latest.status,
1395
+ mergeVerdict: latest.mergeVerdict,
1396
+ reviewer: latest.reviewer,
1397
+ reviewedCommit: reviewedTip,
1398
+ },
1399
+ }
1400
+ }
1401
+
1289
1402
  async merge(input) {
1290
1403
  return withLock(this.paths, async () => {
1291
- const gate = await this.evaluateGate(input)
1292
- if (!gate.ok) return { ok: false, blockers: gate.blockers, missionId: input.missionId }
1293
1404
  const state = await this.load()
1294
1405
  const mission = state.missions.find((entry) => entry.id === input.missionId)
1406
+ if (!mission) {
1407
+ return { ok: false, blockers: [{ code: 'mission-missing', detail: `unknown mission "${input.missionId}"` }] }
1408
+ }
1409
+ const baseTip = await revParse(this.root, state.base)
1410
+ if (mission.status === 'merged') {
1411
+ return { ok: true, alreadyMerged: true, mergedCommit: mission.mergedCommit ?? null, baseTip }
1412
+ }
1413
+ const reconciled = await this.reconcileLandedMerge(state, mission)
1414
+ if (reconciled) return reconciled
1415
+ const gate = await this.evaluateGate(input)
1416
+ if (!gate.ok) return { ok: false, blockers: gate.blockers, missionId: input.missionId }
1417
+
1295
1418
  const worktree = mission.worktree
1296
- const baseTipBefore = await revParse(this.root, state.base)
1419
+ const baseTipBefore = await revParse(this.root, 'HEAD')
1297
1420
  const branchTip = await revParse(worktree.path, 'HEAD')
1298
-
1421
+ const timeoutMs = mergeTimeoutMs(input.timeoutMs)
1422
+ let failure = null
1299
1423
  try {
1300
- await git(this.root, ['merge', '--no-ff', worktree.branch, '-m', `tower: merge ${mission.id} — ${mission.title}`])
1424
+ await git(
1425
+ this.root,
1426
+ ['merge', '--no-ff', worktree.branch, '-m', `tower: merge ${mission.id} — ${mission.title}`],
1427
+ { timeoutMs, maxBuffer: MERGE_MAX_BUFFER },
1428
+ )
1301
1429
  } catch (error) {
1302
- await git(this.root, ['merge', '--abort']).catch(() => {})
1430
+ failure = error
1431
+ }
1432
+
1433
+ const newTip = await revParse(this.root, 'HEAD')
1434
+ const parents = await commitParents(this.root, newTip)
1435
+ const expectedShape = parents.length === 2 && parents[0] === baseTipBefore && parents[1] === branchTip
1436
+ const warnings = []
1437
+
1438
+ if (failure && expectedShape) {
1439
+ warnings.push(
1440
+ `merge command reported a failure (${describeGitFailure(failure)}) but merge commit ${newTip.slice(0, 7)} exists; treated as merged`,
1441
+ )
1442
+ const dirty = (await git(this.root, ['status', '--porcelain']))
1443
+ .split('\n')
1444
+ .filter(Boolean)
1445
+ .map((line) => line.slice(3).trim())
1446
+ if (dirty.length > 0) {
1447
+ const changed = new Set(gate.changedFiles ?? [])
1448
+ if (dirty.every((file) => changed.has(file))) {
1449
+ await git(this.root, ['reset', '--hard', 'HEAD'])
1450
+ warnings.push('restored index/worktree to the merge commit (only merge-related paths were dirty)')
1451
+ } else {
1452
+ warnings.push('working tree still has unrelated changes; left untouched — inspect it before the next Git operation')
1453
+ }
1454
+ }
1455
+ } else if (failure) {
1456
+ const unmerged = (await tryGit(this.root, ['ls-files', '-u'])) ?? ''
1457
+ const conflict = unmerged.trim().length > 0
1458
+ let cleanup = 'none'
1459
+ const mergeHeadPath = path.join(this.commonDir, 'MERGE_HEAD')
1460
+ if (await exists(mergeHeadPath)) {
1461
+ try {
1462
+ await git(this.root, ['merge', '--abort'])
1463
+ cleanup = 'aborted'
1464
+ } catch {
1465
+ await removeStaleGitLocks(this.commonDir)
1466
+ try {
1467
+ await git(this.root, ['merge', '--abort'])
1468
+ cleanup = 'aborted-after-lock-cleanup'
1469
+ } catch {
1470
+ await git(this.root, ['reset', '--hard', baseTipBefore]).catch(() => {})
1471
+ cleanup = 'reset'
1472
+ }
1473
+ }
1474
+ } else if (newTip !== baseTipBefore) {
1475
+ await removeStaleGitLocks(this.commonDir)
1476
+ await git(this.root, ['reset', '--hard', baseTipBefore]).catch(() => {})
1477
+ cleanup = 'reset'
1478
+ }
1479
+ // A killed git process can leave the index staged even without
1480
+ // MERGE_HEAD. Restore only when every dirty path belongs to this merge.
1481
+ const dirtyAfter = (await git(this.root, ['status', '--porcelain']))
1482
+ .split('\n')
1483
+ .filter(Boolean)
1484
+ .map((line) => line.slice(3).trim())
1485
+ if (dirtyAfter.length > 0) {
1486
+ const changed = new Set(gate.changedFiles ?? [])
1487
+ if (dirtyAfter.every((file) => changed.has(file))) {
1488
+ await removeStaleGitLocks(this.commonDir)
1489
+ await git(this.root, ['reset', '--hard', baseTipBefore]).catch(() => {})
1490
+ cleanup = cleanup === 'none' ? 'reset' : `${cleanup}+reset`
1491
+ } else {
1492
+ warnings.push(`cleanup left unrelated changes untouched: ${dirtyAfter.slice(0, 5).join(', ')}`)
1493
+ }
1494
+ }
1495
+ const detail = describeGitFailure(failure).slice(0, 2000)
1303
1496
  const now = Date.now()
1304
- mission.status = 'blocked'
1305
- mission.blockers.push(`merge conflict: ${String(error?.message ?? error).slice(0, 240)}`)
1497
+ if (conflict) {
1498
+ mission.status = 'blocked'
1499
+ mission.blockers.push(`merge conflict: ${detail.slice(0, 240)}`)
1500
+ } else {
1501
+ mission.notes.push(`merge command failed before creating a commit (${detail.slice(0, 240)}); mission left ${mission.status} for retry`)
1502
+ }
1306
1503
  mission.updatedAt = now
1307
1504
  state.revision += 1
1308
1505
  state.updatedAt = now
1309
1506
  await writeJsonAtomic(this.paths.state, state)
1310
- await appendJsonl(this.paths.activity, { at: now, type: 'merge', missionId: mission.id, text: 'conflict' })
1311
- return { ok: false, blockers: [{ code: 'merge-conflict', detail: String(error?.message ?? error) }] }
1507
+ await appendJsonl(this.paths.activity, { at: now, type: 'merge', missionId: mission.id, text: `${conflict ? 'conflict' : 'failed-before-commit'} (${cleanup})` })
1508
+ return {
1509
+ ok: false,
1510
+ missionId: mission.id,
1511
+ blockers: [
1512
+ {
1513
+ code: conflict ? 'merge-conflict' : 'merge-hook-failed',
1514
+ detail,
1515
+ ...(failure.timedOut === true ? { timeout: true } : {}),
1516
+ cleanup,
1517
+ },
1518
+ ],
1519
+ ...(warnings.length > 0 ? { warnings } : {}),
1520
+ }
1312
1521
  }
1313
1522
 
1314
- const newTip = await revParse(this.root, 'HEAD')
1315
- const parentLine = await git(this.root, ['rev-list', '--parents', '-n', '1', 'HEAD'])
1316
- const parents = parentLine.trim().split(/\s+/).slice(1)
1317
- if (parents.length !== 2 || parents[0] !== baseTipBefore || parents[1] !== branchTip) {
1523
+ if (!expectedShape) {
1318
1524
  return {
1319
1525
  ok: false,
1320
1526
  blockers: [
@@ -1342,7 +1548,14 @@ export class Tower {
1342
1548
  missionId: mission.id,
1343
1549
  text: `${branchTip.slice(0, 7)} -> ${newTip.slice(0, 7)} on ${state.base}`,
1344
1550
  })
1345
- return { ok: true, mergedCommit: newTip, baseTip: baseTipBefore, branchTip, parents }
1551
+ return {
1552
+ ok: true,
1553
+ mergedCommit: newTip,
1554
+ baseTip: baseTipBefore,
1555
+ branchTip,
1556
+ parents,
1557
+ ...(warnings.length > 0 ? { warnings, recoveredFromFailure: true } : {}),
1558
+ }
1346
1559
  })
1347
1560
  }
1348
1561
 
@@ -1364,6 +1577,20 @@ export class Tower {
1364
1577
  }
1365
1578
  const oldTip = await revParse(mission.worktree.path, 'HEAD')
1366
1579
  const baseTip = await revParse(this.root, state.base)
1580
+ if (await isAncestor(this.root, oldTip, baseTip)) {
1581
+ // The branch is already contained in the base (manual merge, crashed
1582
+ // merge, earlier fast-forward). Rewriting the branch tip to the base
1583
+ // would destroy the reviewed tip and the ability to record the merge.
1584
+ return {
1585
+ ok: true,
1586
+ alreadyMerged: true,
1587
+ skipped: true,
1588
+ oldTip,
1589
+ newTip: oldTip,
1590
+ baseTip,
1591
+ note: `${oldTip.slice(0, 7)} is already contained in ${state.base} ${baseTip.slice(0, 7)}; branch left untouched — call tower_merge to record the landed merge`,
1592
+ }
1593
+ }
1367
1594
  if (await isAncestor(this.root, baseTip, oldTip)) {
1368
1595
  return { ok: true, alreadyUpToDate: true, oldTip, newTip: oldTip, baseTip }
1369
1596
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-tower",
3
- "version": "1.1.1",
3
+ "version": "1.2.0",
4
4
  "type": "module",
5
5
  "description": "DSH-native Tower: mission planning, worktree isolation, review-gated merges, and a human-owned Tower mode.",
6
6
  "license": "MIT",
@@ -39,7 +39,7 @@
39
39
  "locale/*.json",
40
40
  "icon.svg",
41
41
  "README.md",
42
- "README.zh.md",
42
+ "README.en.md",
43
43
  "CHANGELOG.md",
44
44
  "LICENSE"
45
45
  ],
package/README.zh.md DELETED
@@ -1,166 +0,0 @@
1
- # dsh-tower
2
-
3
- [![CI](https://github.com/louifinn/dsh-tower/actions/workflows/test.yml/badge.svg?branch=main)](https://github.com/louifinn/dsh-tower/actions/workflows/test.yml)
4
- [![npm](https://img.shields.io/npm/v/dsh-tower.svg)](https://www.npmjs.com/package/dsh-tower)
5
- [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
6
- [English](README.md)
7
-
8
- **Tower** 是 [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness)(DSH)的多代理编排插件。它把一个 agent 会话变成一个小型工程团队:会话本身是*控制塔*,worker agent 在隔离的 Git worktree 里实现任务,任何改动都必须经过独立审查和硬门禁才能合入基线分支。
9
-
10
- Tower 模式与 mission/审查工作流的灵感来自 Moonshot AI 的 **Kimi Code** 中实验性的 Tower 模式([Kimi Code](https://www.kimi.com/code))。本项目是面向 DSH 的独立实现,与 Moonshot AI 无隶属关系;详见[致谢](#致谢)。
11
-
12
- 仓库根目录**就是** DSH bundle 包根:`package.json` 声明 `dsh.bundle.patch`,`cordis.patch.yml` 位于根目录,符合 DSH 插件规范。
13
-
14
- ## 特性
15
-
16
- - **由人类掌握的 Tower 模式。** `/tower` 切换会话级模式:收窄工具面并注入 Tower 协议;`/tower on|off <指令>` 还能把指令作为下一步用户消息转发给模型。
17
- - **任务级隔离。** 每个 mission 使用独立分支和 Git worktree,worker 不会碰到你的主检出。
18
- - **审查门禁合并。** 审查者必须针对当前分支 tip 给出 `clean` / `merge` 结论;合并门禁重新校验 scope、分支新鲜度、任务完成度、审查记录与 worktree 归属,通过后才执行 `--no-ff` 合并。
19
- - **可恢复的协作状态。** roster、inbox、findings、reviews 与 mission 状态持久化在 `.git/dsh-tower/` 下,重启不会丢计划。
20
- - **默认安全。** Tower 模式开启期间拒绝 `create_goal`、`update_goal(resume)`、`todo_write`、Team 任务板与 `spawn_teammate`;被暂停的 goal 不会被自动恢复。
21
-
22
- ## 环境要求
23
-
24
- - DeepSeek Harness `0.2.1-rc.2`(或兼容的新版本)
25
- - Node.js 22+
26
- - Git(worktree 在本地创建,不需要联网)
27
-
28
- ## 安装
29
-
30
- 把已发布的包安装到 DSH profile:
31
-
32
- ```bash
33
- dsh plugin --profile web add dsh-tower
34
- ```
35
-
36
- 在 DSH 中也可以用插件管理器指定同一目标:
37
-
38
- ```text
39
- plugin_manager({ action: "install_bundle", target: "dsh-tower" })
40
- ```
41
-
42
- 开发时改为从 GitHub 安装或链接本地 checkout:
43
-
44
- ```bash
45
- dsh plugin --profile web add github:louifinn/dsh-tower
46
- dsh plugin --profile <profile> add <本仓库的绝对路径>
47
- ```
48
-
49
- Host 插件在 DSH 进程启动时加载,**安装或更新后需重启 DSH**。卸载使用 `remove_bundle`,包名 `dsh-tower`。
50
-
51
- ## 快速开始
52
-
53
- ```
54
- /tower on
55
-
56
- tower_init <repoRoot>
57
- tower_plan <repoRoot> missions=[...]
58
- tower_spawn <repoRoot> missionId=M1 role=worker
59
- # worker 实现并汇报完成
60
- tower_preflight <repoRoot> missionId=M1 # 必须全绿才能合并
61
- tower_spawn <repoRoot> missionId=M1 role=reviewer
62
- tower_review ... # 审查者提交结论
63
- tower_preflight <repoRoot> missionId=M1
64
- tower_merge <repoRoot> missionId=M1
65
- tower_teardown <repoRoot>
66
-
67
- /tower off
68
- ```
69
-
70
- 小改动通常一轮 worker + 一轮 reviewer 即可完成。
71
-
72
- ## 命令
73
-
74
- | 输入 | 行为 |
75
- |---|---|
76
- | `/tower` | 开启 Tower 模式(已开启则保持) |
77
- | `/tower on` | 开启或保持 |
78
- | `/tower on <指令>` | 开启/保持,并把 `<指令>` 作为下一条用户消息转发 |
79
- | `/tower off` | 退出 Tower 模式 |
80
- | `/tower off <指令>` | 退出并转发 `<指令>` |
81
- | `/tower <指令>` | 确保开启,然后转发 `<指令>` |
82
-
83
- 图片与文件附件会随指令一起作为一条完整的 `UserMessage` 转发。
84
-
85
- ## 配置
86
-
87
- ```yaml
88
- - id: tower
89
- name: 'dsh-tower'
90
- config:
91
- section: '(可选)覆盖 Tower 模式系统提示文案'
92
- allowAgentControl: true # true 时注册 tower_control;false 时只有人类能切换
93
- ```
94
-
95
- - 代码中 `allowAgentControl` 默认 `false`;仓库自带的 `cordis.patch.yml` 把它设为 `true`:当用户意图或任务状态明确时,模型可以自行进出 Tower 模式,但必须给出理由。
96
- - 设为 `false` 时只有人类能切换模式。
97
-
98
- ## 工具(17 个)
99
-
100
- `tower_init` · `tower_plan` · `tower_mission` · `tower_status` · `tower_spawn` · `tower_roster` · `tower_adopt` · `tower_send` · `tower_inbox` · `tower_complete` · `tower_finding` · `tower_review` · `tower_preflight` · `tower_merge` · `tower_rebase` · `tower_teardown` · `tower_mode`
101
-
102
- `tower_control` 仅在 `allowAgentControl: true` 时存在。
103
-
104
- ## 安全与约束
105
-
106
- - **门禁不全绿不合并。** `tower_preflight` 是只读检查,返回稳定的 blocker code;存在 blocker 时 `tower_merge` 拒绝合并。
107
- - **审查绑定提交。** 审查后分支 tip 变化会报 `tip-moved`,必须重新审查;唯一的例外是干净 rebase 的免审豁免。
108
- - **scope 强制。** mission 只能修改其 scope glob 匹配的文件,越界会以 `out-of-scope` 阻止合并。
109
- - **worktree 归属。** 脏工作树、运行中的 worker、已关闭 mission 的分支都会阻止合并或 teardown。
110
- - **goal 隔离。** 进入 Tower 模式会暂停活跃 goal 并记录;退出不会自动恢复,需要你自行恢复。
111
-
112
- 13 个稳定 blocker code:`branch-missing`、`deps-unmerged`、`read-only-survey`、`stale-base`、`no-review`、`review-hold`、`not-clean`、`review-not-approved`、`tip-moved`、`review-mission-mismatch`、`out-of-scope`、`base-dirty`、`branch-owned-by-closed-missions`(另有 mission/worker 守卫)。
113
-
114
- ## 工作原理
115
-
116
- mission 生命周期:`planned → active → completed → merged`(或 `blocked` / `paused` / `abandoned`)。状态位置:
117
-
118
- | 路径 | 内容 |
119
- |---|---|
120
- | `.git/dsh-tower/state.json` | missions、roster、reviews、findings、inbox |
121
- | `.git/dsh-tower/activity.jsonl` | 只追加的活动日志 |
122
- | `<主检出父目录>/.dsh-tower-worktrees/<hash>/<missionId>` | 每个 mission 的 worktree |
123
- | `$DSH_HOME/tower-mode/modes.json` | 每个 agent/session 的 Tower 模式状态 |
124
-
125
- worker 与 reviewer 是使用内置 `tower-worker` preset 的普通 DSH 子代理;它们的工具被限制在 mission 允许面(文件系统、shell、Tower 协议工具),不包含 merge/spawn。
126
-
127
- ## 项目结构
128
-
129
- | 路径 | 内容 |
130
- |---|---|
131
- | `package.json`、`cordis.patch.yml` | bundle 清单与 loader patch(插入 Host 插件和 `tower-worker` preset) |
132
- | `index.js`、`lib/`、`worker.js` | Host 入口、协议实现、worker preset 策略 |
133
- | `locale/`、`icon.svg` | 本地化与图标 |
134
- | `tests/` | 纯函数、协议 e2e、插件级测试;可选的真实 Web 运行时脚本 |
135
- | `ui-bundle/` | 可选 Client 小挂件(静态 `/tower` 提示),默认不安装 |
136
- | `.github/workflows/test.yml` | CI:Node 22/24 上跑离线套件 |
137
-
138
- ## 开发与测试
139
-
140
- ```bash
141
- npm test # node --test tests/*.test.mjs
142
- ```
143
-
144
- 离线套件不需要 DSH 进程,只在系统临时目录创建一次性 Git 仓库。隔离 Web 运行时检查、模型驱动验收和会话诊断脚本见 `tests/README.md`。
145
-
146
- ## 常见问题
147
-
148
- - **安装/更新后插件不出现。** Host 插件在 DSH 启动时加载,请重启 DSH;并确认安装目标指向本仓库根目录(含 `package.json` 与 `cordis.patch.yml` 的那一层),而不是子目录。
149
- - **没有 `tower_control`。** 配置 `allowAgentControl` 为 `false`;改为 `true` 并重启。
150
- - **合并被阻止。** 运行 `tower_preflight` 查看 blocker code;`stale-base` 表示先执行 `tower_rebase`。
151
- - **基线前进后分支过期。** `tower_rebase` 在 worktree 内完成 rebase;干净且无冲突的 rebase 可免去上一轮审查的重审。
152
-
153
- ## 贡献
154
-
155
- 欢迎 issue 与 pull request。提交 PR 前请先运行 `npm test`,细节见 [CONTRIBUTING.md](CONTRIBUTING.md)。安全相关问题请不要在公开 issue 里贴利用细节,先开一个最小化 issue 申请私下渠道。
156
-
157
- ## 致谢
158
-
159
- - **Kimi Code(Moonshot AI)**:Tower 模式的整体概念——由人类开启的模式、每个 mission 独立 Git worktree、审查门禁合并协议——灵感来自 Kimi Code 的实验性 Tower 模式([官网](https://www.kimi.com/code)、[源码](https://github.com/MoonshotAI/kimi-code))。本项目在 DeepSeek Harness 的服务之上重新实现这些想法,不包含 Kimi Code 源码,也与 Moonshot AI 无隶属关系。
160
- - **DeepSeek Harness**:本插件构建所依赖的插件、工具、会话与子代理 API。
161
-
162
- ## License
163
-
164
- MIT © Loui Finn
165
-
166
- [![](https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white)](https://github.com/deepseek-ai/deepseek-harness)