dsh-tower 2.6.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.
Files changed (116) hide show
  1. package/CHANGELOG.md +82 -0
  2. package/COMPATIBILITY.md +1 -0
  3. package/CONTRIBUTING.md +1 -1
  4. package/README.en.md +2 -1
  5. package/README.md +6 -1
  6. package/adapters/leases.js +20 -6
  7. package/adapters/legacy.js +149 -37
  8. package/adapters/stateFile.js +11 -1
  9. package/app/archive.js +30 -5
  10. package/app/briefing.js +95 -12
  11. package/app/checkCompletion.js +107 -18
  12. package/app/enrichedMissions.js +1 -1
  13. package/app/freshness.js +77 -0
  14. package/app/gate.js +26 -12
  15. package/app/merge.js +7 -4
  16. package/app/mission.js +24 -1
  17. package/app/missionContext.js +2 -1
  18. package/app/plan.js +64 -1
  19. package/app/provision.js +133 -22
  20. package/app/read.js +38 -5
  21. package/app/rebase.js +10 -8
  22. package/app/review.js +97 -61
  23. package/app/reviewCheckout.js +133 -0
  24. package/app/sandbox.js +121 -0
  25. package/app/sharedCheckout.js +90 -0
  26. package/app/spawn-prechecks.js +25 -24
  27. package/app/spawn-support.js +59 -8
  28. package/app/spawn.js +3 -0
  29. package/app/teardown.js +119 -2
  30. package/app/tower.js +29 -1
  31. package/cordis.patch.yml +50 -0
  32. package/docs/adr/0006-mission-shapes.md +306 -0
  33. package/docs/architecture/overview.md +6 -4
  34. package/docs/guide/install-upgrade-migration.md +26 -0
  35. package/docs/operations/runbook.md +1 -1
  36. package/docs/reference/blockers.md +8 -4
  37. package/docs/reference/component-surface.md +7 -2
  38. package/docs/reference/config.generated.md +6 -4
  39. package/docs/reference/config.md +1 -2
  40. package/docs/reference/doctor.md +7 -7
  41. package/docs/reference/operations.md +1 -1
  42. package/docs/reference/shapes.md +123 -0
  43. package/docs/reference/surface-budget.md +33 -1
  44. package/docs/reference/testing.md +4 -2
  45. package/docs/reference/tools.generated.md +5 -5
  46. package/docs/reference/tools.md +2 -2
  47. package/docs/zh/reference/blockers.md +2 -2
  48. package/docs/zh/reference/config.md +1 -2
  49. package/docs/zh/reference/testing.md +3 -2
  50. package/domain/batch.js +75 -105
  51. package/domain/collab/batch.js +30 -10
  52. package/domain/collab/spawn.js +53 -16
  53. package/domain/config/schema.js +47 -17
  54. package/domain/contracts/outputs.js +0 -1
  55. package/domain/contracts/tool-inputs/missions.js +9 -1
  56. package/domain/evidence.js +283 -0
  57. package/domain/gate.js +11 -31
  58. package/domain/integration/gate.js +17 -43
  59. package/domain/integration/init.js +3 -2
  60. package/domain/integration/provision.js +51 -0
  61. package/domain/integration/rebase.js +16 -1
  62. package/domain/mission.js +16 -33
  63. package/domain/missions/completion.js +46 -8
  64. package/domain/missions/findings.js +2 -1
  65. package/domain/missions/lifecycle.js +131 -35
  66. package/domain/missions/plan.js +30 -6
  67. package/domain/missions/review.js +2 -18
  68. package/domain/planning.js +5 -5
  69. package/domain/read/missionContext.js +17 -3
  70. package/domain/read/views.js +5 -1
  71. package/domain/roles.js +222 -0
  72. package/domain/roster.js +9 -3
  73. package/domain/scope.js +51 -0
  74. package/domain/shapes/build.js +254 -0
  75. package/domain/shapes/index.js +171 -0
  76. package/domain/shapes/survey.js +445 -0
  77. package/domain/state/archive.js +28 -4
  78. package/domain/state/migrations.js +111 -4
  79. package/domain/state/schema.js +69 -7
  80. package/domain/state/semantics.js +93 -13
  81. package/domain/state.js +5 -1
  82. package/domain/summary.js +5 -11
  83. package/domain/vocabulary.js +15 -33
  84. package/host/capability-registry.js +25 -9
  85. package/host/doctor.js +2 -1
  86. package/host/preset-survey-sandbox.js +19 -0
  87. package/host/preset-survey.js +18 -0
  88. package/host/presets.js +8 -3
  89. package/host/tool-registry.js +14 -12
  90. package/locale/en.json +16 -1
  91. package/locale/preset-survey/en.json +6 -0
  92. package/locale/preset-survey/zh.json +6 -0
  93. package/locale/preset-survey-sandbox/en.json +6 -0
  94. package/locale/preset-survey-sandbox/zh.json +6 -0
  95. package/locale/zh.json +16 -1
  96. package/package.json +13 -2
  97. package/ports/state.js +8 -1
  98. package/scripts/architecture-waivers.json +3 -3
  99. package/scripts/check-docs.mjs +1 -1
  100. package/scripts/check-pack.mjs +2 -0
  101. package/scripts/component-surface.json +68 -20
  102. package/scripts/generate-capability-locale.mjs +5 -0
  103. package/scripts/generate-shapes-reference.mjs +171 -0
  104. package/scripts/mount-smoke/surface.mjs +3 -0
  105. package/scripts/pack-manifest.txt +21 -0
  106. package/scripts/shape-audit.mjs +361 -0
  107. package/scripts/surface-baseline.json +68 -4
  108. package/survey-sandbox.js +48 -0
  109. package/survey.js +40 -0
  110. package/tools/complete.js +5 -5
  111. package/tools/diff.js +5 -3
  112. package/tools/plan.js +1 -1
  113. package/tools/preflight.js +3 -2
  114. package/tools/spawn.js +47 -49
  115. package/tools/test.js +2 -1
  116. package/types/index.d.ts +66 -5
package/CHANGELOG.md CHANGED
@@ -4,6 +4,88 @@
4
4
 
5
5
  ## 未发布(Unreleased)
6
6
 
