dsh-tower 2.0.3 → 2.1.0-beta.1
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 +141 -0
- package/COMPATIBILITY.md +38 -0
- package/CONTRIBUTING.md +150 -2
- package/README.en.md +59 -16
- package/README.md +57 -16
- package/SECURITY.md +31 -1
- package/adapters/default.js +10 -0
- package/adapters/store-fs.js +6 -0
- package/adapters/vcs-git.js +4 -0
- package/app/batch.js +79 -0
- package/app/board.js +25 -0
- package/app/checkCompletion.js +76 -0
- package/app/enrichedMissions.js +26 -0
- package/app/findings.js +78 -0
- package/app/gate.js +108 -0
- package/app/init.js +249 -0
- package/app/merge.js +567 -0
- package/app/messages.js +66 -0
- package/app/mission.js +35 -0
- package/app/missionContext.js +14 -0
- package/app/plan.js +42 -0
- package/app/provision.js +193 -0
- package/app/read.js +57 -0
- package/app/rebase.js +302 -0
- package/app/recording.js +72 -0
- package/app/review.js +145 -0
- package/app/roster.js +123 -0
- package/app/spawn.js +125 -0
- package/app/teardown.js +184 -0
- package/app/tower.js +524 -0
- package/cordis.patch.yml +61 -53
- package/docs/README.md +33 -1
- package/docs/architecture/overview.md +114 -14
- package/docs/board.md +72 -0
- package/docs/examples/first-mission.md +2 -1
- package/docs/faq.md +11 -7
- package/docs/guide/hot-reload.md +1 -1
- package/docs/guide/install-upgrade-migration.md +22 -6
- package/docs/guide/quickstart.md +12 -7
- package/docs/guide/release.md +125 -0
- package/docs/guide/scenarios-and-philosophy.md +86 -69
- package/docs/operations/security.md +65 -14
- package/docs/reference/blockers.md +11 -10
- package/docs/reference/config.generated.md +66 -0
- package/docs/reference/config.md +11 -7
- package/docs/reference/errors.generated.md +39 -0
- package/docs/reference/errors.md +10 -2
- package/docs/reference/events.md +7 -4
- package/docs/reference/testing.md +2 -1
- package/docs/reference/tools.generated.md +33 -0
- package/docs/reference/tools.md +8 -7
- package/docs/zh/README.md +4 -3
- package/docs/zh/faq.md +9 -6
- package/docs/zh/guide/hot-reload.md +1 -1
- package/docs/zh/guide/install-upgrade-migration.md +17 -5
- package/docs/zh/guide/quickstart.md +13 -8
- package/docs/zh/guide/scenarios-and-philosophy.md +55 -50
- package/docs/zh/operations/security.md +43 -7
- package/docs/zh/reference/blockers.md +10 -10
- package/docs/zh/reference/config.md +11 -7
- package/docs/zh/reference/errors.md +4 -1
- package/docs/zh/reference/events.md +6 -3
- package/docs/zh/reference/testing.md +3 -2
- package/docs/zh/reference/tools.md +8 -7
- package/domain/collab/batch.js +112 -0
- package/domain/collab/messages.js +127 -0
- package/domain/collab/roster.js +158 -0
- package/domain/collab/spawn.js +188 -0
- package/domain/config/schema.js +404 -0
- package/domain/contracts/outputs.js +279 -0
- package/domain/contracts/tool-inputs/collaboration.js +58 -0
- package/domain/contracts/tool-inputs/findings.js +29 -0
- package/domain/contracts/tool-inputs/index.js +94 -0
- package/domain/contracts/tool-inputs/integration.js +37 -0
- package/domain/contracts/tool-inputs/missions.js +94 -0
- package/domain/contracts/tool-inputs/ops.js +75 -0
- package/domain/contracts/tool-inputs/shared.js +25 -0
- package/domain/errors.js +96 -0
- package/domain/gate.js +125 -0
- package/domain/integration/gate.js +176 -0
- package/domain/integration/init.js +225 -0
- package/domain/integration/liveness.js +31 -0
- package/domain/integration/merge.js +377 -0
- package/domain/integration/provision.js +124 -0
- package/domain/integration/rebase.js +236 -0
- package/domain/integration/review.js +52 -0
- package/domain/integration/teardown.js +118 -0
- package/domain/mission.js +68 -0
- package/domain/missions/completion.js +114 -0
- package/domain/missions/findings.js +201 -0
- package/domain/missions/lifecycle.js +185 -0
- package/domain/missions/plan.js +149 -0
- package/domain/missions/review.js +179 -0
- package/domain/planning.js +118 -0
- package/domain/read/enrichedMissions.js +58 -0
- package/domain/read/missionContext.js +53 -0
- package/domain/read/views.js +128 -0
- package/domain/report.js +59 -0
- package/domain/roster.js +40 -0
- package/domain/scope.js +105 -0
- package/domain/state/schema.js +233 -0
- package/domain/state/semantics.js +117 -0
- package/domain/state.js +33 -0
- package/domain/summary.js +114 -0
- package/domain/vocabulary.js +179 -0
- package/index.js +86 -34
- package/lib/briefing.js +176 -0
- package/lib/compatibility.js +108 -0
- package/lib/config.js +54 -302
- package/lib/doctor.js +108 -9
- package/lib/git.js +93 -15
- package/lib/hmr.js +4 -3
- package/lib/leases.js +3 -2
- package/lib/legacy.js +20 -9
- package/lib/modeState.js +39 -0
- package/lib/modeStore.js +35 -33
- package/lib/persistence.js +349 -0
- package/lib/presets.js +35 -0
- package/lib/spawn-prechecks.js +168 -0
- package/lib/spawn-support.js +79 -0
- package/lib/store.js +57 -85
- package/lib/tests.js +46 -11
- package/lib/tool-registry.js +544 -0
- package/lib/workspaceLock.js +46 -22
- package/lib/worktreeOwnership.js +5 -4
- package/locale/en.json +23 -1
- package/locale/zh.json +22 -0
- package/package.json +33 -4
- package/ports/clock.js +6 -0
- package/ports/ids.js +7 -0
- package/ports/index.js +2 -0
- package/ports/store.js +11 -0
- package/ports/vcs.js +11 -0
- package/scripts/architecture.mjs +94 -0
- package/scripts/check-docs.mjs +44 -3
- package/scripts/check-pack.mjs +49 -0
- package/scripts/domain-metrics.mjs +48 -0
- package/scripts/generate-config-reference.mjs +46 -0
- package/scripts/generate-error-reference.mjs +29 -0
- package/scripts/generate-tools-reference.mjs +56 -0
- package/scripts/issue-audit.mjs +259 -0
- package/scripts/lib/cli.mjs +76 -0
- package/scripts/lib/tmp.mjs +20 -0
- package/scripts/mount-smoke/fixtures.mjs +162 -0
- package/scripts/mount-smoke/host.json +4 -0
- package/scripts/mount-smoke/probe.mjs +125 -0
- package/scripts/mount-smoke/surface.mjs +141 -0
- package/scripts/mount-smoke.mjs +270 -0
- package/scripts/pack-manifest.txt +203 -0
- package/scripts/release-check.mjs +303 -0
- package/skills/tower-recovery.md +7 -5
- package/tools/adopt.js +72 -0
- package/tools/batch.js +78 -0
- package/tools/complete.js +53 -0
- package/tools/diff.js +39 -0
- package/tools/doctor.js +50 -0
- package/tools/finding.js +40 -0
- package/tools/findings.js +39 -0
- package/tools/import-legacy.js +42 -0
- package/tools/inbox.js +39 -0
- package/tools/index.js +39 -0
- package/tools/init.js +63 -0
- package/tools/interrupt.js +42 -0
- package/tools/legacy-map.js +44 -0
- package/tools/merge.js +39 -0
- package/tools/mission.js +41 -0
- package/tools/plan.js +39 -0
- package/tools/preflight.js +44 -0
- package/tools/rebase.js +39 -0
- package/tools/recover.js +45 -0
- package/tools/reload.js +52 -0
- package/tools/resource.js +54 -0
- package/tools/review.js +45 -0
- package/tools/roster.js +60 -0
- package/tools/send.js +147 -0
- package/tools/spawn.js +196 -0
- package/tools/status.js +94 -0
- package/tools/teardown.js +42 -0
- package/tools/test.js +61 -0
- package/types/cordis.d.ts +6 -0
- package/types/index.d.ts +339 -0
- package/ui-bundle/README.md +111 -0
- package/ui-bundle/client.js +1980 -0
- package/ui-bundle/cordis.patch.yml +3 -0
- package/ui-bundle/icon.svg +6 -0
- package/ui-bundle/index.js +2 -0
- package/ui-bundle/locale/en.json +6 -0
- package/ui-bundle/locale/zh.json +6 -0
- package/ui-bundle/package.json +35 -0
- package/lib/errors.js +0 -111
- package/lib/tools.js +0 -2250
- package/lib/tower.js +0 -2689
- /package/{lib → domain}/batch.js +0 -0
package/README.md
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
[](https://github.com/louifinn/dsh-tower/actions/workflows/test.yml)
|
|
4
4
|
[](https://www.npmjs.com/package/dsh-tower)
|
|
5
|
+
[](https://dsh-plugin.org/plugins/louifinn/dsh-tower)
|
|
5
6
|
[](LICENSE)
|
|
6
7
|
[English](README.en.md)
|
|
7
8
|
|
|
@@ -13,9 +14,9 @@ Tower 模式与 mission/审查工作流的灵感来自 Moonshot AI 的 **Kimi Co
|
|
|
13
14
|
|
|
14
15
|
## 特性
|
|
15
16
|
|
|
16
|
-
-
|
|
17
|
+
- **可配置的 Tower 模式。** `/tower` 切换会话级模式:收窄工具面并注入 Tower 协议;`/tower on|off <指令>` 还能把指令作为下一步用户消息转发给模型。默认也允许 Agent 通过 `tower_control` 带理由进出;设 `mode.allowAgentControl:false` 可改为仅人类控制。
|
|
17
18
|
- **任务级隔离。** 每个 mission 使用独立分支和 Git worktree,worker 不会碰到你的主检出。
|
|
18
|
-
- **审查门禁合并。**
|
|
19
|
+
- **审查门禁合并。** 开启 `review.requireReview:true` 时,审查者针对当前分支 tip 给出 `clean` / `merge` 结论;合并门禁重新校验 scope、分支新鲜度、任务完成度、审查记录与 worktree 归属,通过后才执行 `--no-ff` 合并。独立审查默认可选(门禁关闭);全局开启时,单个 mission 用 `reviewPolicy:"none"` + 理由并开启 `gates.allowReviewExemption:true` 做更窄豁免。
|
|
19
20
|
- **可恢复的协作状态。** roster、inbox、findings、reviews 与 mission 状态持久化在 `.git/dsh-tower/` 下,重启不会丢计划。
|
|
20
21
|
- **默认安全。** Tower 模式开启期间拒绝 `create_goal`、`update_goal(resume)`、`todo_write`、Team 任务板与 `spawn_teammate`;被暂停的 goal 不会被自动恢复。
|
|
21
22
|
- **可追溯的 finding 生命周期。** `tower_findings` 列表、读取并按 `open → triaged → fixed` / `wontfix` 流转,也可重新打开;只有 tower 或 worker 能改状态,reviewer 只能读。
|
|
@@ -33,7 +34,7 @@ Tower 是**面向 Git 仓库变更的交付协议**,不是所有多代理场
|
|
|
33
34
|
|
|
34
35
|
- 一个特性/重构能拆成 2–8 个 scope 清晰、可独立审查的 mission;
|
|
35
36
|
- 有依赖顺序、需要分支新鲜度与 scope 约束的迁移;
|
|
36
|
-
- 敏感仓库、发布绑定变更,或需要"
|
|
37
|
+
- 敏感仓库、发布绑定变更,或需要"可审计合并 + 测试证据"(需要时开启独立审查)的工作;
|
|
37
38
|
- 跨上下文压缩、重启、多天协作,需要从本地状态恢复的工作;
|
|
38
39
|
- 需要把"完成"定义成任务 + 测试(可选独立审查),而不是聊天里的一句"好了"。
|
|
39
40
|
|
|
@@ -51,7 +52,7 @@ Tower 模式会拒绝 Team/Workflow/Ralph/普通 subagent 工具并清除 plan m
|
|
|
51
52
|
|
|
52
53
|
## 环境要求
|
|
53
54
|
|
|
54
|
-
- DeepSeek Harness `0.2.1-rc.2
|
|
55
|
+
- DeepSeek Harness `0.2.1-rc.2`(已验证的 host 构建见 [COMPATIBILITY.md](COMPATIBILITY.md))
|
|
55
56
|
- Node.js 22+
|
|
56
57
|
- Git(worktree 在本地创建,不需要联网)
|
|
57
58
|
|
|
@@ -78,6 +79,16 @@ dsh plugin --profile <profile> add <本仓库的绝对路径>
|
|
|
78
79
|
|
|
79
80
|
Host 插件在 DSH 进程启动时加载,**安装或更新后需重启 DSH**。卸载使用 `remove_bundle`,包名 `dsh-tower`。
|
|
80
81
|
|
|
82
|
+
可选的 Web UI 随 npm 包一起发布在 `ui-bundle/`:npm 安装后目标为
|
|
83
|
+
`<profile>/node_modules/dsh-tower/ui-bundle`,源码安装时为目标仓库的 `ui-bundle/` 目录。用
|
|
84
|
+
`plugin_manager({action:"install_bundle", target:"<该目录>"})` 安装并重启 DSH,然后刷新页面。
|
|
85
|
+
|
|
86
|
+
### 已知冲突
|
|
87
|
+
|
|
88
|
+
不要同时安装 `dsh-plugins-tower`(第三方包)。它的 loader row 也使用 `name: dsh-tower`,会解析到
|
|
89
|
+
本包,导致本包被加载两次或对方代码不生效。`tower_doctor` 的 `loader-conflicts` 检查会报告该情况;
|
|
90
|
+
卸载冲突包并重启 DSH。
|
|
91
|
+
|
|
81
92
|
## 快速开始
|
|
82
93
|
|
|
83
94
|
```text
|
|
@@ -87,16 +98,17 @@ tower_init repoRoot=D:\work\repo # 首次初始化,记录 base
|
|
|
87
98
|
tower_plan repoRoot=D:\work\repo missions=[{"title":"Feature A","scope":["src/a/**"],"tasks":["implement","commit"],"kind":"build"}]
|
|
88
99
|
tower_spawn repoRoot=D:\work\repo missionId=M1 role=worker
|
|
89
100
|
# worker 在 worktree 内实现并提交,调用 tower_complete;最终报告会返回本会话
|
|
101
|
+
# 可选:需要强制独立审查时,先开启 review.requireReview:true
|
|
90
102
|
tower_spawn repoRoot=D:\work\repo missionId=M1 role=reviewer
|
|
91
103
|
# reviewer 只读审查并调用 tower_review(clean+merge 或 p1-Nitems/p2-Nitems)
|
|
92
|
-
tower_preflight repoRoot=D:\work\repo missionId=M1 #
|
|
104
|
+
tower_preflight repoRoot=D:\work\repo missionId=M1 # 默认审查可选;开启 review.requireReview 时审查前预期 no-review,审查后 ok:true
|
|
93
105
|
tower_merge repoRoot=D:\work\repo missionId=M1
|
|
94
106
|
tower_teardown repoRoot=D:\work\repo
|
|
95
107
|
|
|
96
108
|
/tower off
|
|
97
109
|
```
|
|
98
110
|
|
|
99
|
-
小改动通常一轮 worker
|
|
111
|
+
小改动通常一轮 worker 即可完成;需要独立审查时再加一轮 reviewer。完整可复制示例见 [docs/examples/first-mission.md](docs/examples/first-mission.md),逐工具参数见 [docs/reference/tools.md](docs/reference/tools.md)。
|
|
100
112
|
|
|
101
113
|
## 命令
|
|
102
114
|
|
|
@@ -107,10 +119,22 @@ tower_teardown repoRoot=D:\work\repo
|
|
|
107
119
|
| `/tower on <指令>` | 开启/保持,并把 `<指令>` 作为下一条用户消息转发 |
|
|
108
120
|
| `/tower off` | 退出 Tower 模式 |
|
|
109
121
|
| `/tower off <指令>` | 退出并转发 `<指令>` |
|
|
122
|
+
| `/tower ack` | 记录人类确认,供 `mode.requireHumanAckOnFirstEntry` 使用 |
|
|
110
123
|
| `/tower <指令>` | 确保开启,然后转发 `<指令>` |
|
|
111
124
|
|
|
112
125
|
图片与文件附件会随指令一起作为一条完整的 `UserMessage` 转发。
|
|
113
126
|
|
|
127
|
+
## 组件模型
|
|
128
|
+
|
|
129
|
+
本 bundle 采用 **all-or-nothing**:`cordis.patch.yml` 只插入一个 `tower-bundle`
|
|
130
|
+
`cordis:group` 行,Host 插件、skills、worker/reviewer preset 都是它的内部 section。插件页只应把
|
|
131
|
+
这个 group 当作一个组件;关闭它时四个 section 一起关闭,不会留下指向已消失 `tower_*` 工具的
|
|
132
|
+
orphan preset/skill。不要再把子行单独启停。
|
|
133
|
+
|
|
134
|
+
进入 Tower 模式与 `tower_plan` 前会校验两个 preset 是否已注册;缺失时直接以 `preset-missing`
|
|
135
|
+
拒绝(并在消息中点名 `preset-tower-worker` / `preset-tower-reviewer`),不会先完成规划/派发再在
|
|
136
|
+
review/merge 阶段死锁。
|
|
137
|
+
|
|
114
138
|
## 配置
|
|
115
139
|
|
|
116
140
|
```yaml
|
|
@@ -124,6 +148,7 @@ tower_teardown repoRoot=D:\work\repo
|
|
|
124
148
|
autoPauseGoal: true
|
|
125
149
|
denyTools: [create_goal, todo_write, team_task_create, team_task_update, team_task_reopen, spawn_teammate]
|
|
126
150
|
review:
|
|
151
|
+
requireReview: false # 默认审查可选;需要强制独立审查时改为 true
|
|
127
152
|
maxRoundsPerReviewer: 5
|
|
128
153
|
merge:
|
|
129
154
|
timeoutMs: 900000
|
|
@@ -135,21 +160,21 @@ tower_teardown repoRoot=D:\work\repo
|
|
|
135
160
|
maxDepth: 1
|
|
136
161
|
```
|
|
137
162
|
|
|
138
|
-
- 代码中 `mode.allowAgentControl` 默认 `
|
|
163
|
+
- 代码中 `mode.allowAgentControl` 默认 `true`;随包 `cordis.patch.yml` 也显式设为 `true`:模型可在用户意图/任务状态明确时自行进出,但必须给出理由;设为 `false` 时只有人类能切换。随包 patch 还默认开启 `mode.workspaceLock`(单活塔)、`send.interruptDefault`、`init.autoGitInit`,并把 `spawn.staleMs` 设为 15s。
|
|
139
164
|
- `/tower base <branch>` 可设置会话级 init base 偏好;`/tower on` 会清除活跃 plan mode(`mode.planModePolicy:"refuse"` 则拒绝进入),保证 plan/tower 互斥。
|
|
140
165
|
- 仓库级配置:在 `<git-common-dir>/dsh-tower/config.json`(通常是 `.git/dsh-tower/config.json`)放入同样的 JSON 结构,可覆盖 `review`/`merge`/`worktrees`/`spawn`;优先级为代码默认 < profile < 仓库配置 < 工具参数。`mode.*` 属于会话级,只在 profile 生效。
|
|
141
166
|
- 所有键与默认值见 [docs/reference/config.md](docs/reference/config.md);未知顶层键会被拒绝(防止拼写错误静默失效),嵌套分组内的未知键会被忽略。工具错误码见 [docs/reference/errors.md](docs/reference/errors.md)。
|
|
142
167
|
|
|
143
|
-
## 工具(
|
|
168
|
+
## 工具(27 个核心)
|
|
144
169
|
|
|
145
|
-
`tower_init` · `tower_plan` · `tower_mission` · `tower_status` · `tower_spawn` · `tower_roster` · `tower_adopt` · `tower_diff` · `tower_send` · `tower_inbox` · `tower_complete` · `tower_interrupt` · `tower_finding` · `tower_findings` · `tower_review` · `tower_preflight` · `tower_merge` · `tower_rebase` · `tower_teardown` · `tower_mode` · `tower_doctor` · `tower_test` · `tower_batch` · `tower_resource` · `tower_import_legacy` · `tower_legacy_map`
|
|
170
|
+
`tower_init` · `tower_plan` · `tower_mission` · `tower_status` · `tower_spawn` · `tower_roster` · `tower_adopt` · `tower_diff` · `tower_send` · `tower_inbox` · `tower_complete` · `tower_interrupt` · `tower_finding` · `tower_findings` · `tower_review` · `tower_preflight` · `tower_merge` · `tower_rebase` · `tower_teardown` · `tower_mode` · `tower_doctor` · `tower_recover` · `tower_test` · `tower_batch` · `tower_resource` · `tower_import_legacy` · `tower_legacy_map`
|
|
146
171
|
|
|
147
|
-
`tower_control`
|
|
172
|
+
`tower_control` 默认注册(`allowAgentControl:true`);设为 `false` 可禁用;`tower_reload` 在 `dev.hotReload`(默认 true)时注册,无 HMR 服务调用会返回 `reload-unavailable`(因此最多 29 个工具)。mission 级工具同时接受 `id` 作为 `missionId` 的别名;`task_done`/`taskDone` 等 snake/camel 写法均可。
|
|
148
173
|
|
|
149
174
|
## 安全与约束
|
|
150
175
|
|
|
151
176
|
- **门禁不全绿不合并。** `tower_preflight` 是只读检查,返回稳定的 blocker code;存在 blocker 时 `tower_merge` 拒绝合并。
|
|
152
|
-
- **审查绑定提交。**
|
|
177
|
+
- **审查绑定提交。** 开启 `review.requireReview` 时,审查后分支 tip 变化会报 `tip-moved`,必须重新审查;reviewer 使用只读 preset,若在审查期间改动分支,提交审查会被拒绝。
|
|
153
178
|
- **scope 强制。** mission 只能修改其 scope glob 匹配的文件,越界会以 `out-of-scope` 阻止合并。
|
|
154
179
|
- **worktree 归属。** 脏工作树、运行中的 worker、已关闭 mission 的分支都会阻止合并或 teardown。
|
|
155
180
|
- **goal 隔离。** 进入 Tower 模式会暂停活跃 goal 并记录;退出不会自动恢复,需要你自行恢复。
|
|
@@ -173,12 +198,12 @@ worker 与 reviewer 是使用内置 `tower-worker` preset 的普通 DSH 子代
|
|
|
173
198
|
|
|
174
199
|
| 路径 | 内容 |
|
|
175
200
|
|---|---|
|
|
176
|
-
| `package.json`、`cordis.patch.yml` | bundle 清单与 loader patch
|
|
201
|
+
| `package.json`、`cordis.patch.yml` | bundle 清单与 loader patch(单个 `tower-bundle` group,内含 Host、skills 与两个 preset) |
|
|
177
202
|
| `index.js`、`lib/`、`worker.js` | Host 入口、协议实现、worker preset 策略 |
|
|
178
203
|
| `locale/`、`icon.svg` | 本地化与图标 |
|
|
179
204
|
| `tests/` | 纯函数、协议 e2e、插件级测试;可选的真实 Web 运行时脚本 |
|
|
180
205
|
| `ui-bundle/` | 可选 Client 小挂件(Tower 工具卡片、Tower·on 状态 chip、模式感知的 disabled 行、dock 摘要、临时 overlay 状态条),默认不安装 |
|
|
181
|
-
| `.github/workflows/test.yml` | CI:Node 22/24
|
|
206
|
+
| `.github/workflows/test.yml` | CI:Node 22/24 离线套件、release 与覆盖率检查、Node 22 挂载 smoke 与种子失败 fixtures |
|
|
182
207
|
|
|
183
208
|
## 开发与测试
|
|
184
209
|
|
|
@@ -190,10 +215,26 @@ npm test # node --test tests/*.test.mjs
|
|
|
190
215
|
|
|
191
216
|
离线套件不需要 DSH 进程,只在系统临时目录创建一次性 Git 仓库。隔离 Web 运行时检查、模型驱动验收和会话诊断脚本见 `tests/README.md`。
|
|
192
217
|
|
|
218
|
+
## CI 证明了什么
|
|
219
|
+
|
|
220
|
+
每次 push 与 PR 都运行同一组 job;每个 job 对应一条可检查的声明,而不是一句 README 承诺。
|
|
221
|
+
|
|
222
|
+
| Job | 声明 | 证据 |
|
|
223
|
+
|---|---|---|
|
|
224
|
+
| `test`(Node 22/24) | 离线套件、文档一致性、生成文档、发布文件清单、架构、类型与 domain 纯度全部成立 | `npm test`、`npm run docs:check`、三个 `docs:check:generated*`、`pack:check`、`architecture:check`、`types:check`、`domain:check` |
|
|
225
|
+
| `test` 的 `Coverage (Node 22)` 步骤 | 覆盖率在 CI 中真实测量(只报告,不设阈值、不加徽章) | `npm run test:coverage` |
|
|
226
|
+
| `test` 的 `Release check`(Node 22)步骤 | tag 是一种声明:版本已越过上一个版本 tag、推送的 tag 是 `v<version>`、CHANGELOG 带有发布内容 | `npm run release:check` |
|
|
227
|
+
| `mount`(Node 22) | 打出的 tarball 经 plugin CLI 装进一次性 profile 后,真实 loader 挂载并注册了 bundle 声称的表面 | `npm run smoke:mount` |
|
|
228
|
+
| `mount-fixtures`(Node 22,非必需检查) | mount smoke 仍会因正确的种子失败而变红(改名一个工具 id、删除 skills 段),三次运行共用一次缓存的 host 安装 | `npm run smoke:fixtures -- --host-dir <缓存的 host>` |
|
|
229
|
+
|
|
230
|
+
`mount` 断言一次性 profile 的**已加载表面**,而不是 `cordis.patch.yml` 的文本:29 个 `tower_*` 工具(27 个 `tools/*.js` 模块 + `tower_mode` / `tower_control`)、3 个内置 skill、`tower-worker` / `tower-reviewer` 两个 preset、`/tower` 命令,以及唯一一个顶层 `tower-bundle` group 行(#66)。它不调用模型、不打开浏览器。在维护者的 Windows 机器上实测:host 冷装(含安装)约 100 秒,host 已缓存约 85 秒;CI 中该步骤上限为 3 分钟(`timeout-minutes: 3`),`mount` job 上限 5 分钟。红叉会打印解析后的 profile 路径、host build、已加载计数与每一条未满足的声明。
|
|
231
|
+
|
|
232
|
+
host build 只固定在一个地方:`scripts/mount-smoke/host.json`,`COMPATIBILITY.md` 最新一行由单元测试强制与它一致,发布时不再各写一遍。`npm run smoke:fixtures` 用两种种子失败(改名一个工具 id、删除 skills 段)证明 smoke 会以正确原因变红;CI 中它作为非必需的 `mount-fixtures` job 运行,control 与两个种子通过 `--host-dir` 共用一次缓存的 host 安装(本机热跑约 2 分钟)。
|
|
233
|
+
|
|
193
234
|
## 文档
|
|
194
235
|
|
|
195
236
|
- [docs/README.md](docs/README.md) — 文档索引(新用户 / 运维 / 维护者三条阅读路径)
|
|
196
|
-
- [中文文档](docs/zh/README.md) · [快速开始](docs/guide/quickstart.md) · [安装、升级与迁移](docs/guide/install-upgrade-migration.md)
|
|
237
|
+
- [中文文档](docs/zh/README.md) · [快速开始](docs/guide/quickstart.md) · [何时使用 Tower](docs/guide/scenarios-and-philosophy.md) · [安装、升级与迁移](docs/guide/install-upgrade-migration.md)
|
|
197
238
|
- [工具参考](docs/reference/tools.md) · [配置参考](docs/reference/config.md) · [blocker 与失败码](docs/reference/blockers.md) · [错误码](docs/reference/errors.md) · [事件](docs/reference/events.md) · [测试准入](docs/reference/testing.md) · [批次/租约/旧版](docs/reference/operations.md) · [Doctor](docs/reference/doctor.md) · [热重载](docs/guide/hot-reload.md)
|
|
198
239
|
- [架构与状态机](docs/architecture/overview.md) · [运维 runbook](docs/operations/runbook.md) · [安全模型](docs/operations/security.md)
|
|
199
240
|
- [FAQ](docs/faq.md) · [示例](docs/examples/first-mission.md)
|
|
@@ -201,7 +242,7 @@ npm test # node --test tests/*.test.mjs
|
|
|
201
242
|
## 常见问题
|
|
202
243
|
|
|
203
244
|
- **安装/更新后插件不出现。** Host 插件在 DSH 启动时加载,请重启 DSH;并确认安装目标指向本仓库根目录(含 `package.json` 与 `cordis.patch.yml` 的那一层),而不是子目录。
|
|
204
|
-
- **没有 `tower_control`。**
|
|
245
|
+
- **没有 `tower_control`。** 配置中被设为 `allowAgentControl:false`;改回 `true` 并重启。
|
|
205
246
|
- **合并被阻止。** 运行 `tower_preflight` 查看 blocker code;`stale-base` 表示先执行 `tower_rebase`。
|
|
206
247
|
- **基线前进后分支过期。** `tower_rebase` 在 worktree 内完成 rebase;干净且无冲突的 rebase 可免去上一轮审查的重审。
|
|
207
248
|
- **合并提交已经存在,但 `tower_merge` 报了失败。** 重新执行 `tower_merge` 即可:它会检测已落地的合并提交(或分支已包含在 base 中)并记录为 reconciled,不会重复合并。
|
|
@@ -213,7 +254,7 @@ npm test # node --test tests/*.test.mjs
|
|
|
213
254
|
|
|
214
255
|
## 致谢
|
|
215
256
|
|
|
216
|
-
- **Kimi Code(Moonshot AI)**:Tower
|
|
257
|
+
- **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 无隶属关系。
|
|
217
258
|
- **DeepSeek Harness**:本插件构建所依赖的插件、工具、会话与子代理 API。
|
|
218
259
|
|
|
219
260
|
## License
|
package/SECURITY.md
CHANGED
|
@@ -11,10 +11,40 @@ Areas that matter for security reports:
|
|
|
11
11
|
- Command forwarding: injecting content into the model history that the user did not send.
|
|
12
12
|
- Writes outside the declared scope, the mission worktree, or the DSH home directory.
|
|
13
13
|
|
|
14
|
+
## Repository protection on `main`
|
|
15
|
+
|
|
16
|
+
The repository's own landing path is part of the security surface, not separate from it: the merge
|
|
17
|
+
gate, state lock, persistence and scope code decide whether a user's repository is modified, so a bad
|
|
18
|
+
change there is exploitable for every repository that installs the plugin. `main` is therefore
|
|
19
|
+
protected with:
|
|
20
|
+
|
|
21
|
+
- a pull request required for every change, with **admins enforced** — a direct push is rejected even
|
|
22
|
+
for the maintainer;
|
|
23
|
+
- required checks `test (22)`, `test (24)` and `mount` — the packed bundle must mount and register its
|
|
24
|
+
surface before a change can merge, and a red or missing check blocks the merge;
|
|
25
|
+
- force-pushes and branch deletion blocked, so a released tag's history cannot be rewritten;
|
|
26
|
+
- no mandatory approvals and no CODEOWNERS gate. With one maintainer, self-merge is the workflow;
|
|
27
|
+
what replaces the second approval is the four-line PR body for the two-key paths in
|
|
28
|
+
[CONTRIBUTING.md](CONTRIBUTING.md#landing-changes).
|
|
29
|
+
|
|
30
|
+
Rationale, the enforcement boundary (server-side protection is not a plugin feature) and the list of
|
|
31
|
+
what the setting does **not** buy are in
|
|
32
|
+
[docs/operations/security.md](docs/operations/security.md#repository-protection). Verify the setting:
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
gh api repos/louifinn/dsh-tower/branches/main/protection \
|
|
36
|
+
--jq '{checks: .required_status_checks.contexts, strict: .required_status_checks.strict,
|
|
37
|
+
admins: .enforce_admins.enabled, force_push: .allow_force_pushes.enabled,
|
|
38
|
+
deletions: .allow_deletions.enabled, reviews: .required_pull_request_reviews}'
|
|
39
|
+
```
|
|
40
|
+
|
|
14
41
|
## Reporting
|
|
15
42
|
|
|
16
43
|
Please do **not** publish exploit details in a public issue. Open a minimal issue titled `security: request private contact` describing only the impact category (for example “merge gate bypass”), and a maintainer will arrange a private channel. Include the affected version and, if possible, a minimal reproduction in the private follow-up.
|
|
17
44
|
|
|
18
45
|
## Supported versions
|
|
19
46
|
|
|
20
|
-
Security fixes target the latest released
|
|
47
|
+
Security fixes target the latest released `2.x` line (currently `2.0.4`). Older pre-release snapshots
|
|
48
|
+
are not maintained. Private reports are acknowledged in the minimal public issue and handled through
|
|
49
|
+
the maintainer's private channel; do not attach exploit details, tokens, or production logs to a
|
|
50
|
+
public issue.
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
// Default composition root for Tower dependencies (#50).
|
|
2
|
+
import * as store from '../lib/store.js'
|
|
3
|
+
import * as vcs from '../lib/git.js'
|
|
4
|
+
|
|
5
|
+
export const defaultDeps = {
|
|
6
|
+
store,
|
|
7
|
+
vcs,
|
|
8
|
+
clock: { now: () => Date.now() },
|
|
9
|
+
ids: { uuid: () => crypto.randomUUID() },
|
|
10
|
+
}
|
package/app/batch.js
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// Batch use cases.
|
|
2
|
+
import { TowerToolError } from '../domain/errors.js'
|
|
3
|
+
import { withLock, writeJsonAtomic, appendJsonl } from '../lib/store.js'
|
|
4
|
+
import { validateState } from '../domain/state.js'
|
|
5
|
+
import { validateCreate as validateBatchCreate, validateClose as validateBatchClose } from '../domain/batch.js'
|
|
6
|
+
import {
|
|
7
|
+
nextBatchId,
|
|
8
|
+
createBatchRecord,
|
|
9
|
+
closeBatchRecord,
|
|
10
|
+
abandonBatchCheck,
|
|
11
|
+
applyBatchAbandon,
|
|
12
|
+
batchStatusPayload,
|
|
13
|
+
} from '../domain/collab/batch.js'
|
|
14
|
+
|
|
15
|
+
function fail(message, code = 'batch-invalid') {
|
|
16
|
+
throw new TowerToolError(code, message)
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export async function createBatch(tower, input = {}, actor = 'tower') {
|
|
20
|
+
return withLock(tower.paths, async () => {
|
|
21
|
+
const state = await tower.load()
|
|
22
|
+
const normalized = validateBatchCreate(state, input)
|
|
23
|
+
if (!Array.isArray(state.batches)) state.batches = []
|
|
24
|
+
const batch = createBatchRecord(nextBatchId(state.batches), normalized, actor, Date.now())
|
|
25
|
+
state.batches.push(batch)
|
|
26
|
+
state.revision += 1
|
|
27
|
+
state.updatedAt = batch.createdAt
|
|
28
|
+
validateState(state, tower.paths.state)
|
|
29
|
+
await writeJsonAtomic(tower.paths.state, state)
|
|
30
|
+
await appendJsonl(tower.paths.activity, {
|
|
31
|
+
at: batch.createdAt,
|
|
32
|
+
type: 'batch',
|
|
33
|
+
text: `created ${batch.id}: ${batch.missionIds.join(', ')}`,
|
|
34
|
+
})
|
|
35
|
+
return batch
|
|
36
|
+
})
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export async function closeBatch(tower, batchId, options = {}, actor = 'tower') {
|
|
40
|
+
return withLock(tower.paths, async () => {
|
|
41
|
+
const state = await tower.load()
|
|
42
|
+
const batch = (state.batches ?? []).find((entry) => entry.id === batchId)
|
|
43
|
+
if (!batch) fail(`unknown batch "${batchId}"`)
|
|
44
|
+
const result = validateBatchClose(state, batch, { ...options, state })
|
|
45
|
+
const blockers = Array.isArray(result.blockers) ? result.blockers : []
|
|
46
|
+
if (blockers.length > 0) return { ok: false, batch, blockers, checks: result.checks ?? [] }
|
|
47
|
+
closeBatchRecord(batch, options, actor, Date.now())
|
|
48
|
+
state.revision += 1
|
|
49
|
+
state.updatedAt = batch.closedAt
|
|
50
|
+
validateState(state, tower.paths.state)
|
|
51
|
+
await writeJsonAtomic(tower.paths.state, state)
|
|
52
|
+
await appendJsonl(tower.paths.activity, { at: batch.closedAt, type: 'batch', text: `closed ${batch.id}` })
|
|
53
|
+
return { ok: true, batch, blockers: [], checks: result.checks ?? [] }
|
|
54
|
+
})
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** @param {string} batchId @param {string} [actor] @param {string | null} [reason] */
|
|
58
|
+
export async function abandonBatch(tower, batchId, actor = 'tower', reason = null) {
|
|
59
|
+
return withLock(tower.paths, async () => {
|
|
60
|
+
const state = await tower.load()
|
|
61
|
+
const batch = (state.batches ?? []).find((entry) => entry.id === batchId)
|
|
62
|
+
if (!batch) fail(`unknown batch "${batchId}"`)
|
|
63
|
+
const { alreadyAbandoned } = abandonBatchCheck(batch, batchId)
|
|
64
|
+
if (alreadyAbandoned) return { ok: true, batch, alreadyAbandoned: true }
|
|
65
|
+
const now = Date.now()
|
|
66
|
+
applyBatchAbandon({ batch, actor, reason, now })
|
|
67
|
+
state.revision += 1
|
|
68
|
+
state.updatedAt = batch.abandonedAt
|
|
69
|
+
validateState(state, tower.paths.state)
|
|
70
|
+
await writeJsonAtomic(tower.paths.state, state)
|
|
71
|
+
await appendJsonl(tower.paths.activity, { at: batch.abandonedAt, type: 'batch', text: `abandoned ${batch.id}` })
|
|
72
|
+
return { ok: true, batch, alreadyAbandoned: false }
|
|
73
|
+
})
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export async function batchStatus(tower, batchId) {
|
|
77
|
+
const state = await tower.load()
|
|
78
|
+
return batchStatusPayload(state, batchId)
|
|
79
|
+
}
|
package/app/board.js
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// Mission board (MISSIONS.md) writing use cases.
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
import { fsp } from '../adapters/store-fs.js'
|
|
4
|
+
import { renameWithRetry } from '../lib/store.js'
|
|
5
|
+
|
|
6
|
+
export function boardFile(tower) {
|
|
7
|
+
return tower.config.docs?.boardLocation === 'repo'
|
|
8
|
+
? path.join(tower.root, 'MISSIONS.md')
|
|
9
|
+
: path.join(tower.paths.dir, 'MISSIONS.md')
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export async function writeBoard(tower) {
|
|
13
|
+
if (tower.config.docs?.board === false) return null
|
|
14
|
+
try {
|
|
15
|
+
const state = await tower.load()
|
|
16
|
+
const file = boardFile(tower)
|
|
17
|
+
await tower.ensureExcluded(file)
|
|
18
|
+
const tmp = `${file}.tmp-${process.pid}-${Date.now()}`
|
|
19
|
+
await fsp.writeFile(tmp, tower.boardMarkdown(state), 'utf8')
|
|
20
|
+
await renameWithRetry(tmp, file)
|
|
21
|
+
return file
|
|
22
|
+
} catch {
|
|
23
|
+
return null
|
|
24
|
+
}
|
|
25
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// Worker completion preparation and commit use cases.
|
|
2
|
+
import { TowerToolError } from '../domain/errors.js'
|
|
3
|
+
import {
|
|
4
|
+
normalizeChecks,
|
|
5
|
+
assertCompletionRequest,
|
|
6
|
+
assertCompletionInbox,
|
|
7
|
+
completionReport,
|
|
8
|
+
} from '../domain/missions/completion.js'
|
|
9
|
+
import { fullMission } from '../domain/summary.js'
|
|
10
|
+
import { validateState } from '../domain/state.js'
|
|
11
|
+
import { countCommits, isClean } from '../lib/git.js'
|
|
12
|
+
import { withLock, writeJsonAtomic, appendJsonl } from '../lib/store.js'
|
|
13
|
+
|
|
14
|
+
export { normalizeChecks }
|
|
15
|
+
|
|
16
|
+
export async function checkCompletion(tower, input) {
|
|
17
|
+
const state = await tower.load()
|
|
18
|
+
const mission = state.missions.find((entry) => entry.id === input.missionId)
|
|
19
|
+
if (!mission) {
|
|
20
|
+
throw new TowerToolError('invalid-argument', `unknown mission "${input.missionId}"`)
|
|
21
|
+
}
|
|
22
|
+
const worker = assertCompletionRequest({ mission, roster: state.roster, input })
|
|
23
|
+
await tower.assertWorktree(mission.worktree)
|
|
24
|
+
const commits = await countCommits(mission.worktree.path, mission.worktree.baseCommit)
|
|
25
|
+
if (commits < 1) {
|
|
26
|
+
throw new TowerToolError(
|
|
27
|
+
'invalid-argument',
|
|
28
|
+
`mission branch has no commits since ${String(mission.worktree.baseCommit).slice(0, 12)}`,
|
|
29
|
+
)
|
|
30
|
+
}
|
|
31
|
+
if (!(await isClean(mission.worktree.path))) {
|
|
32
|
+
const dirty = (await tower.git(mission.worktree.path, ['status', '--porcelain']))
|
|
33
|
+
.split('\n')
|
|
34
|
+
.filter(Boolean)
|
|
35
|
+
.map((line) => line.slice(3).trim())
|
|
36
|
+
throw new TowerToolError(
|
|
37
|
+
'worktree-dirty',
|
|
38
|
+
`mission ${mission.id} worktree has uncommitted changes (${dirty.slice(0, 8).join(', ')}${dirty.length > 8 ? ', …' : ''}); commit, remove, or record them before tower_complete`,
|
|
39
|
+
)
|
|
40
|
+
}
|
|
41
|
+
const unread = tower.unreadFor(state, worker.name)
|
|
42
|
+
assertCompletionInbox({ mission, worker, unread })
|
|
43
|
+
return { state, mission, worker, commits }
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export async function complete(tower, input) {
|
|
47
|
+
// Two-phase pilot (#51): expensive git/state checks run before the lock;
|
|
48
|
+
// the lock only revalidates the state revision and commits the transition.
|
|
49
|
+
const prepared = await tower.checkCompletion(input)
|
|
50
|
+
return withLock(tower.paths, async () => {
|
|
51
|
+
const state = await tower.load()
|
|
52
|
+
if (state.revision !== prepared.state.revision) {
|
|
53
|
+
throw new TowerToolError('stale-base', 'tower state changed while completion was being prepared; retry')
|
|
54
|
+
}
|
|
55
|
+
const mission = state.missions.find((entry) => entry.id === input.missionId)
|
|
56
|
+
if (!mission) throw new TowerToolError('mission-not-found', `unknown mission "${input.missionId}"`)
|
|
57
|
+
const { worker, commits } = prepared
|
|
58
|
+
const checks = normalizeChecks(input.checks)
|
|
59
|
+
const now = Date.now()
|
|
60
|
+
mission.status = 'completed'
|
|
61
|
+
mission.completionReport = completionReport({ report: input.report, worker, checks, now })
|
|
62
|
+
mission.updatedAt = now
|
|
63
|
+
state.revision += 1
|
|
64
|
+
state.updatedAt = now
|
|
65
|
+
|
|
66
|
+
validateState(state, tower.paths.state)
|
|
67
|
+
await writeJsonAtomic(tower.paths.state, state)
|
|
68
|
+
await appendJsonl(tower.paths.activity, {
|
|
69
|
+
at: now,
|
|
70
|
+
type: 'complete',
|
|
71
|
+
missionId: mission.id,
|
|
72
|
+
text: `${worker.name} reported completion (${commits} commit(s))`,
|
|
73
|
+
})
|
|
74
|
+
return { mission: fullMission(mission), commits, unread: 0 }
|
|
75
|
+
})
|
|
76
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// Dashboard mission enrichment use case.
|
|
2
|
+
import { exists } from '../lib/store.js'
|
|
3
|
+
import { revParse } from '../lib/git.js'
|
|
4
|
+
import {
|
|
5
|
+
enrichedMissionView,
|
|
6
|
+
latestReviewForMission,
|
|
7
|
+
missionsToEnrich,
|
|
8
|
+
} from '../domain/read/enrichedMissions.js'
|
|
9
|
+
|
|
10
|
+
export async function enrichedMissions(tower, state, onlyIds) {
|
|
11
|
+
const out = []
|
|
12
|
+
for (const mission of missionsToEnrich(state.missions, onlyIds)) {
|
|
13
|
+
const latest = latestReviewForMission(state, mission)
|
|
14
|
+
let tipMoved = false
|
|
15
|
+
if (latest && mission.worktree && (await exists(mission.worktree.path))) {
|
|
16
|
+
try {
|
|
17
|
+
const tip = await revParse(mission.worktree.path, 'HEAD')
|
|
18
|
+
tipMoved = tip !== latest.reviewedCommit
|
|
19
|
+
} catch {
|
|
20
|
+
tipMoved = false
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
out.push(enrichedMissionView({ mission, latest, tipMoved }))
|
|
24
|
+
}
|
|
25
|
+
return out
|
|
26
|
+
}
|
package/app/findings.js
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
// Findings use cases.
|
|
2
|
+
import { withLock, writeJsonAtomic, appendJsonl } from '../lib/store.js'
|
|
3
|
+
import { validateState } from '../domain/state.js'
|
|
4
|
+
import { TowerToolError } from '../domain/errors.js'
|
|
5
|
+
import {
|
|
6
|
+
normalizeFindingInput,
|
|
7
|
+
newFinding,
|
|
8
|
+
normalizeFindingActor,
|
|
9
|
+
wantsFindingUpdate,
|
|
10
|
+
readFindingsView,
|
|
11
|
+
applyFindingUpdate,
|
|
12
|
+
findingUpdateView,
|
|
13
|
+
} from '../domain/missions/findings.js'
|
|
14
|
+
|
|
15
|
+
function fail(message, code = 'invalid-argument') {
|
|
16
|
+
throw new TowerToolError(code, message)
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export async function addFinding(tower, input) {
|
|
20
|
+
return withLock(tower.paths, async () => {
|
|
21
|
+
const state = await tower.load()
|
|
22
|
+
if (!Array.isArray(state.findings)) state.findings = []
|
|
23
|
+
const fields = normalizeFindingInput(input, state.missions)
|
|
24
|
+
const now = Date.now()
|
|
25
|
+
const seq = (state.findingSeq ?? 0) + 1
|
|
26
|
+
state.findingSeq = seq
|
|
27
|
+
const finding = newFinding(fields, { seq, now, reportedBy: input.reportedBy })
|
|
28
|
+
state.findings.push(finding)
|
|
29
|
+
state.revision += 1
|
|
30
|
+
state.updatedAt = now
|
|
31
|
+
|
|
32
|
+
validateState(state, tower.paths.state)
|
|
33
|
+
await writeJsonAtomic(tower.paths.state, state)
|
|
34
|
+
await appendJsonl(tower.paths.activity, {
|
|
35
|
+
at: now,
|
|
36
|
+
type: 'finding',
|
|
37
|
+
missionId: finding.missionId,
|
|
38
|
+
text: `${finding.id} ${finding.type}: ${finding.title}`,
|
|
39
|
+
})
|
|
40
|
+
return finding
|
|
41
|
+
})
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** List or read findings. Read-only paths never take the writer lock. */
|
|
45
|
+
export async function readFindings(tower, input = {}) {
|
|
46
|
+
const state = await tower.load()
|
|
47
|
+
return readFindingsView(state.findings ?? [], input)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* List, read, or transition findings. Listing and reading are lock-free;
|
|
52
|
+
* only a transition takes the writer lock. Reviewers may only read.
|
|
53
|
+
*/
|
|
54
|
+
export async function findings(tower, input = {}) {
|
|
55
|
+
const actor = normalizeFindingActor(input.actor)
|
|
56
|
+
if (!wantsFindingUpdate(input)) return tower.readFindings(input)
|
|
57
|
+
|
|
58
|
+
return withLock(tower.paths, async () => {
|
|
59
|
+
const state = await tower.load()
|
|
60
|
+
const all = state.findings ?? []
|
|
61
|
+
const finding = all.find((entry) => entry.id === input.id)
|
|
62
|
+
if (!finding) fail(`unknown finding "${input.id}"`, 'finding-not-found')
|
|
63
|
+
const now = Date.now()
|
|
64
|
+
const { noteAdded } = applyFindingUpdate({ finding, input, actor, now })
|
|
65
|
+
state.revision += 1
|
|
66
|
+
state.updatedAt = now
|
|
67
|
+
validateState(state, tower.paths.state)
|
|
68
|
+
await writeJsonAtomic(tower.paths.state, state)
|
|
69
|
+
await appendJsonl(tower.paths.activity, {
|
|
70
|
+
at: now,
|
|
71
|
+
type: 'finding',
|
|
72
|
+
missionId: finding.missionId,
|
|
73
|
+
text: `${finding.id} ${finding.status}${noteAdded ? ' +note' : ''} (${actor.name})`,
|
|
74
|
+
})
|
|
75
|
+
// Counts must reflect the transition just recorded.
|
|
76
|
+
return findingUpdateView(finding, all)
|
|
77
|
+
})
|
|
78
|
+
}
|