dsh-tower 2.5.0 → 2.7.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 +114 -0
- package/COMPATIBILITY.md +2 -0
- package/CONTRIBUTING.md +1 -1
- package/GLOSSARY.md +6 -0
- package/PRINCIPLES.md +15 -7
- package/README.en.md +18 -16
- package/README.md +18 -11
- package/adapters/leases.js +20 -6
- package/adapters/legacy.js +149 -37
- package/adapters/stateFile.js +11 -1
- package/app/archive.js +30 -5
- package/app/briefing.js +95 -12
- package/app/checkCompletion.js +107 -18
- package/app/enrichedMissions.js +1 -1
- package/app/freshness.js +77 -0
- package/app/gate.js +26 -12
- package/app/merge.js +7 -4
- package/app/mission.js +24 -1
- package/app/missionContext.js +2 -1
- package/app/plan.js +64 -1
- package/app/provision.js +133 -22
- package/app/read.js +38 -5
- package/app/rebase.js +10 -8
- package/app/review.js +104 -61
- package/app/reviewCheckout.js +133 -0
- package/app/sandbox.js +121 -0
- package/app/sharedCheckout.js +90 -0
- package/app/spawn-prechecks.js +31 -20
- package/app/spawn-support.js +59 -8
- package/app/spawn.js +3 -0
- package/app/teardown.js +119 -2
- package/app/tower.js +34 -6
- package/client.js +321 -0
- package/cordis.patch.yml +55 -3
- package/docs/adr/0005-component-surface.md +58 -13
- package/docs/adr/0006-mission-shapes.md +306 -0
- package/docs/architecture/overview.md +18 -10
- package/docs/examples/first-mission.md +3 -2
- package/docs/faq.md +9 -7
- package/docs/guide/install-upgrade-migration.md +58 -7
- package/docs/guide/quickstart.md +20 -10
- package/docs/guide/scenarios-and-philosophy.md +35 -28
- package/docs/operations/runbook.md +1 -1
- package/docs/operations/security.md +11 -10
- package/docs/reference/blockers.md +20 -11
- package/docs/reference/component-surface.md +20 -8
- package/docs/reference/config.generated.md +8 -5
- package/docs/reference/config.md +30 -5
- package/docs/reference/doctor.md +7 -7
- package/docs/reference/errors.generated.md +1 -0
- package/docs/reference/errors.md +2 -1
- package/docs/reference/operations.md +1 -1
- package/docs/reference/shapes.md +123 -0
- package/docs/reference/surface-budget.md +53 -4
- package/docs/reference/testing.md +5 -3
- package/docs/reference/tools.generated.md +7 -7
- package/docs/reference/tools.md +5 -5
- package/docs/zh/faq.md +7 -5
- package/docs/zh/guide/install-upgrade-migration.md +25 -6
- package/docs/zh/guide/quickstart.md +17 -10
- package/docs/zh/guide/scenarios-and-philosophy.md +23 -15
- package/docs/zh/operations/security.md +9 -8
- package/docs/zh/reference/blockers.md +13 -9
- package/docs/zh/reference/config.md +22 -5
- package/docs/zh/reference/errors.md +2 -1
- package/docs/zh/reference/testing.md +5 -4
- package/docs/zh/reference/tools.md +3 -3
- package/domain/batch.js +75 -105
- package/domain/collab/batch.js +30 -10
- package/domain/collab/spawn.js +53 -16
- package/domain/components.js +12 -4
- package/domain/config/schema.js +87 -22
- package/domain/contracts/outputs.js +2 -1
- package/domain/contracts/tool-inputs/missions.js +10 -2
- package/domain/errors.js +6 -0
- package/domain/evidence.js +283 -0
- package/domain/gate.js +17 -34
- package/domain/integration/gate.js +17 -43
- package/domain/integration/init.js +3 -2
- package/domain/integration/provision.js +51 -0
- package/domain/integration/rebase.js +16 -1
- package/domain/integration/review.js +203 -9
- package/domain/mission.js +16 -33
- package/domain/missions/completion.js +46 -8
- package/domain/missions/findings.js +2 -1
- package/domain/missions/lifecycle.js +131 -35
- package/domain/missions/plan.js +30 -6
- package/domain/missions/review.js +2 -18
- package/domain/planning.js +5 -5
- package/domain/read/missionContext.js +17 -3
- package/domain/read/views.js +5 -1
- package/domain/roles.js +222 -0
- package/domain/roster.js +9 -3
- package/domain/scope.js +51 -0
- package/domain/shapes/build.js +254 -0
- package/domain/shapes/index.js +171 -0
- package/domain/shapes/survey.js +445 -0
- package/domain/state/archive.js +28 -4
- package/domain/state/migrations.js +111 -4
- package/domain/state/schema.js +69 -7
- package/domain/state/semantics.js +93 -13
- package/domain/state.js +5 -1
- package/domain/summary.js +5 -11
- package/domain/vocabulary.js +15 -33
- package/host/capability-registry.js +25 -9
- package/host/config.js +40 -3
- package/host/doctor.js +20 -8
- package/host/preset-survey-sandbox.js +19 -0
- package/host/preset-survey.js +18 -0
- package/host/presets.js +17 -11
- package/host/tool-registry.js +23 -13
- package/locale/en.json +16 -1
- package/locale/preset-survey/en.json +6 -0
- package/locale/preset-survey/zh.json +6 -0
- package/locale/preset-survey-sandbox/en.json +6 -0
- package/locale/preset-survey-sandbox/zh.json +6 -0
- package/locale/zh.json +16 -1
- package/package.json +19 -2
- package/ports/state.js +8 -1
- package/scripts/architecture-waivers.json +3 -3
- package/scripts/check-components.mjs +120 -6
- package/scripts/check-docs.mjs +1 -1
- package/scripts/check-pack.mjs +5 -0
- package/scripts/component-surface.json +83 -29
- package/scripts/generate-capability-locale.mjs +5 -0
- package/scripts/generate-shapes-reference.mjs +171 -0
- package/scripts/mount-smoke/surface.mjs +3 -0
- package/scripts/pack-manifest.txt +22 -0
- package/scripts/shape-audit.mjs +361 -0
- package/scripts/surface-baseline.json +75 -4
- package/scripts/types-coverage.mjs +7 -0
- package/survey-sandbox.js +48 -0
- package/survey.js +40 -0
- package/tools/complete.js +5 -5
- package/tools/diff.js +5 -3
- package/tools/plan.js +1 -1
- package/tools/preflight.js +3 -2
- package/tools/review.js +1 -1
- package/tools/spawn.js +48 -50
- package/tools/status.js +8 -1
- package/tools/test.js +2 -1
- package/types/index.d.ts +76 -5
|
@@ -6,12 +6,13 @@ Tower 提供的是**协作层面的保证**,不是沙箱。需要理解哪些
|
|
|
6
6
|
|
|
7
7
|
## 由插件强制执行的约束
|
|
8
8
|
|
|
9
|
-
-
|
|
10
|
-
tip 变化会被门禁拒绝(`tip-moved`),除非一次干净且无冲突的 rebase
|
|
11
|
-
之后修改分支会被拒绝(`reviewer must not modify the mission branch`)。
|
|
12
|
-
-
|
|
13
|
-
|
|
14
|
-
|
|
9
|
+
- **审查绑定(策略为 required 时)。** `tower_review` 会记录它检查的分支 tip;策略为
|
|
10
|
+
`review.mode:required` 时,tip 变化会被门禁拒绝(`tip-moved`),除非一次干净且无冲突的 rebase
|
|
11
|
+
给予了豁免。reviewer 在 spawn 之后修改分支会被拒绝(`reviewer must not modify the mission branch`)。
|
|
12
|
+
- **审查默认可选,可由三态策略强制。** `review.mode` 取 `off`、`optional`(默认)或 `required`。
|
|
13
|
+
设为 `required` 才启用独立审查门禁。策略为 required 时,mission 级 `reviewPolicy:"none"` + 理由是
|
|
14
|
+
更窄的豁免。策略为 `off` 时审查被有意关闭;为 `optional` 时插件不强制职责分离,只应用于可信仓库或
|
|
15
|
+
有人类监督的会话。该策略是插件页上的一个控件,不是组件行。
|
|
15
16
|
- **角色强制。** 调用方会根据 roster 解析。worker 的 mission 更新仅限于 task/blocker/note;
|
|
16
17
|
`completed`、`abandoned`、扩大 scope 与清除 blocker 仅限控制塔,完成(连同未读消息/提交门禁)必须
|
|
17
18
|
走 `tower_complete`。仅限控制塔的工具会以 `[tower:not-authorized]` 拒绝 roster 中的 worker。
|
|
@@ -52,8 +53,8 @@ Tower 模式并修复它”。Tower **不认证**这句话背后的人类意图
|
|
|
52
53
|
|
|
53
54
|
它**不**防御:完全通过所有门禁但被恶意指令操纵的模型;仓库自己声明的 hooks/测试命令;或无视
|
|
54
55
|
指令、通过 shell 访问 worktree 之外文件的 worker。更高保证的团队应设
|
|
55
|
-
`mode.allowAgentControl:false
|
|
56
|
-
OS 级隔离运行 host。
|
|
56
|
+
`mode.allowAgentControl:false`、保持审查策略为 `review.mode:required`、在运行 Tower 前审查仓库,
|
|
57
|
+
并用 OS 级隔离运行 host。
|
|
57
58
|
|
|
58
59
|
## 不由插件强制执行的约束(部署关注点)
|
|
59
60
|
|
|
@@ -5,6 +5,10 @@
|
|
|
5
5
|
`tower_preflight` 返回 `{ok:false, blockers:[{code, detail}]}`。`tower_merge` 与 `tower_rebase`
|
|
6
6
|
在操作失败时返回相同结构。code 稳定;detail 面向人。下表共定义 32 个稳定 blocker/failure code。
|
|
7
7
|
|
|
8
|
+
下列审查 blocker(`no-review`、`review-mission-mismatch`、`not-clean`、`review-hold`、
|
|
9
|
+
`review-not-approved`、`tip-moved`、`review-exemption-invalid`)只在 `review.mode` 为 `required`
|
|
10
|
+
时产生。`off` 或 `optional` 不会产生审查 blocker;`off` 还会移除 `tower_review` 并拒绝 reviewer spawn。
|
|
11
|
+
|
|
8
12
|
## 门禁 blocker(26 个)
|
|
9
13
|
|
|
10
14
|
| Code | 含义 / 下一步 |
|
|
@@ -16,16 +20,16 @@
|
|
|
16
20
|
| `no-check-evidence` | 启用了 `gates.requireChecks` 且完成报告没有 `checks`。把 completed mission 退回 `active`,带 checks 重新调用 `tower_complete`,然后重新审查。 |
|
|
17
21
|
| `branch-missing` | 还没有 worktree/分支:先 spawn worker。 |
|
|
18
22
|
| `stale-base` | 分支不包含基线 tip:执行 `tower_rebase`。 |
|
|
19
|
-
| `read-only-survey` | survey mission 修改了文件;survey mission 必须只读。 |
|
|
20
23
|
| `out-of-scope` | 改动文件超出 mission scope;有意为之就扩大 scope,否则回退。在大小写敏感的检出上,仅大小写不同的路径仍会被拒绝,detail 会指出折叠大小写后本可匹配的 scope 条目。 |
|
|
21
24
|
| `base-dirty` | 主检出存在未提交改动,合并会覆盖它们。 |
|
|
22
25
|
| `deps-unmerged` | 依赖 mission 尚未满足:build 依赖须 `merged`/`abandoned`;survey 依赖在 `completed` 即视为满足(含归档记录)。 |
|
|
23
|
-
| `
|
|
24
|
-
| `review
|
|
25
|
-
| `
|
|
26
|
-
| `
|
|
27
|
-
| `review-
|
|
28
|
-
| `
|
|
26
|
+
| `survey-stale` | build 依赖的 survey 的 `readCommit` 不是 build base 的祖先或同一提交。默认只是 `plan`/`preflight` 的 `warnings[]` 条目;`gates.requireFreshSurvey:true` 时同一 code 阻止合并。针对当前 base 重新规划 survey,或记录为何该读取仍然有效。 |
|
|
27
|
+
| `no-review` | `review.mode:required` 且分支上没有审查:spawn reviewer(或关闭审查门禁)。 |
|
|
28
|
+
| `review-mission-mismatch` | `review.mode:required` 且最新审查属于另一个 mission。 |
|
|
29
|
+
| `not-clean` | `review.mode:required` 且最新审查是 `p1-Nitems`/`p2-Nitems`;需要再来一轮(同一轮的各 route 结论会追加记录,不会让状态反复翻转)。 |
|
|
30
|
+
| `review-hold` | `review.mode:required` 且最新审查建议 hold;必须由后续的干净审查取代。 |
|
|
31
|
+
| `review-not-approved` | `review.mode:required` 且最新干净审查给出的是 `fix-then-merge` 或 `hold`;只有 `merge` 才算批准。 |
|
|
32
|
+
| `tip-moved` | `review.mode:required` 且审查之后分支发生变化;重新审查(或使用干净 rebase 的免审豁免)。 |
|
|
29
33
|
| `worker-busy` | 该 mission 的 roster agent 正在运行;等待或让其退役。 |
|
|
30
34
|
| `worker-status-unknown` | 父目录不可用,无法证明 live roster agent 已停止;等目录恢复后重试,或先 adopt。 |
|
|
31
35
|
| `base-wrong-branch` | 主检出不在记录的基线分支上。 |
|
|
@@ -33,7 +37,7 @@
|
|
|
33
37
|
| `worker-stale` | 该 mission 的 roster worker 超过 `spawn.staleMs` 没有目录活动;合并前 adopt(只有确实还在运行时才 interrupt)。 |
|
|
34
38
|
| `tests-missing` | 启用了 `gates.requireTests` 且没有针对当前分支 tip 的通过测试回执;运行 `tower_test` 或 `tower_complete`。 |
|
|
35
39
|
| `tests-failed` | 最新测试发现回执失败,或不满足 minCount/required 检查。修复测试并重跑。 |
|
|
36
|
-
| `review-exemption-invalid` | `review.
|
|
40
|
+
| `review-exemption-invalid` | `review.mode:required` 且 mission 请求 `reviewPolicy:"none"`,但 `gates.allowReviewExemption` 关闭(或缺少理由)。 |
|
|
37
41
|
| `merge-intent-pending` | 崩溃/失败前记录了合并意图;先运行 `tower_merge` 对账,再进行其他门禁。 |
|
|
38
42
|
|
|
39
43
|
## 合并失败(4 个)
|
|
@@ -59,4 +63,4 @@
|
|
|
59
63
|
分支已包含在基线中时,`tower_rebase` 返回 `alreadyMerged: true`;此时调用 `tower_merge` 记录这次
|
|
60
64
|
已落地的合并。
|
|
61
65
|
|
|
62
|
-
稳定的 blocker code 共
|
|
66
|
+
稳定的 blocker code 共 31 个(25 个门禁 + 4 个合并失败 + 2 个 rebase 专属;`worker-busy` 与门禁共用)。
|
|
@@ -27,13 +27,30 @@ merge、worktree 与 spawn 策略。它使用与 profile 配置相同的 JSON
|
|
|
27
27
|
|
|
28
28
|
```json
|
|
29
29
|
{
|
|
30
|
-
"review": { "
|
|
30
|
+
"review": { "mode": "required", "maxRoundsPerReviewer": 3 },
|
|
31
31
|
"merge": { "timeoutMs": 1800000 },
|
|
32
32
|
"worktrees": { "root": "D:/tower-worktrees" },
|
|
33
33
|
"spawn": { "reviewerPreset": "tower-reviewer", "maxDepth": 2 }
|
|
34
34
|
}
|
|
35
35
|
```
|
|
36
36
|
|
|
37
|
+
`review.mode` 就是审查策略:`off`、`optional` 或 `required`。`review.requireReview` 是保留一个版本的
|
|
38
|
+
兼容别名(`true` → `required`,`false` → `optional`),请优先使用 `review.mode`。同一层同时设置两者时,
|
|
39
|
+
`requireReview:true` 与任何非 `required` 的 mode 冲突,会以 `config-invalid` 拒绝并同时点名两个键。
|
|
40
|
+
`requireReview:false` 与任何显式 mode 都不冲突:`off`、`optional`、`required` 都接受,且以 mode 为准
|
|
41
|
+
(`required` 仍然强制门禁;只有该 mode 才会把解析后的别名镜像回 `true`)。仓库层会独立解析:它可以放宽
|
|
42
|
+
审查,但若 profile 已是 `review.mode:off`,`tower_review` 根本没有注册,仓库层不能自行提高 mode。
|
|
43
|
+
|
|
44
|
+
插件页控件显示的正是运行时解析出的策略:`review.mode` 存在时以它为准;只设置了 `review.requireReview`
|
|
45
|
+
的配置会显示为「必需」(`true`)或「可选」(`false`)。保存任何选择都会在同一份 profile patch 中写入
|
|
46
|
+
`review.mode`,并把 `review.requireReview` 置为 `false`,因此该别名不可能与所选 mode 矛盾。
|
|
47
|
+
|
|
48
|
+
同样的三个选项也出现在插件页的 `dsh-tower` 配置区:**关闭**、**可选**(默认)和 **必需**,每个选项都有一行效果说明。
|
|
49
|
+
该控件通过宿主设置表单写入 profile 配置,因此无需手动编辑文件,页面就会接受并显示所选值。
|
|
50
|
+
|
|
51
|
+
但已挂载的工具面无法实时重建:启动时审查为关闭的 profile 没有注册 `tower_review`。通过页面切换到「可选」
|
|
52
|
+
或「必需」会立即改变合并门禁,但审查工具本身需要重启(或重新加载配置)后才会出现;出现这种情况后控件会给出提示。
|
|
53
|
+
|
|
37
54
|
`mode.*` 分组是会话级的,不是仓库级的:为兼容 schema,它可以在仓库文件中出现,但只有 profile
|
|
38
55
|
配置能控制 Tower 模式提示与模型切换开关。文件格式错误、值不是对象、未知顶层键,或违反声明 schema
|
|
39
56
|
的值(例如 `merge.timeoutMs` 小于 1000)会以稳定的 `config-invalid` / `config-unknown-key`
|
|
@@ -60,7 +77,8 @@ merge、worktree 与 spawn 策略。它使用与 profile 配置相同的 JSON
|
|
|
60
77
|
| `mode.denyTools` | goal/todo/team/workflow/subagent 工具(默认 12 个) | 模式激活期间被拒绝的工具。 |
|
|
61
78
|
| `mode.storePath` | `$DSH_HOME/tower-mode` | `modes.json` 所在目录。 |
|
|
62
79
|
| `mode.maxEntries` | `200` | 最多持久化的模式条目数(1–2000)。 |
|
|
63
|
-
| `review.
|
|
80
|
+
| `review.mode` | `optional` | 审查策略。`off` = 审查被有意关闭(记录审查会被拒绝、`tower_spawn` 拒绝 reviewer 角色、合并门禁忽略审查;profile 级的 `off` 也不会注册 `tower_review`,仓库级的 `off` 则拒绝提交);`optional` = 审查可用但从不阻塞合并(默认);`required` = 绑定分支 tip 的 clean 审查是合并门禁(`no-review`/`not-clean`/`review-hold`/`review-not-approved`/`tip-moved`/`review-mission-mismatch`)。 |
|
|
81
|
+
| `review.requireReview` | `false` | `review.mode` 的兼容别名(已弃用):`true` → `required`,`false` → `optional`。请优先使用 `review.mode`。`true` 与非 `required` 的 `review.mode` 同时设置会以 `config-invalid` 拒绝并点名两个键;`false` 可与任何显式 `review.mode`(`off`/`optional`/`required`)共存,且不会覆盖它。插件页控件在该别名是唯一键时按它显示,并在保存时写入 `review.mode` 且把 `review.requireReview` 归一化为 `false`。 |
|
|
64
82
|
| `review.maxRoundsPerReviewer` | `5` | 每个 reviewer 对每条分支的审查轮数上限。 |
|
|
65
83
|
| `merge.timeoutMs` | `900000`(15 分钟) | `tower_merge` 默认超时,包含钩子;单次调用的 `timeoutMs` 优先。 |
|
|
66
84
|
| `merge.maxBufferBytes` | `67108864`(64 MB) | 合并输出缓冲区。 |
|
|
@@ -81,11 +99,10 @@ merge、worktree 与 spawn 策略。它使用与 profile 配置相同的 JSON
|
|
|
81
99
|
| `tests.admission` | `off` | `spawn` 在启动 worker 前运行发现(fail-closed,不启动子代理);`complete` 在 `tower_complete` 时运行;`off` 只在显式 `tower_test` 时运行(默认;不会自动执行仓库代码)。 |
|
|
82
100
|
| `tests.timeoutMs` | `60000` | 每条发现命令的超时。 |
|
|
83
101
|
| `tests.maxBytes` | `16777216`(16 MiB) | 每条命令捕获的 stdout 上限。 |
|
|
84
|
-
| `tests.autoChecks` | `true` | 向完成报告附加自动 `tests` 检查。 |
|
|
85
102
|
| `tests.allowPackageDiscovery` | `true` | 允许仓库控制的 `tower.testDiscovery` 命令(会执行 worktree 代码;见安全页)。 |
|
|
86
|
-
| `gates.requireChecks` | `false` |
|
|
103
|
+
| `gates.requireChecks` | `false` | 合并前要求至少一条已记录检查(`tower_complete.checks`)或测试回执(`no-check-evidence`)。 |
|
|
87
104
|
| `gates.requireTests` | `false` | 要求针对当前 mission 分支 tip 的通过测试回执(`tests-missing`);与审查门禁独立。`tower-tests` 组件行关闭时,规划/进入模式会带解释拒绝该组合。 |
|
|
88
|
-
| `gates.allowReviewExemption` | `false` | 在 `review.
|
|
105
|
+
| `gates.allowReviewExemption` | `false` | 在 `review.mode:required` 时,允许用 `reviewPolicy:"none"` + 非空理由跳过审查 blocker;默认故意关闭;`review.mode` 为 `off` 或 `optional` 时无影响(不会产生审查 blocker)。 |
|
|
89
106
|
| `gates.maxReportBytes` | `32768`(32 KiB) | `tower_complete.report` 的 UTF-8 字节硬上限。超出时以 `invalid-argument` 拒绝并给出可执行的 `nextAction`(把全文写进 mission worktree 的文件并引用路径),绝不截断。已存在的超限报告仍可读取。 |
|
|
90
107
|
| `docs.boardLocation` | `common` | `common` = `<git-common-dir>/dsh-tower/MISSIONS.md`;`repo` = `<repoRoot>/MISSIONS.md`。是否写出看板由 `tower-board` 组件行决定(#133);`docs.board` 已移除,会作为未知键被拒绝。 |
|
|
91
108
|
| `archive.afterDays` | `30` | 年龄触发:终态任务超过这么多天未更新,会在下次变更时归档(`0` 表示立即归档)。归档本身由 `tower-archive` 组件行决定(#135);`archive.enabled` 已移除,会作为未知键被拒绝。 |
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
[English](../../reference/errors.md) | **简体中文**
|
|
4
4
|
|
|
5
5
|
工具失败是抛出的错误,不是 `{ok:false}` 结果(门禁结果是例外:见 [blockers.md](blockers.md))。
|
|
6
|
-
每个抛出的错误都以稳定 code 作为消息前缀。本版本定义
|
|
6
|
+
每个抛出的错误都以稳定 code 作为消息前缀。本版本定义 36 个工具错误码。
|
|
7
7
|
|
|
8
8
|
```
|
|
9
9
|
[tower:mission-not-found] unknown mission "M9"
|
|
@@ -35,6 +35,7 @@
|
|
|
35
35
|
| `provider-cooldown` | 连续可重试的 provider 失败后,spawn 进入短暂冷却。 | 等待 `blockedUntil` 后重试 |
|
|
36
36
|
| `preset-missing` | 配置的 agent preset 未注册。 | 缺少 `tower-worker`/`tower-reviewer` preset 行、DSH 未重启 |
|
|
37
37
|
| `reviewer-prerequisite` | reviewer spawn 的前置条件未满足。 | mission 尚未 `completed` 或还没有 worktree 就 spawn reviewer |
|
|
38
|
+
| `review-disabled` | 审查被 `review.mode:off` 关闭;该配置下没有 reviewer、审查结论或审查 blocker。 | `review.mode` 为 `off` 时以 `role:"reviewer"` 调用 `tower_spawn`,或提交审查 |
|
|
38
39
|
| `not-authorized` | 调用方 agent 角色无权执行该操作。 | worker 调用仅限控制塔的工具,或修改 mission 状态/scope;reviewer 修改 finding |
|
|
39
40
|
| `workspace-busy` | 另一个 Tower 会话持有该仓库的 workspace 锁。 | `mode.workspaceLock:true` 时第二个会话调用仓库级工具 |
|
|
40
41
|
| `worker-limit` | 仓库达到 `spawn.maxLiveWorkers` 个活跃 worker。 | 默认上限下 spawn 第五个 worker |
|
|
@@ -48,10 +48,11 @@ stdout/stderr 文件、SHA-256 哈希、退出码、计数、required 名称、w
|
|
|
48
48
|
`tower_complete` 会抛出 `[tower:tests-failed]`。
|
|
49
49
|
|
|
50
50
|
- `tower_test({repoRoot,missionId})` 按需运行发现并记录回执。
|
|
51
|
-
- `
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
51
|
+
- `testRuns` 中的回执是唯一的证据形态;`tower_complete` 不再向完成报告复制 `tests` 检查,完成检查、批次关闭
|
|
52
|
+
与 `gates.requireChecks` 读取同一份回执。
|
|
53
|
+
- `gates.requireChecks` 要求任意已记录检查或回执;`gates.requireTests` 要求针对当前 mission 分支 tip 的
|
|
54
|
+
通过回执(`tests-missing`)。测试门禁与审查独立:即使策略为 `review.mode:optional`(默认)或
|
|
55
|
+
`off`,测试门禁仍检查分支 tip。
|
|
55
56
|
|
|
56
57
|
## 信任边界
|
|
57
58
|
|
|
@@ -37,7 +37,7 @@ roster 角色由 `exec.agent` 与 roster 共同决定。mission 级工具(`tow
|
|
|
37
37
|
| `tower_plan` | `missions[]`(`title`——可打印 Unicode,NFC 规范化,≤120 字符、`scope[]`、`tasks[]?`、`deps[]?`、`kind?`、`context?`、`references[]?` ≤20、`reviewPolicy?`、`reviewExemptionReason?`) | 创建 mission `M1..`;重叠的 build scope 会自动加依赖;拒绝未知依赖与依赖循环。mission id 保持 ASCII(`M1`、`M2`);不可见字符与超长标题会以 `invalid-argument` 拒绝。`reviewPolicy:"none"` 需要非空理由;审查门禁开启时,preflight 还要求 `gates.allowReviewExemption`。 |
|
|
38
38
|
| `tower_mission` | `id`(别名 `missionId`)、`status?`、`task_done?`、`task_drop?`+`task_drop_reason?`、`blocker?`、`clear_blockers?`、`note?`、`scope?`、`pause_cause?`、`authorization?` | 更新单个 mission。worker 可安全使用的字段是 task/blocker/note;不能在这里设置 `merged`。暂停的 mission 记录 `pause_cause`(user/failure/resource/dependency);恢复用户暂停的 mission 会遵循 `mode.requireResumeAuthorization`。 |
|
|
39
39
|
| `tower_status` | `missionId?`、`offset?`、`limit?`、`includeGate?`、`format?` | 默认返回面板。分页 mission 只带 `reportBytes`;`missionId` 返回完整详情(任务/备注/审查/finding/完成报告正文)。`offset`/`limit` 对 mission 分页(默认 20,最大 100),`includeGate` 附带门禁摘要,`format:"markdown"` 返回看板。host 提供 mode hook 时还会包含 `mode.lastChange` 与最近 `history`。还返回仓库级 git 互斥锁 `gitLock`(`{held, pid, phase, heldMs}`,空闲时 `{held:false}`)。 |
|
|
40
|
-
| `tower_spawn` | `missionId`(别名 `id`)、`role`(`worker`/`reviewer`)、`name?`、`instructions?`、`worktreeRoot?`、`wait?`、`waitMs?`、`takeover?`、`includeDirtyBase?` | 准备或复用 worktree 并启动子代理。worker 使用 `tower-worker`;reviewer 要求 mission 为 `completed`,并使用只读的 `tower-reviewer
|
|
40
|
+
| `tower_spawn` | `missionId`(别名 `id`)、`role`(`worker`/`reviewer`)、`name?`、`instructions?`、`worktreeRoot?`、`wait?`、`waitMs?`、`takeover?`、`includeDirtyBase?` | 准备或复用 worktree 并启动子代理。worker 使用 `tower-worker`;reviewer 要求 mission 为 `completed`,并使用只读的 `tower-reviewer`;`review.mode:off` 时 reviewer 角色会以 `[tower:review-disabled]` 被拒绝。`takeover:true` 替换已不再运行的 worker 条目(ownerGeneration 递增)。每个 mission 只能有一个活跃 worker;`spawn.maxLiveWorkers` 限制整个仓库。 |
|
|
41
41
|
| `tower_roster` | — | roster 及来自子代理目录的实时活动(`running`/`inactive`/`missing`/`unknown`)与 `stale` 标记。只读。 |
|
|
42
42
|
| `tower_adopt` | `retireMissing?` | 把 roster 条目与持久子代理目录对账。可证明已消失的 agent 会被标记为 retired(默认),或标记为 `missing`(`retireMissing:false`)以便检查/接管。 |
|
|
43
43
|
| `tower_diff` | `missionId`(别名 `id`)、`maxBytes?` | 只读 merge-base diff:文件、提交、有上限的 patch。供 reviewer 安全使用。 |
|
|
@@ -47,8 +47,8 @@ roster 角色由 `exec.agent` 与 roster 共同决定。mission 级工具(`tow
|
|
|
47
47
|
| `tower_interrupt` | `to?`(roster 名称)、`agentId?`、`reason?` | 通过 `subagents.interrupt` 中断一个运行中的 roster agent,并记录 `state:"interrupted"`。幂等(未运行 → `interrupted:false`)。 |
|
|
48
48
|
| `tower_finding` | `type`、`title`、`summary`、`details`、`suggested_fix`、`severity?`、`missionId?`、`location?` | 记录 scope 之外的问题;reviewer 用它代替直接改文件。新 finding 初始为 `open`。 |
|
|
49
49
|
| `tower_findings` | `id?`、`status?`、`note?`、过滤(`type?`、`severity?`、`missionId?`、`open?`、`limit?`) | 生命周期工具:列出/读取/流转。状态为 `open → triaged → fixed`/`wontfix`,允许重新打开。只有控制塔或 worker 能修改;reviewer 只能列出/读取。 |
|
|
50
|
-
| `tower_review` | `missionId`(别名 `id`)、`status`(`clean`/`p1-Nitems`/`p2-Nitems`)、`merge`(`merge`/`fix-then-merge`/`hold`)、`findings`、`decision`、`checks?`、`breakGlassReason?` | 绑定当前 tip 的 reviewer 结论。reviewer 必须是获派的 reviewer(或由控制塔使用 break-glass
|
|
51
|
-
| `tower_preflight` | `missionId`(别名 `id`) | 只读门禁:返回 `{ok, blockers[], branchTip, baseTip, changedFiles, review}
|
|
50
|
+
| `tower_review` | `missionId`(别名 `id`)、`status`(`clean`/`p1-Nitems`/`p2-Nitems`)、`merge`(`merge`/`fix-then-merge`/`hold`)、`findings`、`decision`、`checks?`、`breakGlassReason?` | 绑定当前 tip 的 reviewer 结论。reviewer 必须是获派的 reviewer(或由控制塔使用 break-glass)。`review.mode:off` 时不注册;仓库层为 `off` 时提交会以 `[tower:review-disabled]` 被拒绝。非 clean 结论使 mission 回到 active 后,其他 route 仍可为同一 reviewed tip 提交结论:追加到同一轮(`appended:true`),不再改变状态;tip 已移动时会被明确拒绝。 |
|
|
51
|
+
| `tower_preflight` | `missionId`(别名 `id`) | 只读门禁:返回 `{ok, blockers[], branchTip, baseTip, changedFiles, review}`。审查 blocker 只在 `review.mode:required` 时出现;`off`/`optional` 会跳过。 |
|
|
52
52
|
| `tower_merge` | `missionId`(别名 `id`)、`timeoutMs?`(别名 `timeout_ms`) | 门禁全绿后合并。若合并提交已落地则记录(`reconciled`);若命令失败但提交已存在则恢复(`recoveredFromFailure`);区分 `merge-hook-failed` 与 `merge-conflict`。 |
|
|
53
53
|
| `tower_rebase` | `missionId`(别名 `id`) | 在 worktree 内把分支 rebase 到基线。worker 运行中或 worktree 脏时拒绝。分支已包含在基线中时返回 `alreadyMerged` 且保持分支不动。 |
|
|
54
54
|
| `tower_teardown` | `force?`、`exclude?`、`dry_run?`(别名 `dryRun`) | 移除符合条件的 merged/abandoned worktree 与分支;运行中的 agent 始终保留。 |
|
package/domain/batch.js
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
// Batch admission packets (2.1): pure create/close/summarize validation.
|
|
2
2
|
//
|
|
3
|
+
// A batch is a second, generic lifecycle layered over missions (#149 W3): it is
|
|
4
|
+
// not a mission shape and never gets a kind. Its whole lifecycle — statuses,
|
|
5
|
+
// transition edges, terminality, legacy-status normalization and the status
|
|
6
|
+
// vocabulary — lives in this module (see `BATCH_STATUS*`), so no other module
|
|
7
|
+
// compares or writes `batch.status` to a string literal.
|
|
8
|
+
//
|
|
3
9
|
// This module performs no filesystem, git, or subprocess I/O. The tower tool
|
|
4
10
|
// layer reads the worktree, receipt files, and test results, then passes what
|
|
5
11
|
// it observed into `validateClose`. Receipt hashes may be supplied by the
|
|
@@ -11,7 +17,7 @@
|
|
|
11
17
|
// state.missions[] -> { id, status, mergedCommit?, completionReport?, testRuns? }
|
|
12
18
|
// state.batches[] -> { id, status: 'open'|'closed'|'abandoned', missionIds,
|
|
13
19
|
// exclusions, requiredChecks, receiptPaths, receiptHashes? }
|
|
14
|
-
// state.legacySnapshots[] -> {
|
|
20
|
+
// state.legacySnapshots[] -> { sourceHash, pausedMissionIds: string[] }
|
|
15
21
|
// A batch record is the normalized validateCreate result plus `id`, `status`,
|
|
16
22
|
// `createdAt`, and the optional `receiptHashes` expected-hash map.
|
|
17
23
|
//
|
|
@@ -23,16 +29,64 @@
|
|
|
23
29
|
// `input.treeTip` to bind recorded check `treeCommit` values to the current
|
|
24
30
|
// HEAD.
|
|
25
31
|
import path from 'node:path'
|
|
32
|
+
import { findEvidence } from './evidence.js'
|
|
33
|
+
import { shapeFor } from './shapes/index.js'
|
|
26
34
|
|
|
27
35
|
const SHA256_PATTERN = /^[a-f0-9]{64}$/i
|
|
28
36
|
const MAX_MISSIONS = 50
|
|
29
37
|
const MAX_CHECKS = 50
|
|
30
38
|
const MAX_RECEIPTS = 200
|
|
31
39
|
const MAX_EXCLUSIONS = 50
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
40
|
+
|
|
41
|
+
// The batch lifecycle contract (#149 W3). A batch is opened by tower_batch
|
|
42
|
+
// create and leaves `open` exactly once, to `closed` (every member verified) or
|
|
43
|
+
// `abandoned` (retired without verification). There is no third stop state and
|
|
44
|
+
// no per-shape variant.
|
|
45
|
+
export const BATCH_STATUS = Object.freeze({ OPEN: 'open', CLOSED: 'closed', ABANDONED: 'abandoned' })
|
|
46
|
+
/** @type {readonly string[]} */
|
|
47
|
+
export const BATCH_STATUSES = Object.freeze([BATCH_STATUS.OPEN, BATCH_STATUS.CLOSED, BATCH_STATUS.ABANDONED])
|
|
48
|
+
export const BATCH_TRANSITIONS = Object.freeze({
|
|
49
|
+
[BATCH_STATUS.OPEN]: Object.freeze([BATCH_STATUS.CLOSED, BATCH_STATUS.ABANDONED]),
|
|
50
|
+
[BATCH_STATUS.CLOSED]: Object.freeze([]),
|
|
51
|
+
[BATCH_STATUS.ABANDONED]: Object.freeze([]),
|
|
52
|
+
})
|
|
53
|
+
// Pre-v5 documents (and hand-written fixtures) used synonyms for `open`; v5
|
|
54
|
+
// normalizes them once in the state migration and nothing else accepts them.
|
|
55
|
+
/** @type {readonly string[]} */
|
|
56
|
+
const LEGACY_OPEN_BATCH_STATUSES = Object.freeze(['created', 'active', 'pending'])
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* @param {unknown} value
|
|
60
|
+
* @returns {boolean}
|
|
61
|
+
*/
|
|
62
|
+
export function isBatchTerminal(value) {
|
|
63
|
+
return value === BATCH_STATUS.CLOSED || value === BATCH_STATUS.ABANDONED
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* @param {unknown} from
|
|
68
|
+
* @param {unknown} to
|
|
69
|
+
* @returns {boolean}
|
|
70
|
+
*/
|
|
71
|
+
export function canTransitionBatch(from, to) {
|
|
72
|
+
return (BATCH_TRANSITIONS[/** @type {string} */ (from)] ?? []).includes(/** @type {string} */ (to))
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Normalize a recorded batch status to the canonical vocabulary. An absent
|
|
77
|
+
* status means `open`; a legacy synonym means `open`; an unknown value is left
|
|
78
|
+
* as-is so validation can report it instead of silently rewriting it.
|
|
79
|
+
* @param {unknown} value
|
|
80
|
+
* @returns {string}
|
|
81
|
+
*/
|
|
82
|
+
export function normalizeBatchStatus(value) {
|
|
83
|
+
if (value === undefined || value === null || value === '') return BATCH_STATUS.OPEN
|
|
84
|
+
const status = String(value)
|
|
85
|
+
if (BATCH_STATUSES.includes(status)) return status
|
|
86
|
+
if (LEGACY_OPEN_BATCH_STATUSES.includes(status)) return BATCH_STATUS.OPEN
|
|
87
|
+
return status
|
|
88
|
+
}
|
|
89
|
+
|
|
36
90
|
|
|
37
91
|
/** Stable batch error; add `batch-invalid` to ERROR_CODES to map it directly. */
|
|
38
92
|
export class BatchError extends Error {
|
|
@@ -123,28 +177,13 @@ function normalizeReceiptPathOrNull(value, where) {
|
|
|
123
177
|
/** Legacy snapshots imported by adapters/legacy.js; only paused missions may be excluded. */
|
|
124
178
|
function pausedLegacyIds(state) {
|
|
125
179
|
const ids = new Set()
|
|
126
|
-
const collectMission = (mission) => {
|
|
127
|
-
if (isPlainObject(mission) && nonEmptyString(mission.id) && mission.status === 'paused') {
|
|
128
|
-
ids.add(mission.id.trim())
|
|
129
|
-
}
|
|
130
|
-
}
|
|
131
|
-
const collectList = (value) => {
|
|
132
|
-
if (Array.isArray(value)) value.forEach(collectMission)
|
|
133
|
-
else if (isPlainObject(value)) Object.values(value).forEach(collectMission)
|
|
134
|
-
}
|
|
135
180
|
const snapshots = Array.isArray(state.legacySnapshots) ? state.legacySnapshots : []
|
|
136
181
|
for (const snapshot of snapshots) {
|
|
137
182
|
if (!isPlainObject(snapshot)) continue
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
if (
|
|
141
|
-
|
|
142
|
-
// hand-written fixtures may use `pausedIds`.
|
|
143
|
-
for (const key of ['pausedIds', 'pausedMissionIds']) {
|
|
144
|
-
if (Array.isArray(snapshot[key])) {
|
|
145
|
-
for (const id of snapshot[key]) if (nonEmptyString(id)) ids.add(id.trim())
|
|
146
|
-
}
|
|
147
|
-
}
|
|
183
|
+
// #149 W4: `pausedMissionIds` is the one canonical field the legacy adapter
|
|
184
|
+
// writes. Reading any other shape here would be a second live vocabulary.
|
|
185
|
+
if (!Array.isArray(snapshot.pausedMissionIds)) continue
|
|
186
|
+
for (const id of snapshot.pausedMissionIds) if (nonEmptyString(id)) ids.add(id.trim())
|
|
148
187
|
}
|
|
149
188
|
return ids
|
|
150
189
|
}
|
|
@@ -153,82 +192,13 @@ function missionInOpenBatch(state, missionId) {
|
|
|
153
192
|
const batches = Array.isArray(state.batches) ? state.batches : []
|
|
154
193
|
for (const batch of batches) {
|
|
155
194
|
if (!isPlainObject(batch)) continue
|
|
156
|
-
const status = batch.status ??
|
|
157
|
-
if (
|
|
195
|
+
const status = normalizeBatchStatus(batch.status ?? BATCH_STATUS.OPEN)
|
|
196
|
+
if (isBatchTerminal(status)) continue
|
|
158
197
|
if (Array.isArray(batch.missionIds) && batch.missionIds.includes(missionId)) return batch
|
|
159
198
|
}
|
|
160
199
|
return null
|
|
161
200
|
}
|
|
162
201
|
|
|
163
|
-
function checkNames(entry) {
|
|
164
|
-
if (!isPlainObject(entry)) return []
|
|
165
|
-
for (const candidate of [entry.name, entry.id, entry.check]) {
|
|
166
|
-
if (nonEmptyString(candidate)) return [candidate.trim()]
|
|
167
|
-
}
|
|
168
|
-
// A nameless runDiscovery receipt stored in mission.testRuns is the built-in
|
|
169
|
-
// test tool evidence. It answers to `tests` (the tool-layer auto check name)
|
|
170
|
-
// and to `tower_test` (the synthetic name used by earlier integrations).
|
|
171
|
-
if (Array.isArray(entry.commands) && Array.isArray(entry.tests)) return ['tests', 'tower_test']
|
|
172
|
-
return []
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
function checkOutcome(entry) {
|
|
176
|
-
if (!isPlainObject(entry)) return null
|
|
177
|
-
if (typeof entry.status === 'string') {
|
|
178
|
-
const status = entry.status.trim().toLowerCase()
|
|
179
|
-
if (PASS_STATUSES.has(status)) return true
|
|
180
|
-
if (FAIL_STATUSES.has(status)) return false
|
|
181
|
-
}
|
|
182
|
-
if (typeof entry.ok === 'boolean') return entry.ok
|
|
183
|
-
if (typeof entry.pass === 'boolean') return entry.pass
|
|
184
|
-
if (typeof entry.success === 'boolean') return entry.success
|
|
185
|
-
if (entry.exitCode !== undefined && entry.exitCode !== null) {
|
|
186
|
-
const exitCode = typeof entry.exitCode === 'number' ? entry.exitCode : Number(entry.exitCode)
|
|
187
|
-
if (Number.isFinite(exitCode)) return exitCode === 0
|
|
188
|
-
}
|
|
189
|
-
if (entry.error !== undefined && entry.error !== null && entry.error !== '') return false
|
|
190
|
-
return null
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
/**
|
|
194
|
-
* Find the most authoritative recorded outcome for `name` on one mission:
|
|
195
|
-
* completion report first, then testRuns newest-first, then caller-supplied
|
|
196
|
-
* `extraChecks` (optionally scoped by `missionId`).
|
|
197
|
-
*/
|
|
198
|
-
function findCheck(mission, name, extraChecks, missionId) {
|
|
199
|
-
const sources = []
|
|
200
|
-
const reportChecks = Array.isArray(mission?.completionReport?.checks) ? mission.completionReport.checks : []
|
|
201
|
-
for (let index = reportChecks.length - 1; index >= 0; index -= 1) {
|
|
202
|
-
sources.push({ source: 'completionReport', entry: reportChecks[index] })
|
|
203
|
-
}
|
|
204
|
-
const testRuns = Array.isArray(mission?.testRuns) ? mission.testRuns : []
|
|
205
|
-
for (let index = testRuns.length - 1; index >= 0; index -= 1) {
|
|
206
|
-
const run = testRuns[index]
|
|
207
|
-
if (!isPlainObject(run)) continue
|
|
208
|
-
if (Array.isArray(run.checks) && run.checks.length > 0) {
|
|
209
|
-
for (let inner = run.checks.length - 1; inner >= 0; inner -= 1) {
|
|
210
|
-
sources.push({ source: 'testRuns', entry: run.checks[inner] })
|
|
211
|
-
}
|
|
212
|
-
} else {
|
|
213
|
-
sources.push({ source: 'testRuns', entry: run })
|
|
214
|
-
}
|
|
215
|
-
}
|
|
216
|
-
for (let index = extraChecks.length - 1; index >= 0; index -= 1) {
|
|
217
|
-
const entry = extraChecks[index]
|
|
218
|
-
if (!isPlainObject(entry)) continue
|
|
219
|
-
const scope = entry.missionId ?? entry.mission_id
|
|
220
|
-
if (scope !== undefined && scope !== null && String(scope) !== missionId) continue
|
|
221
|
-
sources.push({ source: 'input', entry })
|
|
222
|
-
}
|
|
223
|
-
for (const source of sources) {
|
|
224
|
-
if (!checkNames(source.entry).includes(name)) continue
|
|
225
|
-
const outcome = checkOutcome(source.entry)
|
|
226
|
-
if (outcome === null) continue
|
|
227
|
-
return { outcome, source: source.source, entry: source.entry }
|
|
228
|
-
}
|
|
229
|
-
return null
|
|
230
|
-
}
|
|
231
|
-
|
|
232
202
|
function blocker(message, code = 'batch-invalid') {
|
|
233
203
|
return { code, message }
|
|
234
204
|
}
|
|
@@ -414,9 +384,9 @@ export function validateClose(state, batch, input = {}) {
|
|
|
414
384
|
const receiptPaths = receiptPathsRaw.map((value, index) => normalizeReceiptPath(value, `batch.receiptPaths[${index}]`))
|
|
415
385
|
|
|
416
386
|
const blockers = []
|
|
417
|
-
const status = batch.status ??
|
|
418
|
-
if (status ===
|
|
419
|
-
else if (status ===
|
|
387
|
+
const status = normalizeBatchStatus(batch.status ?? BATCH_STATUS.OPEN)
|
|
388
|
+
if (status === BATCH_STATUS.CLOSED) blockers.push(blocker(`batch ${batch.id ?? ''} is already closed`.trim()))
|
|
389
|
+
else if (status === BATCH_STATUS.ABANDONED) blockers.push(blocker(`batch ${batch.id ?? ''} is abandoned`.trim()))
|
|
420
390
|
|
|
421
391
|
const missions = Array.isArray(state.missions) ? state.missions : fail('state.missions must be an array')
|
|
422
392
|
const byId = new Map()
|
|
@@ -447,13 +417,13 @@ export function validateClose(state, batch, input = {}) {
|
|
|
447
417
|
results.push({ missionId, name, ok: true, outcome: 'skipped', source: 'abandoned' })
|
|
448
418
|
continue
|
|
449
419
|
}
|
|
450
|
-
const found =
|
|
420
|
+
const found = findEvidence(shapeFor(mission.kind).evidence, mission, name, { extraChecks, missionId })
|
|
451
421
|
if (!found) {
|
|
452
422
|
results.push({ missionId, name, ok: false, outcome: 'missing', source: null })
|
|
453
423
|
blockers.push(blocker(`mission ${missionId} has no recorded check "${name}"`, 'tests-missing'))
|
|
454
424
|
continue
|
|
455
425
|
}
|
|
456
|
-
if (found.
|
|
426
|
+
if (found.ok !== true) {
|
|
457
427
|
results.push({ missionId, name, ok: false, outcome: 'failed', source: found.source })
|
|
458
428
|
blockers.push(blocker(`mission ${missionId} check "${name}" did not pass`, 'tests-failed'))
|
|
459
429
|
continue
|
|
@@ -507,14 +477,14 @@ export function summarize(state, batch) {
|
|
|
507
477
|
if (!Array.isArray(receiptPathsRaw)) fail('batch.receiptPaths must be an array')
|
|
508
478
|
const receiptPaths = receiptPathsRaw.map((value, index) => normalizeReceiptPath(value, `batch.receiptPaths[${index}]`))
|
|
509
479
|
const exclusions = Array.isArray(batch.exclusions) ? batch.exclusions : []
|
|
510
|
-
const status = batch.status ??
|
|
480
|
+
const status = normalizeBatchStatus(batch.status ?? BATCH_STATUS.OPEN)
|
|
511
481
|
|
|
512
482
|
const missions = Array.isArray(state.missions) ? state.missions : fail('state.missions must be an array')
|
|
513
483
|
const byId = new Map()
|
|
514
484
|
for (const mission of missions) if (isPlainObject(mission) && nonEmptyString(mission.id)) byId.set(mission.id, mission)
|
|
515
485
|
|
|
516
486
|
const blockers = []
|
|
517
|
-
if (
|
|
487
|
+
if (isBatchTerminal(status)) blockers.push(blocker(`batch ${batch.id ?? ''} is already ${status}`.trim()))
|
|
518
488
|
const members = []
|
|
519
489
|
const checks = []
|
|
520
490
|
for (const missionId of missionIds) {
|
|
@@ -539,7 +509,7 @@ export function summarize(state, batch) {
|
|
|
539
509
|
checks.push({ missionId, name, ok: true, outcome: 'skipped', source: 'abandoned' })
|
|
540
510
|
continue
|
|
541
511
|
}
|
|
542
|
-
const found =
|
|
512
|
+
const found = findEvidence(shapeFor(mission.kind).evidence, mission, name, { missionId })
|
|
543
513
|
if (!found) {
|
|
544
514
|
checks.push({ missionId, name, ok: false, outcome: 'missing', source: null })
|
|
545
515
|
continue
|
|
@@ -547,8 +517,8 @@ export function summarize(state, batch) {
|
|
|
547
517
|
checks.push({
|
|
548
518
|
missionId,
|
|
549
519
|
name,
|
|
550
|
-
ok: found.
|
|
551
|
-
outcome: found.
|
|
520
|
+
ok: found.ok === true,
|
|
521
|
+
outcome: found.ok === true ? 'passed' : 'failed',
|
|
552
522
|
source: found.source,
|
|
553
523
|
})
|
|
554
524
|
}
|
package/domain/collab/batch.js
CHANGED
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
// Pure batch lifecycle decisions and status projection.
|
|
2
2
|
//
|
|
3
|
-
// `domain/batch.js` owns the admission/close evidence
|
|
4
|
-
// owns the id assignment, close/abandon
|
|
5
|
-
// that app/batch.js used to keep inline.
|
|
6
|
-
// layer.
|
|
3
|
+
// `domain/batch.js` owns the lifecycle vocabulary, admission/close evidence
|
|
4
|
+
// validation and normalization; this module owns the id assignment, close/abandon
|
|
5
|
+
// record transitions, and status payload that app/batch.js used to keep inline.
|
|
6
|
+
// Receipt file reads stay in the tool layer. Every status move goes through the
|
|
7
|
+
// lifecycle module's `canTransitionBatch`, so this file never names a status
|
|
8
|
+
// literal of its own (#149 W3).
|
|
7
9
|
// @ts-check
|
|
8
10
|
import { TowerToolError } from '../errors.js'
|
|
9
|
-
import { summarize } from '../batch.js'
|
|
11
|
+
import { BATCH_STATUS, canTransitionBatch, isBatchTerminal, normalizeBatchStatus, summarize } from '../batch.js'
|
|
10
12
|
|
|
11
13
|
/** @typedef {import('../../types/index.js').TowerState} TowerState */
|
|
12
14
|
|
|
@@ -19,6 +21,21 @@ function fail(message, code = 'batch-invalid') {
|
|
|
19
21
|
throw new TowerToolError(code, message)
|
|
20
22
|
}
|
|
21
23
|
|
|
24
|
+
/**
|
|
25
|
+
* Move a draft batch to `to`, refusing a transition the lifecycle does not
|
|
26
|
+
* allow. A no-op (already at the target) is handled by the callers, not here.
|
|
27
|
+
* @param {any} batch
|
|
28
|
+
* @param {string} to
|
|
29
|
+
* @param {string} batchId
|
|
30
|
+
* @returns {void}
|
|
31
|
+
*/
|
|
32
|
+
function assertBatchMove(batch, to, batchId) {
|
|
33
|
+
const from = normalizeBatchStatus(batch.status)
|
|
34
|
+
if (!canTransitionBatch(from, to)) {
|
|
35
|
+
fail(`batch ${batchId} is ${from}; it cannot move to ${to}${isBatchTerminal(from) ? ' (a batch is closed or abandoned exactly once)' : ''}`)
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
22
39
|
/**
|
|
23
40
|
* Next free `B<n>` id.
|
|
24
41
|
* @param {any[]} batches
|
|
@@ -43,7 +60,7 @@ export function nextBatchId(batches) {
|
|
|
43
60
|
export function createBatchRecord(id, normalized, actor, now) {
|
|
44
61
|
return {
|
|
45
62
|
id,
|
|
46
|
-
status:
|
|
63
|
+
status: BATCH_STATUS.OPEN,
|
|
47
64
|
...normalized,
|
|
48
65
|
createdBy: actor,
|
|
49
66
|
createdAt: now,
|
|
@@ -59,7 +76,8 @@ export function createBatchRecord(id, normalized, actor, now) {
|
|
|
59
76
|
* @returns {any}
|
|
60
77
|
*/
|
|
61
78
|
export function closeBatchRecord(batch, options, actor, now) {
|
|
62
|
-
batch.
|
|
79
|
+
assertBatchMove(batch, BATCH_STATUS.CLOSED, String(batch.id ?? ''))
|
|
80
|
+
batch.status = BATCH_STATUS.CLOSED
|
|
63
81
|
batch.closedAt = now
|
|
64
82
|
batch.closedBy = actor
|
|
65
83
|
batch.close = {
|
|
@@ -78,8 +96,9 @@ export function closeBatchRecord(batch, options, actor, now) {
|
|
|
78
96
|
* @returns {{ alreadyAbandoned: boolean }}
|
|
79
97
|
*/
|
|
80
98
|
export function abandonBatchCheck(batch, batchId) {
|
|
81
|
-
|
|
82
|
-
|
|
99
|
+
const status = normalizeBatchStatus(batch.status)
|
|
100
|
+
if (status === BATCH_STATUS.CLOSED) fail(`batch ${batchId} is already closed`)
|
|
101
|
+
return { alreadyAbandoned: status === BATCH_STATUS.ABANDONED }
|
|
83
102
|
}
|
|
84
103
|
|
|
85
104
|
/**
|
|
@@ -88,7 +107,8 @@ export function abandonBatchCheck(batch, batchId) {
|
|
|
88
107
|
* @returns {any}
|
|
89
108
|
*/
|
|
90
109
|
export function applyBatchAbandon({ batch, actor, reason, now }) {
|
|
91
|
-
batch.
|
|
110
|
+
assertBatchMove(batch, BATCH_STATUS.ABANDONED, String(batch.id ?? ''))
|
|
111
|
+
batch.status = BATCH_STATUS.ABANDONED
|
|
92
112
|
batch.abandonedAt = now
|
|
93
113
|
batch.abandonedBy = actor
|
|
94
114
|
batch.abandonReason = typeof reason === 'string' && reason.trim() ? reason.trim() : null
|