7
+ ## 2.7.0(2026-11-10)
8
+
9
+ ### 新增
10
+
11
+ - **#148 检查任务(survey)成为独立的只读任务形状**:`tower_plan` 接受 `kind: "survey"`,
12
+ 记录 `readCommit` 与 `readDirty`,不创建分支、工作树或提交;完成时要求 HEAD 与记录的提交一致、
13
+ 受版本控制的文件状态不变,并接受可选的结构化结果 `{paths, symbols, notes, plan}`。
14
+ `tower-survey` 是独立的可选组件行,随包提供只读 preset 与可选的 detached sandbox preset;
15
+ survey 不占用执行代理并发预算,也不会被要求合并或 rebase。完成的 survey 现在是终态,可归档、
16
+ 满足依赖、不再阻塞 `tower_init --reanchor`,也不再被规划为“未完成工作”。
17
+ - **#148 可写的 detached survey sandbox**:`survey.sandbox: "detached"` 为需要写文件的检查任务
18
+ 提供隔离工作树(无分支,HEAD 固定在 `readCommit`),完成后清理;只读检查仍使用共享检出。
19
+ - **#148 依赖的新鲜度与结果传递**:`survey-stale` 警告在依赖读取落后于当前基准时出现;
20
+ `gates.requireFreshSurvey: true` 可将它提升为合并阻塞。依赖 briefing 会带上 survey 的
21
+ `readCommit` 与有界结果(路径/符号/计划/备注)。
22
+ - **#149 形状注册表与维度审计机制**:`domain/shapes/{index,build,survey}.js` 成为任务生命周期的
23
+ 唯一来源;生成式 `docs/reference/shapes.md` 列出 16 个状态维度的裁决、拥有模块与机械规则;
24
+ `scripts/shape-audit.mjs` 的 12 条规则随 `npm run check` 运行,阻止新的 kind/role 特例与
25
+ 第二套状态词汇。
26
+ - **#149 单一证据读取器**:`domain/evidence.js` 统一读取完成检查、测试发现回执与调用方检查,
27
+ 并保留来源标签;`tower_complete` 不再复制合成 check,`tower_test` 只返回回执。
28
+ - **#149 旧版导入的严格适配器**:`adapters/legacy.js` 声明一种载荷对应一种格式,拒绝混合
29
+ mission id 命名空间,把 `unresolved` / `dropped` / `remapped` 如实写入摘要与快照记录;导入
30
+ 仍然只读,绝不激活或恢复任务。
31
+ - **#149 形状拥有路径权限**:`shape.reach` 声明 `ownsPaths` 与唯一围栏,`domain/scope.js` 的
32
+ `scopeFenceBlocker` 成为所有门禁、规划与工作树检查的共同来源。
33
+ - **#149 角色与形状正交**:`domain/roles.js` 的 `ROLE_CONTRACT` 统一解析 spawn、preset、roster、
34
+ briefing 与预算,不再出现“角色 ∧ 形状”条件;`reviewer` 仍只绑定完成的 build 任务。
35
+ - **#149 审查、批次、租约与阻塞状态的契约化**:reviewer 在记录 commit 的 detached checkout 上
36
+ 审查,裁决绑定该提交;batch 有显式的 `open → closed|abandoned` 生命周期;过期租约需要显式
37
+ `force` 才能被他人恢复;`blocked` 只由 blockers 派生,不能直接写入。
38
+
39
+ ### 变更
40
+
41
+ - **状态 schema 从 v3 直接迁移到 v5**:`state.json` 在首次写入时自动执行 v3 → v4 → v5 迁移,
42
+ 保留预迁移备份并只推进一次 `revision`。v4 为 survey 增加 `readCommit`/`readDirty` 字段、
43
+ 移除 worktree/branch/baseCommit/merge 字段;v5 从 blockers 派生 `blocked` 并规范化 batch 状态。
44
+ 只读加载仍在内存中迁移,不回写文件;计划但从未启动的旧 survey 会用 `state.base` 锚定。
45
+ - **配置面 57 → 56 键**:移除 `tests.autoChecks`。旧配置会收到带迁移说明的
46
+ `config-unknown-key`,指向 `tower_test` 的 `mission.testRuns` 回执与 `domain/evidence.js`;
47
+ 没有替代开关。可见组件仍为 12 个(内部 4 个),工具仍为 29 个,preset 增至 4 个。
48
+ - **直接写 `blocked` 状态被拒绝**:`tower_mission` 不再接受 `status: "blocked"`;使用
49
+ `blocker` 报告阻塞,使用 `clear_blockers` 解除。`paused` 与 `blocked` 不再重叠。
50
+ - **survey 的合并/变基/预检/差异动作被明确拒绝**:`tower_merge`、`tower_rebase`、
51
+ `tower_preflight` 与 `tower_diff` 对 survey 返回可操作的 `invalid-transition`,而不是
52
+ 合成 `tip-moved`/`no-review` 等 build 语义。
53
+ - **PR 模板新增形状/角色问题**,并要求说明是否新增了应属于 shape 的条件;生成的形状参考
54
+ 与 ADR 0006 同步更新。
55
+
56
+ - **#148/#149 形状注册表 W4:证据、旧版导入、scope 与角色的一次性契约。** `domain/evidence.js`
57
+ 成为测试证据的唯一读取者:测试发现回执记录在 `mission.testRuns` 中并按分支 tip 绑定,
58
+ `tower_complete` 不再把同一证据复制成合成的 `tests` check,门禁、`tower_batch` 与 completion
59
+ 校验都走同一个读取器。删除配置键 `tests.autoChecks`(写入旧键会收到带迁移说明的
60
+ `config-unknown-key`),配置键 57 → 56。`adapters/legacy.js` 改为严格适配器契约:声明格式与
61
+ 状态重映射(Tower Codex `retired` → `abandoned`)、拒绝混合 mission id 命名空间、把
62
+ `unresolved` / `dropped` / `remapped` 如实写入摘要与快照记录,并只向消费方暴露规范的
63
+ `pausedMissionIds`。shape 以 `reach` 拥有路径权限,`domain/scope.js` 的 `scopeFenceBlocker`
64
+ 是唯一围栏;角色与 shape 正交,`domain/roles.js` 的 `ROLE_CONTRACT` 让 spawn 预检、preset、
65
+ roster 与 gate 各自解析,不再出现"角色 ∧ shape"条件。`shape:audit` 新增四条规则
66
+ `evidence-single-read`、`legacy-vocabulary`、`scope-reach`、`role-shape-conflation`。
67
+ - **#149 生成的形状参考改为维度清单。** `docs/reference/shapes.md` 现在按 16 个维度列出裁决
68
+ (shape-owned / correctly generic / redesigned)、拥有模块与机械规则;W1–W4 的裁决同时记录在
69
+ ADR 0006 的 W4 附录中。
70
+
71
+ ### 修复
72
+
73
+ - **完成的 survey 不再死锁管理动作**:`tower_init --reanchor` 不再因一个完成的 survey 被拒绝;
74
+ survey 可归档并计入 `archive.maxMissions`;规划不再把它算作未完成工作。
75
+ - **survey 完成可正常确认收件箱**:只读与 sandbox preset 现在包含 `tower_inbox`,避免存在未读
76
+ 消息时完成被 `completion.js` 拒绝。
77
+ - **旧版计划中的 survey 可迁移**:当 v2.6.0 的 survey 只有 `kind`、没有 baseCommit/worktree 时,
78
+ 迁移用 `state.base` 与真实 tracked fingerprint 锚定,不再让每次加载都以
79
+ `state-migration-failed` 失败。
80
+ - **detached sandbox 的 briefing 使用 sandbox 路径**:不再把 `workdir` 指向主检出,并把空 scope
81
+ 说明改为“可写 sandbox”而非“只读检查”。
82
+ - **归档 survey 的依赖证据不再丢失**:归档索引保留有界的 `readCommit` 与结果;依赖 briefing 与
83
+ `requireFreshSurvey` 在归档后仍能判断新鲜度,不会因缺少证据而失败开放。
84
+ - **已记录的 sandbox 决定完成路径**:`mission.sandbox` 优先于当前配置,避免运行中把 detached 改回
85
+ `none` 时错误删除 sandbox 或切换完成规则。
86
+ - **review checkout 的陈旧路径可恢复**,teardown 会清理残留的审查检出;survey 不占 build 执行
87
+ 代理预算。
88
+
7
89
  ## 2.6.0(2026-11-01)
