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 +24 -0
- package/README.en.md +168 -0
- package/README.md +78 -76
- package/lib/git.js +7 -1
- package/lib/tools.js +8 -3
- package/lib/tower.js +243 -16
- package/package.json +2 -2
- package/README.zh.md +0 -166
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
|
+
[](https://github.com/louifinn/dsh-tower/actions/workflows/test.yml)
|
|
4
|
+
[](https://www.npmjs.com/package/dsh-tower)
|
|
5
|
+
[](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://github.com/deepseek-ai/deepseek-harness)
|
package/README.md
CHANGED
|
@@ -3,52 +3,52 @@
|
|
|
3
3
|
[](https://github.com/louifinn/dsh-tower/actions/workflows/test.yml)
|
|
4
4
|
[](https://www.npmjs.com/package/dsh-tower)
|
|
5
5
|
[](LICENSE)
|
|
6
|
-
[
|
|
6
|
+
[English](README.en.md)
|
|
7
7
|
|
|
8
|
-
**Tower**
|
|
8
|
+
**Tower** 是 [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness)(DSH)的多代理编排插件。它把一个 agent 会话变成一个小型工程团队:会话本身是*控制塔*,worker agent 在隔离的 Git worktree 里实现任务,任何改动都必须经过独立审查和硬门禁才能合入基线分支。
|
|
9
9
|
|
|
10
|
-
Tower
|
|
10
|
+
Tower 模式与 mission/审查工作流的灵感来自 Moonshot AI 的 **Kimi Code** 中实验性的 Tower 模式([Kimi Code](https://www.kimi.com/code))。本项目是面向 DSH 的独立实现,与 Moonshot AI 无隶属关系;详见[致谢](#致谢)。
|
|
11
11
|
|
|
12
|
-
|
|
12
|
+
仓库根目录**就是** DSH bundle 包根:`package.json` 声明 `dsh.bundle.patch`,`cordis.patch.yml` 位于根目录,符合 DSH 插件规范。
|
|
13
13
|
|
|
14
|
-
##
|
|
14
|
+
## 特性
|
|
15
15
|
|
|
16
|
-
-
|
|
17
|
-
-
|
|
18
|
-
-
|
|
19
|
-
-
|
|
20
|
-
-
|
|
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
|
-
##
|
|
22
|
+
## 环境要求
|
|
23
23
|
|
|
24
|
-
- DeepSeek Harness `0.2.1-rc.2
|
|
25
|
-
- Node.js 22
|
|
26
|
-
- Git
|
|
24
|
+
- DeepSeek Harness `0.2.1-rc.2`(或兼容的新版本)
|
|
25
|
+
- Node.js 22+
|
|
26
|
+
- Git(worktree 在本地创建,不需要联网)
|
|
27
27
|
|
|
28
|
-
##
|
|
28
|
+
## 安装
|
|
29
29
|
|
|
30
|
-
|
|
30
|
+
把已发布的包安装到 DSH profile:
|
|
31
31
|
|
|
32
32
|
```bash
|
|
33
33
|
dsh plugin --profile web add dsh-tower
|
|
34
34
|
```
|
|
35
35
|
|
|
36
|
-
|
|
36
|
+
在 DSH 中也可以用插件管理器指定同一目标:
|
|
37
37
|
|
|
38
38
|
```text
|
|
39
39
|
plugin_manager({ action: "install_bundle", target: "dsh-tower" })
|
|
40
40
|
```
|
|
41
41
|
|
|
42
|
-
|
|
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
|
|
46
|
+
dsh plugin --profile <profile> add <本仓库的绝对路径>
|
|
47
47
|
```
|
|
48
48
|
|
|
49
|
-
|
|
49
|
+
Host 插件在 DSH 进程启动时加载,**安装或更新后需重启 DSH**。卸载使用 `remove_bundle`,包名 `dsh-tower`。
|
|
50
50
|
|
|
51
|
-
##
|
|
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
|
|
60
|
-
tower_preflight <repoRoot> missionId=M1 #
|
|
59
|
+
# worker 实现并汇报完成
|
|
60
|
+
tower_preflight <repoRoot> missionId=M1 # 必须全绿才能合并
|
|
61
61
|
tower_spawn <repoRoot> missionId=M1 role=reviewer
|
|
62
|
-
tower_review ... #
|
|
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
|
-
|
|
70
|
+
小改动通常一轮 worker + 一轮 reviewer 即可完成。
|
|
71
71
|
|
|
72
|
-
##
|
|
72
|
+
## 命令
|
|
73
73
|
|
|
74
|
-
|
|
|
74
|
+
| 输入 | 行为 |
|
|
75
75
|
|---|---|
|
|
76
|
-
| `/tower` |
|
|
77
|
-
| `/tower on` |
|
|
78
|
-
| `/tower on
|
|
79
|
-
| `/tower off` |
|
|
80
|
-
| `/tower off
|
|
81
|
-
| `/tower
|
|
76
|
+
| `/tower` | 开启 Tower 模式(已开启则保持) |
|
|
77
|
+
| `/tower on` | 开启或保持 |
|
|
78
|
+
| `/tower on <指令>` | 开启/保持,并把 `<指令>` 作为下一条用户消息转发 |
|
|
79
|
+
| `/tower off` | 退出 Tower 模式 |
|
|
80
|
+
| `/tower off <指令>` | 退出并转发 `<指令>` |
|
|
81
|
+
| `/tower <指令>` | 确保开启,然后转发 `<指令>` |
|
|
82
82
|
|
|
83
|
-
|
|
83
|
+
图片与文件附件会随指令一起作为一条完整的 `UserMessage` 转发。
|
|
84
84
|
|
|
85
|
-
##
|
|
85
|
+
## 配置
|
|
86
86
|
|
|
87
87
|
```yaml
|
|
88
88
|
- id: tower
|
|
89
89
|
name: 'dsh-tower'
|
|
90
90
|
config:
|
|
91
|
-
section: '
|
|
92
|
-
allowAgentControl: true # true
|
|
91
|
+
section: '(可选)覆盖 Tower 模式系统提示文案'
|
|
92
|
+
allowAgentControl: true # true 时注册 tower_control;false 时只有人类能切换
|
|
93
93
|
```
|
|
94
94
|
|
|
95
|
-
- `allowAgentControl`
|
|
96
|
-
-
|
|
95
|
+
- 代码中 `allowAgentControl` 默认 `false`;仓库自带的 `cordis.patch.yml` 把它设为 `true`:当用户意图或任务状态明确时,模型可以自行进出 Tower 模式,但必须给出理由。
|
|
96
|
+
- 设为 `false` 时只有人类能切换模式。
|
|
97
97
|
|
|
98
|
-
##
|
|
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`
|
|
102
|
+
`tower_control` 仅在 `allowAgentControl: true` 时存在。
|
|
103
103
|
|
|
104
|
-
##
|
|
104
|
+
## 安全与约束
|
|
105
105
|
|
|
106
|
-
-
|
|
107
|
-
-
|
|
108
|
-
- **
|
|
109
|
-
- **
|
|
110
|
-
- **
|
|
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
|
-
|
|
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
|
-
##
|
|
114
|
+
## 工作原理
|
|
115
115
|
|
|
116
|
-
|
|
116
|
+
mission 生命周期:`planned → active → completed → merged`(或 `blocked` / `paused` / `abandoned`)。状态位置:
|
|
117
117
|
|
|
118
|
-
|
|
|
118
|
+
| 路径 | 内容 |
|
|
119
119
|
|---|---|
|
|
120
|
-
| `.git/dsh-tower/state.json` | missions
|
|
121
|
-
| `.git/dsh-tower/activity.jsonl` |
|
|
122
|
-
|
|
|
123
|
-
| `$DSH_HOME/tower-mode/modes.json` |
|
|
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
|
-
|
|
125
|
+
worker 与 reviewer 是使用内置 `tower-worker` preset 的普通 DSH 子代理;它们的工具被限制在 mission 允许面(文件系统、shell、Tower 协议工具),不包含 merge/spawn。
|
|
126
126
|
|
|
127
|
-
##
|
|
127
|
+
## 项目结构
|
|
128
128
|
|
|
129
|
-
|
|
|
129
|
+
| 路径 | 内容 |
|
|
130
130
|
|---|---|
|
|
131
|
-
| `package.json
|
|
132
|
-
| `index.js
|
|
133
|
-
| `locale
|
|
134
|
-
| `tests/` |
|
|
135
|
-
| `ui-bundle/` |
|
|
136
|
-
| `.github/workflows/test.yml` | CI
|
|
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
|
-
##
|
|
138
|
+
## 开发与测试
|
|
139
139
|
|
|
140
140
|
```bash
|
|
141
141
|
npm test # node --test tests/*.test.mjs
|
|
142
142
|
```
|
|
143
143
|
|
|
144
|
-
|
|
144
|
+
离线套件不需要 DSH 进程,只在系统临时目录创建一次性 Git 仓库。隔离 Web 运行时检查、模型驱动验收和会话诊断脚本见 `tests/README.md`。
|
|
145
145
|
|
|
146
|
-
##
|
|
146
|
+
## 常见问题
|
|
147
147
|
|
|
148
|
-
-
|
|
149
|
-
-
|
|
150
|
-
-
|
|
151
|
-
-
|
|
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
|
-
##
|
|
155
|
+
## 贡献
|
|
154
156
|
|
|
155
|
-
|
|
157
|
+
欢迎 issue 与 pull request。提交 PR 前请先运行 `npm test`,细节见 [CONTRIBUTING.md](CONTRIBUTING.md)。安全相关问题请不要在公开 issue 里贴利用细节,先开一个最小化 issue 申请私下渠道。
|
|
156
158
|
|
|
157
|
-
##
|
|
159
|
+
## 致谢
|
|
158
160
|
|
|
159
|
-
- **Kimi Code
|
|
160
|
-
- **DeepSeek Harness
|
|
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
|
|
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,
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
1305
|
-
|
|
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 {
|
|
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
|
-
|
|
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 {
|
|
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.
|
|
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.
|
|
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
|
-
[](https://github.com/louifinn/dsh-tower/actions/workflows/test.yml)
|
|
4
|
-
[](https://www.npmjs.com/package/dsh-tower)
|
|
5
|
-
[](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://github.com/deepseek-ai/deepseek-harness)
|