8
90
 
9
91
  ### 新增
package/COMPATIBILITY.md CHANGED
@@ -5,6 +5,7 @@ is shipped in the npm package; doctor reads it locally and never contacts a regi
5
5
 
6
6
  | dsh version | tag / commit | verified on | what was run |
7
7
  |---|---|---|---|
8
+ | 0.2.1-rc.2 | v2.7.0 | 2026-11-10 | mount smoke (28/28 default tools, 3 skills, 4 presets with distinct labels, 1 bundle row), `npm run test:property` (111), full static gate chain, focused shape/survey/evidence/scope/role/review/batch/leases tests, CI `test (22)`, `test (24)`, `test (22, windows)`, `mount`, `mount-fixtures` |
8
9
  | 0.2.1-rc.2 | v2.6.0 | 2026-11-01 | mount smoke (28/28 default tools, 3 skills, 2 presets with distinct labels, 1 bundle row), `npm test` (729), `npm run test:property` (111), `npm run check` (docs + generated + component + surface + pack 294 + types + architecture + domain), `npm run smoke:mount` |
9
10
  | 0.2.1-rc.2 | v2.5.0 | 2026-11-01 | mount smoke (28/28 default tools, 3 skills, 2 presets with distinct labels, 1 bundle row), `npm test` (696), `npm run test:property` (111), `npm run check` (docs + generated + component + surface + pack 293 + types + architecture + domain), `npm run release:check -- --mode tag --tag v2.5.0` |
10
11
  | 0.2.1-rc.2 | v2.4.2 | 2026-10-24 | mount smoke (28/28 default tools, 3 skills, 2 presets with distinct labels, 1 bundle row), `npm test` (631), docs + seven generated checks, types coverage 148/148, components + philosophy + surface gates |
package/CONTRIBUTING.md CHANGED
@@ -126,7 +126,7 @@ the push gates; CI schedules it nightly.
126
126
 
127
127
  ## Pull request checklist
128
128
 
129
- 1. `npm test` passes locally (729 tests today, including protocol end-to-end against a real temporary Git repository and mock spawn coverage), `npm run test:property` passes (111 generated property cases; also part of `npm run check`), and `npm run docs:check` passes.
129
+ 1. `npm test` passes locally (786 tests today, 785 run + 1 platform skip, including protocol end-to-end against a real temporary Git repository and mock spawn coverage), `npm run test:property` passes (111 generated property cases; also part of `npm run check`), and `npm run docs:check` passes.
130
130
  2. New behavior has a test. Bug fixes should add a test that fails before the fix.
131
131
  3. No secrets, tokens, personal paths, session logs, or generated artifacts are added. `.gitignore` covers local acceptance output; do not force-add it.
132
132
  4. Keep the public API and state-file formats compatible; if a change is breaking, call it out in `CHANGELOG.md` and bump the version.
package/README.en.md CHANGED
@@ -16,6 +16,7 @@ The repository root **is** the DSH bundle: `package.json` declares `dsh.bundle.p
16
16
 
17
17
  - **Configurable 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. By default the model may also enter/leave with `tower_control` and a concrete reason; set `mode.allowAgentControl:false` for human-only control.
18
18
  - **Missions with real isolation.** Every mission gets its own branch and Git worktree; workers never touch your checkout.
19
+ - **Two mission shapes, one registry.** Every mission is a `build` or a `survey`, and the shape owns its statuses, completion evidence, provisioning, path reach and spawn contract instead of scattering `if (kind === …)` branches. Surveys are read-only: they pin a `readCommit` (by default in a detached sandbox) and complete without commits, a branch or a merge. `npm run shape:audit` fails a second vocabulary, a per-kind conditional outside `domain/shapes/`, an evidence read outside `domain/evidence.js`, and a role check combined with a shape check.
19
20
  - **Review-gated merges.** The review policy is one tri-state control on the plugin page: `off`, `optional` (the default) or `required` (`review.mode` in config). With `required`, a reviewer bound to the current branch tip must submit a `clean` / `merge` verdict before merge; the merge gate re-checks scope, staleness, task completion, review history, and worktree ownership before a `--no-ff` merge. Independent review is optional by default; use `reviewPolicy:"none"` + reason plus `gates.allowReviewExemption:true` for a single mission when the policy is required. Multi-route review is recorded per round: while a non-clean round keeps the mission `active`, a later reviewer may still submit its verdict for the same reviewed tip and it appends to that round. A completed `survey` dependency satisfies the dependency gate (a build dependency still must be merged). Upgrade note: the old boolean review toggle is a one-release alias; the mapping is in [install, upgrade and migration](docs/guide/install-upgrade-migration.md).
20
21
  - **Durable coordination.** Roster, inbox, findings, reviews, and mission state are persisted under `.git/dsh-tower/`, so a restart does not lose the plan.
21
22
  - **Bounded state growth.** Terminal missions whose worktree is gone move to `.git/dsh-tower/archive/` once `archive.afterDays`/`archive.maxMissions` says so, keeping their tasks, reviews, findings and completion report; mutations then rewrite only active work. `tower_status includeArchived` lists compact archived summaries, and `tower_doctor` reports archive size, count and policy.
@@ -196,7 +197,7 @@ The list above is the 22 core tools plus 5 of the component-owned optional tools
196
197
  - **Worktree ownership.** Dirty worktrees, a running worker, or a branch owned by a closed mission block teardown/merge.
197
198
  - **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.
198
199
 
199
- 32 stable blocker/failure codes (including gate guards, `no-check-evidence`, `worker-stale`, test gates, and merge/rebase operation failures) — the complete list with triggers and fixes is in [docs/reference/blockers.md](docs/reference/blockers.md). Tool-level error codes are in [docs/reference/errors.md](docs/reference/errors.md); events are in [docs/reference/events.md](docs/reference/events.md).
200
+ 31 stable blocker/failure codes (including gate guards, `no-check-evidence`, `worker-stale`, test gates, and merge/rebase operation failures) — the complete list with triggers and fixes is in [docs/reference/blockers.md](docs/reference/blockers.md). Tool-level error codes are in [docs/reference/errors.md](docs/reference/errors.md); events are in [docs/reference/events.md](docs/reference/events.md).
200
201
 
201
202
  ## How it works
202
203
 
package/README.md CHANGED
@@ -16,6 +16,11 @@ Tower 模式与 mission/审查工作流的灵感来自 Moonshot AI 的 **Kimi Co
16
16
 
17
17
  - **可配置的 Tower 模式。** `/tower` 切换会话级模式:收窄工具面并注入 Tower 协议;`/tower on|off <指令>` 还能把指令作为下一步用户消息转发给模型。默认也允许 Agent 通过 `tower_control` 带理由进出;设 `mode.allowAgentControl:false` 可改为仅人类控制。
18
18
  - **任务级隔离。** 每个 mission 使用独立分支和 Git worktree,worker 不会碰到你的主检出。
19
+ - **两种 mission 形状,一个注册表。** 每个 mission 是 `build` 或 `survey`,其状态、完成证据、
20
+ 供给方式、路径权限与 spawn 契约都由 shape 拥有,不再散落 `if (kind === …)` 分支。survey 只读:
21
+ 固定一个 `readCommit`(默认在 detached sandbox 中)即可完成,不需要提交、分支或合并。
22
+ `npm run shape:audit` 会拒绝第二套词汇、`domain/shapes/` 之外的按 kind 条件、
23
+ `domain/evidence.js` 之外的证据读取,以及角色检查与 shape 检查混合的写法。
19
24
  - **审查门禁合并。** 审查策略是插件页上的一个三态控件:`off`、`optional`(默认)或 `required`(配置键 `review.mode`)。设为 `required` 时,审查者针对当前分支 tip 给出 `clean` / `merge` 结论;合并门禁重新校验 scope、分支新鲜度、任务完成度、审查记录与 worktree 归属,通过后才执行 `--no-ff` 合并。独立审查默认可选;策略为 `required` 时,单个 mission 可用 `reviewPolicy:"none"` + 理由并开启 `gates.allowReviewExemption:true` 做更窄豁免。升级提示:旧的布尔审查开关是保留一个版本的别名;映射关系见[安装、升级与迁移](docs/zh/guide/install-upgrade-migration.md)。
20
25
  - **可恢复的协作状态。** roster、inbox、findings、reviews 与 mission 状态持久化在 `.git/dsh-tower/` 下,重启不会丢计划。
21
26
  - **受控的状态增长。** worktree 已移除的终态任务在达到 `archive.afterDays`/`archive.maxMissions` 后移入 `.git/dsh-tower/archive/`,保留 tasks、reviews、findings 与完成报告;此后 mutation 只重写活跃工作。`tower_status includeArchived` 列出压缩后的归档摘要,`tower_doctor` 报告归档大小、数量与策略。
@@ -195,7 +200,7 @@ reviewer 行,策略为 `required` 时会以带解释的 `preset-missing` 锁
195
200
  - **worktree 归属。** 脏工作树、运行中的 worker、已关闭 mission 的分支都会阻止合并或 teardown。
196
201
  - **goal 隔离。** 进入 Tower 模式会暂停活跃 goal 并记录;退出不会自动恢复,需要你自行恢复。
197
202
 
198
- 32 个稳定 blocker/failure code(含 gate 守卫、`no-check-evidence`、worker-stale、tests 门禁与 merge/rebase 操作失败码)——完整清单、触发条件与修复动作见 [docs/reference/blockers.md](docs/reference/blockers.md)。工具本身抛出的错误码见 [docs/reference/errors.md](docs/reference/errors.md),事件见 [docs/reference/events.md](docs/reference/events.md)。
203
+ 31 个稳定 blocker/failure code(含 gate 守卫、`no-check-evidence`、worker-stale、tests 门禁与 merge/rebase 操作失败码)——完整清单、触发条件与修复动作见 [docs/reference/blockers.md](docs/reference/blockers.md)。工具本身抛出的错误码见 [docs/reference/errors.md](docs/reference/errors.md),事件见 [docs/reference/events.md](docs/reference/events.md)。
199
204
 
200
205
  ## 工作原理
201
206
 
@@ -247,7 +247,7 @@ export async function acquireLease(commonDir, options = {}) {
247
247
  'lease-busy',
248
248
  `lease "${name}" is held by ${existing.holder} (pid ${existing.pid}) until ${expiresAt}` +
249
249
  (expired
250
- ? `; the lease expired at ${expiresAt} but is not taken over automatically — its holder must release it explicitly (force: true)`
250
+ ? `; the lease expired at ${expiresAt} but is not taken over automatically — an explicit tower_resource release with force: true recovers it`
251
251
  : ''),
252
252
  )
253
253
  }
@@ -269,9 +269,12 @@ export async function acquireLease(commonDir, options = {}) {
269
269
  }
270
270
 
271
271
  /**
272
- * Release a lease. The token must match its holder; an expired lease can only
273
- * be released with an explicit `force: true`. Returns `true` when removed and
274
- * `false` when no such lease exists.
272
+ * Release a lease. A live lease can only be released by the holder that owns
273
+ * its token. An expired lease is recoverable: the holder may still release it
274
+ * with the original token, and `force: true` lets the tower take over an
275
+ * EXPIRED lease whose holder crashed — that is explicit recovery, never silent
276
+ * takeover, and a live lease always refuses a foreign token. Returns `true` when
277
+ * removed and `false` when no such lease exists.
275
278
  */
276
279
  export async function releaseLease(commonDir, options = {}) {
277
280
  const name = options?.name
@@ -295,10 +298,21 @@ export async function releaseLease(commonDir, options = {}) {
295
298
  const { doc } = await readLeaseDocument(commonDir)
296
299
  const existing = doc.leases[name]
297
300
  if (!existing) return false
301
+ const expired = now >= existing.expiresAt
298
302
  if (existing.token !== token) {
299
- fail('lease-token', `lease "${name}" token does not match its holder; refusing release`)
303
+ if (expired && force) {
304
+ // Explicit recovery of a crashed holder: only an expired lease and only
305
+ // with `force`. Live leases below still refuse a foreign token.
306
+ } else {
307
+ fail(
308
+ 'lease-token',
309
+ expired
310
+ ? `lease "${name}" is held by ${existing.holder} (pid ${existing.pid}) and expired at ${new Date(existing.expiresAt).toISOString()}; the token does not match — pass force: true to take over the expired lease`
311
+ : `lease "${name}" is held by ${existing.holder} (pid ${existing.pid}) until ${new Date(existing.expiresAt).toISOString()}; a live lease can only be released by its holder`,
312
+ )
313
+ }
300
314
  }
301
- if (!force && now >= existing.expiresAt) {
315
+ if (!force && expired) {
302
316
  fail(
303
317
  'lease-busy',
304
318
  `lease "${name}" expired at ${new Date(existing.expiresAt).toISOString()}; release it explicitly with force: true`,
@@ -12,16 +12,48 @@
12
12
  import { fsp } from './store-fs.js'
13
13
  import path from 'node:path'
14
14
  import crypto from 'node:crypto'
15
+ import { isShapeKind } from '../domain/shapes/index.js'
15
16
 
16
17
  const SCHEMA_VERSION = 1
17
18
  const MAX_SNAPSHOT_BYTES = 16 * 1024 * 1024
18
19
  const DSH_STATUSES = ['planned', 'active', 'completed', 'blocked', 'paused', 'merged', 'abandoned']
19
- // Tower Codex also retires missions; retired is read-only history here.
20
+ // Tower Codex also retires missions; `retired` is accepted input vocabulary and
21
+ // is remapped below. It is never a DSH mission status.
20
22
  const CODEX_STATUSES = [...DSH_STATUSES, 'retired']
21
23
  const DSH_ID_RE = /^M\d+$/
22
24
  const CODEX_ID_RE = /^T\d{4,}$/
23
25
  const SHA256_RE = /^[a-f0-9]{64}$/
24
26
 
27
+ // The strict adapter contract (#149 W4). One payload is exactly one legacy
28
+ // format; every status and field alias this module accepts is declared here.
29
+ // Missions are normalised to the shape registry's DSH vocabulary: a status
30
+ // remap is applied and reported in `remapped`, never silently invented, and a
31
+ // value the adapter cannot carry is reported in `unresolved`/`dropped`.
32
+ export const LEGACY_FORMATS = Object.freeze({
33
+ 'tower-codex': Object.freeze({
34
+ idPattern: CODEX_ID_RE,
35
+ idHint: 'T####',
36
+ statuses: CODEX_STATUSES,
37
+ statusRemap: Object.freeze({ retired: 'abandoned' }),
38
+ baseBranchField: 'baseBranch',
39
+ requiresBaseBranch: true,
40
+ }),
41
+ dsh: Object.freeze({
42
+ idPattern: DSH_ID_RE,
43
+ idHint: 'M#',
44
+ statuses: DSH_STATUSES,
45
+ statusRemap: Object.freeze({}),
46
+ baseBranchField: 'base',
47
+ requiresBaseBranch: false,
48
+ }),
49
+ })
50
+
51
+ // Only these fields cross the adapter boundary. Everything else a legacy
52
+ // mission carries is validated for safety, then reported as dropped: the
53
+ // adapter deliberately does not invent DSH semantics for it.
54
+ const CARRIED_MISSION_FIELDS = Object.freeze(['id', 'status', 'kind'])
55
+ const DROPPED_REASON = 'validated for safety but not carried into the summary; the exact payload bytes remain in the stored snapshot'
56
+
25
57
  function fail(code, message) {
26
58
  /** @type {Error & {code?: string}} */
27
59
  const error = new Error(message)
@@ -86,6 +118,9 @@ function invalidResult(error, format = null) {
86
118
  missionIds: [],
87
119
  missionCount: 0,
88
120
  paused: [],
121
+ unresolved: [],
122
+ dropped: [],
123
+ remapped: [],
89
124
  events: null,
90
125
  legacySnapshots: null,
91
126
  repoRoot: null,
@@ -140,31 +175,89 @@ function validateReviews(reviews, id) {
140
175
  return null
141
176
  }
142
177
 
178
+ /**
179
+ * Detect one legacy format per payload. A declared `format` is authoritative;
180
+ * otherwise every mission id must belong to exactly one namespace. Empty
181
+ * mission lists use the top-level shape (a Tower Codex snapshot has a
182
+ * baseBranch; DSH state uses `base`).
183
+ * @param {any} payload
184
+ * @returns {{ format: 'tower-codex'|'dsh'|null, error: string|null }}
185
+ */
186
+ function detectFormat(payload) {
187
+ if (payload.format !== undefined) {
188
+ if (typeof payload.format !== 'string' || !Object.prototype.hasOwnProperty.call(LEGACY_FORMATS, payload.format)) {
189
+ return { format: null, error: `unsupported legacy format ${JSON.stringify(payload.format)} (expected tower-codex or dsh)` }
190
+ }
191
+ return { format: payload.format, error: null }
192
+ }
193
+ if (payload.missions.length === 0) {
194
+ return { format: isNonEmptyString(payload.baseBranch) ? 'tower-codex' : 'dsh', error: null }
195
+ }
196
+ let codex = 0
197
+ let dsh = 0
198
+ for (const mission of payload.missions) {
199
+ const id = mission?.id
200
+ if (typeof id === 'string' && CODEX_ID_RE.test(id)) codex += 1
201
+ else if (typeof id === 'string' && DSH_ID_RE.test(id)) dsh += 1
202
+ }
203
+ if (codex === payload.missions.length) return { format: 'tower-codex', error: null }
204
+ if (dsh === payload.missions.length) return { format: 'dsh', error: null }
205
+ for (const mission of payload.missions) {
206
+ const id = mission?.id
207
+ if (typeof id !== 'string' || (!CODEX_ID_RE.test(id) && !DSH_ID_RE.test(id))) {
208
+ return {
209
+ format: null,
210
+ error: `unsupported legacy mission id ${JSON.stringify(id)} (expected T#### for Tower Codex or M# for DSH)`,
211
+ }
212
+ }
213
+ }
214
+ return { format: null, error: 'legacy snapshot mixes mission id namespaces (T#### and M#); one snapshot is one format' }
215
+ }
216
+
143
217
  /**
144
218
  * @param {any} payload
145
219
  * @param {'tower-codex'|'dsh'} format
146
- * @returns {{error:string|null, missions:Array<{id:string,status:string,kind:string}>, missionIds:string[], paused:string[]}}
220
+ * @param {{ idPattern: RegExp, idHint: string, statuses: string[], statusRemap: Record<string, string>, baseBranchField: string, requiresBaseBranch: boolean }} spec
221
+ * @returns {{error:string|null, missions:Array<{id:string,status:string,kind:string}>, missionIds:string[], paused:string[],
222
+ * unresolved:Array<{missionId:string, field:string, reason:string}>,
223
+ * dropped:Array<{field:string, reason:string}>,
224
+ * remapped:Array<{missionId:string, field:string, from:string, to:string}>}}
147
225
  */
148
- function validateMissions(payload, format) {
149
- const statuses = format === 'tower-codex' ? CODEX_STATUSES : DSH_STATUSES
150
- const idRe = format === 'tower-codex' ? CODEX_ID_RE : DSH_ID_RE
226
+ function validateMissions(payload, format, spec) {
151
227
  const missions = []
152
228
  const missionIds = []
153
229
  const paused = []
230
+ const unresolved = []
231
+ const remapped = []
232
+ const droppedFields = new Set()
154
233
  const seen = new Set()
155
234
  for (let index = 0; index < payload.missions.length; index += 1) {
156
235
  const mission = payload.missions[index]
157
- if (!isPlainObject(mission)) return { error: `missions[${index}] must be an object`, missions: [], missionIds: [], paused: [] }
158
- if (typeof mission.id !== 'string' || !idRe.test(mission.id)) {
159
- return { error: `missions[${index}] has unsupported id ${JSON.stringify(mission.id)} (expected ${format === 'tower-codex' ? 'T####' : 'M#'})`, missions: [], missionIds: [], paused: [] }
236
+ if (!isPlainObject(mission)) return { error: `missions[${index}] must be an object`, missions: [], missionIds: [], paused: [], unresolved: [], dropped: [], remapped: [] }
237
+ if (typeof mission.id !== 'string' || !spec.idPattern.test(mission.id)) {
238
+ return { error: `missions[${index}] has unsupported id ${JSON.stringify(mission.id)} (expected ${spec.idHint})`, missions: [], missionIds: [], paused: [], unresolved: [], dropped: [], remapped: [] }
160
239
  }
161
- if (seen.has(mission.id)) return { error: `duplicate legacy mission id ${mission.id}`, missions: [], missionIds: [], paused: [] }
240
+ if (seen.has(mission.id)) return { error: `duplicate legacy mission id ${mission.id}`, missions: [], missionIds: [], paused: [], unresolved: [], dropped: [], remapped: [] }
162
241
  seen.add(mission.id)
163
- if (typeof mission.status !== 'string' || !statuses.includes(mission.status)) {
164
- return { error: `mission ${mission.id} has unknown status ${JSON.stringify(mission.status)}`, missions: [], missionIds: [], paused: [] }
242
+ if (typeof mission.status !== 'string' || !spec.statuses.includes(mission.status)) {
243
+ return { error: `mission ${mission.id} has unknown status ${JSON.stringify(mission.status)}`, missions: [], missionIds: [], paused: [], unresolved: [], dropped: [], remapped: [] }
244
+ }
245
+ const mappedStatus = spec.statusRemap[mission.status]
246
+ if (mappedStatus !== undefined) {
247
+ remapped.push({ missionId: mission.id, field: 'status', from: mission.status, to: mappedStatus })
248
+ }
249
+ const status = mappedStatus ?? mission.status
250
+ if (!DSH_STATUSES.includes(status)) {
251
+ return { error: `legacy status ${JSON.stringify(mission.status)} has no declared DSH mapping`, missions: [], missionIds: [], paused: [], unresolved: [], dropped: [], remapped: [] }
165
252
  }
166
- if (mission.kind !== undefined && mission.kind !== 'build' && mission.kind !== 'survey') {
167
- return { error: `mission ${mission.id} has unknown kind ${JSON.stringify(mission.kind)}`, missions: [], missionIds: [], paused: [] }
253
+ let kind = 'build'
254
+ if (mission.kind !== undefined) {
255
+ if (!isShapeKind(mission.kind)) {
256
+ return { error: `mission ${mission.id} has unknown kind ${JSON.stringify(mission.kind)}`, missions: [], missionIds: [], paused: [], unresolved: [], dropped: [], remapped: [] }
257
+ }
258
+ kind = mission.kind
259
+ } else {
260
+ unresolved.push({ missionId: mission.id, field: 'kind', reason: 'legacy mission has no kind; assumed build' })
168
261
  }
169
262
  for (const problem of [
170
263
  validateScope(mission.scope, mission.id),
@@ -172,13 +265,24 @@ function validateMissions(payload, format) {
172
265
  validateDeps(mission.deps, mission.id),
173
266
  validateReviews(mission.reviews, mission.id),
174
267
  ]) {
175
- if (problem) return { error: problem, missions: [], missionIds: [], paused: [] }
268
+ if (problem) return { error: problem, missions: [], missionIds: [], paused: [], unresolved: [], dropped: [], remapped: [] }
176
269
  }
177
- missions.push({ id: mission.id, status: mission.status, kind: mission.kind ?? 'build' })
270
+ for (const key of Object.keys(mission)) {
271
+ if (!CARRIED_MISSION_FIELDS.includes(key)) droppedFields.add(key)
272
+ }
273
+ missions.push({ id: mission.id, status, kind })
178
274
  missionIds.push(mission.id)
179
- if (mission.status === 'paused') paused.push(mission.id)
275
+ if (status === 'paused') paused.push(mission.id)
276
+ }
277
+ return {
278
+ missions,
279
+ missionIds,
280
+ paused,
281
+ unresolved,
282
+ dropped: [...droppedFields].sort().map((field) => ({ field, reason: DROPPED_REASON })),
283
+ remapped,
284
+ error: null,
180
285
  }
181
- return { missions, missionIds, paused, error: null }
182
286
  }
183
287
 
184
288
  /**
@@ -189,9 +293,12 @@ function validateMissions(payload, format) {
189
293
  *
190
294
  * @returns {{ok:boolean, format:'tower-codex'|'dsh'|null, schemaVersion:number|null,
191
295
  * missions:Array<{id:string,status:string,kind:string}>, missionIds:string[],
192
- * missionCount:number, paused:string[], events:number|null,
193
- * legacySnapshots:number|null, repoRoot:string|null, baseBranch:string|null,
194
- * error:string|null}}
296
+ * missionCount:number, paused:string[],
297
+ * unresolved:Array<{missionId:string, field:string, reason:string}>,
298
+ * dropped:Array<{field:string, reason:string}>,
299
+ * remapped:Array<{missionId:string, field:string, from:string, to:string}>,
300
+ * events:number|null, legacySnapshots:number|null, repoRoot:string|null,
301
+ * baseBranch:string|null, error:string|null}}
195
302
  */
196
303
  export function validateLegacySnapshot(payload) {
197
304
  if (!isPlainObject(payload)) return invalidResult('legacy snapshot must be a JSON object')
@@ -204,30 +311,26 @@ export function validateLegacySnapshot(payload) {
204
311
  }
205
312
  if (!Array.isArray(payload.missions)) return invalidResult('legacy snapshot has no missions array')
206
313
 
207
- /** @type {'tower-codex'|'dsh'} */
208
- let format = 'dsh'
209
- if (payload.missions.length === 0) {
210
- format = isNonEmptyString(payload.baseBranch) ? 'tower-codex' : 'dsh'
211
- } else {
212
- const firstId = payload.missions[0]?.id
213
- if (typeof firstId === 'string' && CODEX_ID_RE.test(firstId)) format = 'tower-codex'
214
- else if (typeof firstId === 'string' && DSH_ID_RE.test(firstId)) format = 'dsh'
215
- else {
216
- return invalidResult(
217
- `unsupported legacy mission id ${JSON.stringify(firstId)} (expected T#### for Tower Codex or M# for DSH)`,
218
- )
219
- }
314
+ const detected = detectFormat(payload)
315
+ if (detected.error || !detected.format) {
316
+ return invalidResult(detected.error ?? 'legacy snapshot format could not be determined')
220
317
  }
318
+ const format = detected.format
319
+ const spec = LEGACY_FORMATS[format]
221
320
 
222
321
  const rejected = (error) => invalidResult(error, format)
223
- const validated = validateMissions(payload, format)
322
+ const validated = validateMissions(payload, format, spec)
224
323
  if (validated.error) return rejected(validated.error)
225
324
 
226
325
  if (isNonEmptyString(payload.repoRoot) === false) {
227
326
  return rejected(`legacy ${format} snapshot has no repoRoot string`)
228
327
  }
229
- if (format === 'tower-codex' && isNonEmptyString(payload.baseBranch) === false) {
230
- return rejected('legacy tower-codex snapshot has no baseBranch string')
328
+ if (spec.requiresBaseBranch && isNonEmptyString(payload[spec.baseBranchField]) === false) {
329
+ return rejected(`legacy ${format} snapshot has no baseBranch string`)
330
+ }
331
+ const baseBranchValue = payload[spec.baseBranchField]
332
+ if (baseBranchValue !== undefined && baseBranchValue !== null && !isNonEmptyString(baseBranchValue)) {
333
+ return rejected(`legacy ${format} snapshot has no baseBranch string`)
231
334
  }
232
335
  if (payload.events !== undefined && !Array.isArray(payload.events)) {
233
336
  return rejected('legacy snapshot events must be an array')
@@ -247,10 +350,13 @@ export function validateLegacySnapshot(payload) {
247
350
  missionIds: validated.missionIds,
248
351
  missionCount: validated.missionIds.length,
249
352
  paused: validated.paused,
353
+ unresolved: validated.unresolved,
354
+ dropped: validated.dropped,
355
+ remapped: validated.remapped,
250
356
  events: Array.isArray(payload.events) ? payload.events.length : null,
251
357
  legacySnapshots: Array.isArray(payload.legacySnapshots) ? payload.legacySnapshots.length : null,
252
358
  repoRoot: payload.repoRoot.trim(),
253
- baseBranch: format === 'tower-codex' ? payload.baseBranch.trim() : (isNonEmptyString(payload.base) ? payload.base.trim() : null),
359
+ baseBranch: isNonEmptyString(baseBranchValue) ? baseBranchValue.trim() : null,
254
360
  error: null,
255
361
  }
256
362
  }
@@ -342,6 +448,9 @@ export async function importLegacySnapshot(input = {}) {
342
448
  missionIds: validation.missionIds,
343
449
  paused: validation.paused,
344
450
  pausedCount: validation.paused.length,
451
+ unresolved: validation.unresolved,
452
+ dropped: validation.dropped,
453
+ remapped: validation.remapped,
345
454
  events: validation.events,
346
455
  legacySnapshots: validation.legacySnapshots,
347
456
  sourcePath: resolvedSource,
@@ -362,6 +471,9 @@ export async function importLegacySnapshot(input = {}) {
362
471
  missionIds: validation.missionIds,
363
472
  missionCount: validation.missionCount,
364
473
  pausedMissionIds: validation.paused,
474
+ unresolved: validation.unresolved,
475
+ droppedFields: validation.dropped.map((entry) => entry.field),
476
+ remapped: validation.remapped,
365
477
  bytes: buffer.length,
366
478
  importedAt,
367
479
  }
@@ -15,6 +15,7 @@
15
15
  // `state.migrated {from,to}` to the activity log.
16
16
  // A migration that throws therefore leaves state.json byte-identical.
17
17
  import { inspectStateVersion, prepareState } from '../domain/state.js'
18
+ import { legacySurveyNeedsRead } from '../domain/state/migrations.js'
18
19
  import { isStateLockHeld } from './store.js'
19
20
 
20
21
  /**
@@ -37,6 +38,7 @@ import { isStateLockHeld } from './store.js'
37
38
  * now?: number,
38
39
  * persist?: boolean,
39
40
  * migrations?: readonly import('../domain/state/migrations.js').StateMigration[],
41
+ * resolveLegacySurvey?: (rawState: Record<string, any>) => Promise<import('../domain/state/migrations.js').LegacySurveyRead | null>,
40
42
  * }} input
41
43
  * @returns {Promise<StateLoadResult>}
42
44
  */
@@ -54,7 +56,15 @@ export async function loadStateFile(input) {
54
56
  await store.writeJsonAtomic(backupPath, raw)
55
57
  }
56
58
 
57
- const prepared = prepareState(raw, paths.state, { migrations })
59
+ // A legacy survey planned but never provisioned recorded no base at all, so
60
+ // the document alone cannot anchor it. Resolve the repository base exactly
61
+ // once, in the same moment as the migration, and hand it to the pure step.
62
+ const legacySurveyRead =
63
+ version.status === 'migratable' && legacySurveyNeedsRead(raw) && typeof input.resolveLegacySurvey === 'function'
64
+ ? await input.resolveLegacySurvey(raw)
65
+ : null
66
+
67
+ const prepared = prepareState(raw, paths.state, { migrations, legacySurveyRead })
58
68
  if (!prepared.changed) {
59
69
  return { state: prepared.state, version, migration: null }
60
70
  }
package/app/archive.js CHANGED
@@ -144,10 +144,34 @@ export async function sweepArchive(tower, state, options = {}) {
144
144
  force: options.force === true,
145
145
  protectedIds,
146
146
  })
147
- if (selected.length === 0) return { archived: [], written: false, error: null }
147
+ // #148 W2: never archive a mission whose disposable sandbox is still on disk.
148
+ // Archiving would move the only record of the path out of hot state while the
149
+ // checkout leaks; the mission stays hot (protected) until cleanup succeeds on
150
+ // a later mutate or tower_teardown, and this sweep names the caused path.
151
+ const sandboxPending = selected
152
+ .filter((mission) => typeof /** @type {any} */ (mission).sandbox === 'string' && /** @type {any} */ (mission).sandbox.length > 0)
153
+ .map((mission) => ({ missionId: mission.id, sandbox: /** @type {any} */ (mission).sandbox }))
154
+ const pendingIds = new Set(sandboxPending.map((entry) => entry.missionId))
155
+ const archivable = selected.filter((mission) => !pendingIds.has(mission.id))
156
+ for (const entry of sandboxPending) {
157
+ await tower.deps.store.appendJsonl(tower.paths.activity, {
158
+ at: now,
159
+ type: 'archive',
160
+ missionId: entry.missionId,
161
+ text: `deferred: detached sandbox at ${entry.sandbox} is still present; cleanup retries on tower_teardown`,
162
+ }).catch(() => {})
163
+ }
164
+ if (archivable.length === 0) {
165
+ return {
166
+ archived: [],
167
+ written: false,
168
+ error: null,
169
+ ...(sandboxPending.length > 0 ? { sandboxPending } : {}),
170
+ }
171
+ }
148
172
 
149
173
  const archivedYear = archiveYearFor(now)
150
- const entries = selected.map((mission) =>
174
+ const entries = archivable.map((mission) =>
151
175
  archivedEntry(mission, {
152
176
  archivedAt: now,
153
177
  archivedYear,
@@ -165,9 +189,9 @@ export async function sweepArchive(tower, state, options = {}) {
165
189
  if (!written.ok) return { archived: [], written: false, error: written.error ?? 'archive write failed' }
166
190
 
167
191
  // The archive now holds the full record: compact state.json in the same lock.
168
- const ids = new Set(selected.map((mission) => mission.id))
192
+ const ids = new Set(archivable.map((mission) => mission.id))
169
193
  const branches = new Set(
170
- selected
194
+ archivable
171
195
  .map((mission) => mission.worktree?.branch)
172
196
  .filter((branch) => typeof branch === 'string'),
173
197
  )
@@ -184,7 +208,7 @@ export async function sweepArchive(tower, state, options = {}) {
184
208
  state.revision += 1
185
209
  state.updatedAt = now
186
210
 
187
- for (const mission of selected) {
211
+ for (const mission of archivable) {
188
212
  await tower.deps.store.appendJsonl(tower.paths.activity, {
189
213
  at: now,
190
214
  type: 'archive',
@@ -199,5 +223,6 @@ export async function sweepArchive(tower, state, options = {}) {
199
223
  files: written.files,
200
224
  error: null,
201
225
  year: archivedYear,
226
+ ...(sandboxPending.length > 0 ? { sandboxPending } : {}),
202
227
  }
203
228
  }