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
@@ -0,0 +1,306 @@
1
+ # ADR 0006: Mission shapes
2
+
3
+ - Status: accepted
4
+ - Date: 2026-11-10
5
+ - Ref: #148, #149
6
+
7
+ ## Context
8
+
9
+ `kind` was a column on one mission record, but it carried three contracts: a build
10
+ owns a branch and a worktree, is reviewed at its tip, and merges; a survey only
11
+ reads one commit. The state document could describe both shapes at once, so the
12
+ generic layers kept asking `kind === 'survey'` and the answer leaked into
13
+ completion (`commit >= 1`), the merge gate (`read-only-survey`), archive and
14
+ reanchor terminality, planning's open-work count, and provisioning. A survey
15
+ that completed could not be archived, blocked reanchor forever, counted as open
16
+ work, and paid for a full checkout while consuming the live-worker budget.
17
+
18
+ ## Decision
19
+
20
+ `domain/shapes/` is the one registry. Each shape module owns its statuses,
21
+ transitions, terminality, merge/rebase permission, provisioning, plan fields,
22
+ completion invariants, dependency satisfaction, briefing and summary projection.
23
+ `domain/shapes/index.js` is the only way a generic layer resolves a shape:
24
+ `shapeFor(mission.kind)`, `isTerminal(kind, status)`, `canTransition(kind, from,
25
+ to)`, and `assertActionSupported(mission, action)` for merge/rebase/preflight/diff.
26
+ The global `TERMINAL_STATUSES`/`TRANSITIONS` vocabulary and the one-argument
27
+ `isTerminal`/`canTransition` entry points are deleted.
28
+
29
+ Schema version 4 is a TypeBox union: a build mission keeps its worktree/branch and
30
+ merge fields; a survey carries `readCommit` and `readDirty` and may not carry
31
+ `worktree`, `branch`, `baseCommit`, `mergedCommit` or `mergedAt`. `merged` is not
32
+ a survey status. Migration v3→v4 is pure, one-way and idempotent: it stamps every
33
+ mission with a shape, reads a legacy survey's commit from
34
+ `worktree.baseCommit ?? baseCommit`, drops its branch fields, and maps a legacy
35
+ `merged` survey to `completed`.
36
+
37
+ Read-only is structural, not a gate: `tower_plan({kind:'survey'})` refuses while
38
+ the `tower-survey` component row is off; a survey spawn uses the shared checkout
39
+ and the `tower-survey` preset allow-list; `tower_complete` checks that HEAD still
40
+ equals `readCommit`, that the tracked-status set matches the set captured at plan
41
+ time, and that zero commits were made.
42
+
43
+ `scripts/shape-audit.mjs` enforces the decision: no kind or role literal
44
+ comparison outside `domain/shapes/` and `domain/roles.js`, no global status
45
+ vocabulary, no one-argument entry points. Waivers are justified data in
46
+ `scripts/architecture-waivers.json` under `shapeWaivers`; an unjustified or stale
47
+ waiver fails. `docs/reference/shapes.md` is generated from the registry.
48
+
49
+ ## Surface budget
50
+
51
+ The shape redesign grows the config surface by exactly one key, recorded in
52
+ `scripts/surface-baseline.json`:
53
+
54
+ - `spawn.surveyModel` (tuning): an optional independent model route, mirroring
55
+ `spawn.workerModel`/`spawn.reviewerModel`.
56
+
57
+ An earlier cut of this work also added `spawn.surveyPreset` (core) and
58
+ `survey.sandbox` (tuning) in a new `survey` section. Review contracted the
59
+ surface back before release (config 57 → 55, sections 15 → 14): a shape owns its
60
+ preset (`SHAPES.survey.spawn.defaultPreset`), so a `spawn.surveyPreset` key could
61
+ only disagree with the registry; and `survey.sandbox` advertised a detached
62
+ read-only checkout that W1 does not implement. Neither is a tuning knob a user
63
+ should see; the completion invariants (HEAD, tracked-status fingerprint, zero
64
+ commits) are the read-only guarantee, not a placement option.
65
+
66
+ The `tower-survey` row is a visible optional component (components 11 → 12): off
67
+ means surveys cannot be planned (an actionable code) and no survey agent can be
68
+ dispatched; build missions are unaffected. It is a declarative preset row: the
69
+ host resolves its availability through the async `agentPresets` probe, not the
70
+ process-local code registry.
71
+
72
+ ## Rejected alternatives
73
+
74
+ - Keep `kind` on one record and guard each leak with another `kind === 'survey'`
75
+ check. Rejected: that is exactly the census in #149; the state document would
76
+ still be able to describe both shapes and every new layer would re-learn the
77
+ rule.
78
+ - Add a `shape: 'build'|'survey'` flag selecting an old and a new code path during
79
+ a transition period. Rejected: a dual-shape path lets a document carry an
80
+ impossible combination (a survey with a worktree, a merged survey) and makes the
81
+ migration untestable.
82
+ - Keep `commit >= 1` and make a survey commit an empty commit. Rejected: a survey
83
+ is a read, so the empty commit records nothing and hides that HEAD moved.
84
+ - Enforce read-only at merge time (`read-only-survey` in the gate). Rejected: the
85
+ write already happened by then; read-only must be structural (preset allow-list)
86
+ and checked as a completion invariant.
87
+ - Give a survey its own branch so the existing lifecycle "just works". Rejected:
88
+ it reintroduces provisioning cost, branch ownership, stale-base and merge
89
+ vocabulary for a kind that never merges.
90
+
91
+ ## Consequences
92
+
93
+ - `kind` is now a closed vocabulary with one registry; adding a kind means adding
94
+ a shape module and regenerating `docs/reference/shapes.md`, not editing generic
95
+ layers.
96
+ - Older v3 documents migrate on load; a legacy survey's `worktree` is removed by
97
+ a bounded-retry teardown and its completion with commits is accepted as history.
98
+ - A completed survey is terminal, archivable, no longer open work, and no longer
99
+ blocks reanchor or the live-worker budget.
100
+ - Surveys can be planned only while the `tower-survey` row is on, and they cannot
101
+ be merged, rebased, preflighted or diffed: each refuses with `invalid-transition`
102
+ and an actionable next step.
103
+ - The shape audit is part of `npm run check`; a new kind or role comparison in a
104
+ generic layer fails CI unless a justified waiver is recorded.
105
+
106
+ ## Addendum: W2 detached sandbox and dependency semantics (#148)
107
+
108
+ W1 implemented the shared-checkout survey only. W2 adds the placement it left
109
+ out, without changing the default or the shared path:
110
+
111
+ - `survey.sandbox` (`none`|`detached`, default `none`) is tuning, recorded in
112
+ `scripts/surface-baseline.json` (config keys 55 → 56, sections 14 → 15). The
113
+ shape resolves the placement (`resolveProvisioning`) and the preset
114
+ (`resolvePreset`); no generic layer reads the key or compares a kind.
115
+ `spawn.surveyPreset` stays removed because the preset follows the registry and
116
+ the resolved placement.
117
+ - `detached` provisions `git worktree add --detach <worktreeRoot>/<id>-sandbox
118
+ <readCommit>` with no branch and records `mission.sandbox`. It never records
119
+ `baseCommit`, never participates in stale-base, review or merge, and the
120
+ completion invariant is sandbox HEAD === `readCommit` plus zero commits.
121
+ Expected write/shell side effects in the disposable checkout are ignored;
122
+ the shared placement keeps the tracked-status fingerprint invariant.
123
+ - The sandbox preset (`tower-survey-sandbox`) allows read/write/edit/pwsh plus
124
+ the mission tools and still denies spawn/merge/rebase/review/diff. Both survey
125
+ presets are internal children of the one visible `tower-survey` group row, so
126
+ the component count does not move (12) and turning the capability off removes
127
+ both.
128
+ - Cleanup is advisory: completion, abandon and `tower_teardown` remove the
129
+ sandbox with a bounded retry. A failure keeps `mission.sandbox`, records
130
+ `sandboxCleanupError` and names the `git worktree remove --force <path>`
131
+ remedy in the activity log; the transition itself is not rolled back. Archival
132
+ defers a mission whose sandbox is still present instead of moving the only
133
+ record of the path out of hot state.
134
+ - `gates.requireFreshSurvey` (default `false`) controls dependency freshness. A
135
+ build depending on a survey whose `readCommit` is not ancestor-or-equal of the
136
+ build base gets a `survey-stale` warning in plan/preflight with
137
+ `{code,missionId,depId,readCommit,baseCommit,message}`; only when the gate is
138
+ `true` does the same code block the merge. The ancestor check is the existing
139
+ `isAncestor` (`git merge-base --is-ancestor`, which is true for equal commits).
140
+ - A build briefing carries each survey dependency's recorded `result`
141
+ (`paths`/`symbols`/`plan`/`notes`) with bounded formatting, so the dependent
142
+ worker sees the evidence rather than only the terminal status. `result`
143
+ remains survey-only at completion.
144
+ - Placement is decided at provisioning time, not at completion time: a recorded
145
+ `mission.sandbox` wins over current `survey.sandbox` config for provisioning,
146
+ preset and completion invariants. Flipping the key after a sandbox was created
147
+ therefore cannot skip the sandbox HEAD/zero-commit checks or strand the
148
+ disposable checkout, and a survey that never got a sandbox keeps the shared
149
+ invariants.
150
+ - The archive index keeps a survey's `readCommit` and bounded `result`, so an
151
+ archived survey dependency still carries its evidence into a build briefing
152
+ and `gates.requireFreshSurvey` cannot fail open after archiving (#73).
153
+
154
+ ## Addendum: W3 review, batch, leases and paused-vs-blocked (#149)
155
+
156
+ W3 audited four state dimensions that are not mission kinds. None became a
157
+ shape: each has one owning module, and each verdict names a fitness rule that
158
+ `npm run shape:audit` enforces (generated into `docs/reference/shapes.md`).
159
+
160
+ - **Review — commit-stamped evidence (`app/review.js`, `app/reviewCheckout.js`,
161
+ `domain/missions/review.js`).** A verdict is evidence about a commit, not about
162
+ a mutable worktree. `tower_spawn` provisions a disposable detached checkout of
163
+ the mission branch tip for the reviewer and records `reviewCommit` /
164
+ `reviewCheckout` on the roster row. `tower_review` stamps `reviewedCommit` with
165
+ that recorded commit, refuses if the checkout HEAD moved, and the merge gate
166
+ compares the stamp to the current branch tip with the existing `tip-moved`
167
+ blocker. `tower_diff` shows a reviewer the same recorded commit. The old
168
+ after-the-fact "reviewer modified the branch" check (`assertReviewerSpawnTip`)
169
+ is deleted: a stale worker write can no longer affect what a review decided.
170
+ The checkout is removed when the verdict is recorded and retried by
171
+ `tower_teardown`. A tower break-glass verdict stamps the current branch tip
172
+ because there is no checkout. Fitness rule: `review-tip-read`.
173
+ - **Batch — correctly generic second lifecycle (`domain/batch.js`,
174
+ `domain/collab/batch.js`, `app/batch.js`).** A batch is finite evidence over
175
+ missions, not a mission kind, so it stays generic — but its second state
176
+ machine is now owned by one module: `BATCH_STATUS`, `BATCH_TRANSITIONS`
177
+ (`open → closed | abandoned`), `canTransitionBatch`, `isBatchTerminal` and
178
+ `normalizeBatchStatus` (legacy `created`/`active`/`pending` read as `open`).
179
+ `createBatch`/`closeBatch`/`abandonBatch` share one guarded transition and
180
+ state semantics validates the stored status, so an unknown or invented batch
181
+ status fails as state corruption. Receipt verification is unchanged. Fitness
182
+ rule: `batch-status-literal`.
183
+ - **Leases — advisory coordination, never authority (`adapters/leases.js`,
184
+ `app/spawn-prechecks.js`).** Lease acquisition is not OS-enforced exclusivity,
185
+ and the docs say so. There is exactly one enforcement point: the fail-closed
186
+ spawn admission when `spawn.requireLease` names a lease, which refuses a
187
+ missing lease and a lease held by another actor with an actionable remedy. The
188
+ prior contract deadlocked: an expired lease held by a crashed process could
189
+ never be released because a token mismatch was refused even with `force`. Now
190
+ `force: true` releases an **expired** lease (explicit crash recovery) while a
191
+ live lease still refuses a foreign token, and `acquire` never takes over
192
+ automatically. Fitness rule: `lease-ledger`.
193
+ - **Paused vs blocked — two distinct stopped semantics
194
+ (`domain/missions/lifecycle.js`, `domain/state/semantics.js`).** `blocked` is
195
+ derived machine state: it means "open blockers exist". `paused` is a deliberate
196
+ stop with a `pauseCause` (`user`|`failure`|`resource`|`dependency`). They never
197
+ coexist: a recorded blocker clears a pause and moves the mission to `blocked`;
198
+ `clear_blockers` clears them and resumes the mission (to `active`, or to
199
+ `completed` in the same patch); pausing or completing is refused while blockers
200
+ are open; `status: "blocked"` is refused and machine writers go through
201
+ `markBlocked(mission, text)` (merge/rebase conflicts use it). The state
202
+ invariant `status === "blocked" ⇔ open blockers exist` (for non-terminal
203
+ missions) is checked on every load/validation, and migration v4→v5 normalizes
204
+ recorded states: a blocked mission without a blocker gets an explicit legacy
205
+ blocker, blockers under another non-terminal status become `blocked`, and a
206
+ superseded `pauseCause` is removed. It is not a per-kind rule: both shapes
207
+ declare `blocked`/`paused` in their own transition tables. Fitness rule:
208
+ `blocked-status-write`.
209
+ - Because the stopped-status invariant is new, the persisted schema version is
210
+ now **5** (`SCHEMA_VERSION = 5` in `domain/mission.js`); the v4→v5 migration is
211
+ pure and idempotent like its predecessors.
212
+ - The audit grew four rules (`review-tip-read`, `batch-status-literal`,
213
+ `lease-ledger`, `blocked-status-write`) with mutation tests proving each rule
214
+ fails on a seeded violation. `scripts/shape-audit.mjs` exports `RULES`, and
215
+ `scripts/generate-shapes-reference.mjs` refuses to generate if a verdict names
216
+ a rule the audit does not run.
217
+
218
+ ## Addendum: W4 evidence, legacy, scope and role-vs-kind (#149)
219
+
220
+ W4 audited the four remaining dimensions. Each had no single owner; each got a
221
+ written verdict, one owning module, and a fitness rule that `npm run shape:audit`
222
+ enforces (generated into `docs/reference/shapes.md`). No dimension became a
223
+ per-kind conditional and no second shape table was added.
224
+
225
+ - **Test receipts / evidence — redesigned to one reader (`domain/evidence.js`).**
226
+ The same question ("did tests run?") previously had four answers:
227
+ `mission.completionReport.checks`, a synthetic `tests` check that
228
+ `tower_complete` copied from discovery via `tests.autoChecks`,
229
+ `mission.testRuns`, and a separate `gates.requireTests` filter that re-read
230
+ receipts. `domain/evidence.js` now owns the normalized record
231
+ `{names, name, source, ok, command, exitCode, evidence, treeTip, entry}` with
232
+ sources `completionReport` | `testRuns` | `input`, aliases `tests`/`tower_test`
233
+ carried in `names`, and one ordering (completion checks first, then newest
234
+ receipts, then caller checks scoped to the mission) applied by `findEvidence` —
235
+ precedence and ordering are reader decisions, not fields on the record.
236
+ `evidenceEntries`, `findEvidence`, `passingDiscovery`,
237
+ `hasCheckEvidence` and `hasTestReceipts` are the only readers; the shapes
238
+ declare the contract (`build.evidence = {completionChecks: true, testReceipts:
239
+ "tip-bound"}`, `survey.evidence = {completionChecks: true, testReceipts:
240
+ false}`) because only a shape with a branch has a tip to bind a receipt to.
241
+ `gates.requireTests` uses `passingDiscovery`; completion blockers use
242
+ `hasCheckEvidence`; `tower_batch` validation/summary use `findEvidence`.
243
+ `tower_complete` no longer copies a `tests` check into the report and
244
+ `tower_test` returns the receipt only, so the config key `tests.autoChecks`
245
+ disappears (config surface 57 → 56, recorded in `scripts/surface-baseline.json`
246
+ and `docs/reference/surface-budget.md`). Fitness rule:
247
+ `evidence-single-read`, owned by `domain/evidence.js`; mutation tests seed a
248
+ completion-check read, a `mission.testRuns` read and prove the owner module is
249
+ exempt.
250
+ - **Legacy import — redesigned to a strict adapter contract
251
+ (`adapters/legacy.js`).** Import was approximate in four ways: the format was
252
+ inferred from the first mission id (mixed namespaces were only caught
253
+ incidentally), an absent `kind` was silently stamped `build`, a Tower Codex
254
+ `retired` status leaked into the mission summary as if it were a DSH status,
255
+ and batch exclusion read several hand-written snapshot shapes
256
+ (`missions`/`snapshot.missions`/`payload.missions`/`pausedIds`). The adapter now
257
+ exports `LEGACY_FORMATS`: one declared entry per accepted format (id pattern,
258
+ id hint, input statuses, `statusRemap`, `baseBranchField`,
259
+ `requiresBaseBranch`). Detection requires exactly one id namespace (a declared
260
+ `payload.format` is authoritative and validated against the ids); a Codex
261
+ `retired` status is remapped to `abandoned` and reported in `remapped`; an
262
+ absent kind is reported in `unresolved` instead of silently invented; mission
263
+ fields the summary does not carry are reported in `dropped` with the reason
264
+ that the exact bytes remain in the stored snapshot. The persisted snapshot
265
+ record gains `unresolved`, `droppedFields` and `remapped`, so the honesty
266
+ survives a restart. `domain/batch.js` reads only the canonical
267
+ `pausedMissionIds`. The import remains read-only, byte-exact,
268
+ hash-addressed and idempotent: it never writes `state.json`, never activates,
269
+ adopts or resumes a mission, and a paused mission stays paused. Fitness rule:
270
+ `legacy-vocabulary`, owned by `adapters/legacy.js`; mutation tests prove a
271
+ second `tower-codex`/`pausedIds` reader fails outside the adapter.
272
+ - **Scope ownership — redesigned to `shape.reach` (`domain/scope.js` +
273
+ shapes).** The shape flag `ownsScope` lived beside three enforcers (the
274
+ integration gate, the planner's auto-dependencies, and lifecycle scope
275
+ widening), each of which read `mission.scope` itself. The shape now declares
276
+ `reach = { ownsPaths, fence: "scope" | "read-only" }`: a build owns paths and
277
+ fences changed files against its scope; a survey reaches nothing and is fenced
278
+ read-only. `domain/scope.js` exports one `scopeFenceBlocker({ mission, reach,
279
+ changedFiles, caseInsensitive })`; `domain/integration/gate.js` derives the
280
+ reach from `shapeFor(mission.kind)` and re-exports the existing
281
+ `outOfScopeBlocker` shape; `domain/missions/plan.js`,
282
+ `domain/planning.js` and `domain/missions/lifecycle.js` derive
283
+ `reach.ownsPaths`. `ownsScope` is deleted. Fitness rule: `scope-reach`, owned
284
+ by `domain/scope.js`; mutation tests seed an `ownsScope` reader and a direct
285
+ `fileInScope` call.
286
+ - **Role vs kind — redesigned to an orthogonal role contract
287
+ (`domain/roles.js`).** Spawn resolved a reviewer by combining
288
+ `isReviewerRole(role)` with `shape.fields.readCommit`/`shape.canMerge`, and
289
+ presets, prechecks, placement, briefings and the roster each re-derived role
290
+ facts independently. `domain/roles.js` now owns `ROLE_CONTRACT`, one entry per
291
+ actor role with `preset`, `model`, `placement`, `briefing`, `tracksMission`,
292
+ `countsTowardBudget`, `spawnDiscovery` and `requires` (review, canMerge,
293
+ worktree, status set). `resolveRoleSpawn` returns the preset/model route,
294
+ `roleMissionBlockers` returns ordered prerequisites, and
295
+ `roleCountsTowardBudget`, `roleTracksMission`, `rolePlacement`,
296
+ `roleBriefingKind` and `rolePresetConfig` replace the scattered checks. The
297
+ shape supplies only facts (`canMerge`, `fields.worktree`, `spawn.model`);
298
+ `tools/spawn.js`, `app/spawn-prechecks.js`, `app/spawn-support.js`,
299
+ `domain/roster.js` and `host/presets.js` call the role API and never combine a
300
+ role predicate with a shape decision. Fitness rule: `role-shape-conflation`,
301
+ owned by `domain/roles.js`; mutation tests seed a role∧shape conditional and
302
+ prove the owner module is exempt.
303
+ - The audit now runs 12 rules. The generated reference's `Dimension inventory
304
+ (#149)` lists every audited dimension with its verdict, owner module and
305
+ fitness rule; `scripts/generate-shapes-reference.mjs` still refuses to emit a
306
+ rule id the audit does not run.
@@ -226,12 +226,14 @@ planned ──spawn worker──▶ active ──tower_complete──▶ complet
226
226
  │ │ ▲ │
227
227
  │ │ └──── review round not clean ────┐
228
228
  └── abandon ──▶ abandoned│ │
229
- ├── blocker ──▶ blocked ──active/completed──▶ ...
229
+ ├── blocker ──▶ blocked ──clear_blockers──▶ active
230
230
  └── pause ─────▶ paused ──active──▶ ...
231
231
  ```
232
232
 
233
- `merged` and `abandoned` are terminal. `tower_mission` may set `blocked → completed`; `merged` is only
234
- set by `tower_merge`/reconciliation.
233
+ `merged` and `abandoned` are terminal. `blocked` is derived: it holds exactly while the mission has an open
234
+ blocker, `clear_blockers` resumes it to `active`, and `tower_mission` refuses to set `status: "blocked"` directly.
235
+ `paused` is a deliberate stop (`pause_cause`); a blocker supersedes a pause. `merged` is only set by
236
+ `tower_merge`/reconciliation.
235
237
 
236
238
  ## Gate pipeline (`tower_preflight`)
237
239
 
@@ -261,7 +263,7 @@ After the command, regardless of exit status, it inspects `HEAD`:
261
263
  | Path | Contents |
262
264
  |---|---|
263
265
  | `<git-common-dir>/dsh-tower/state.json` | active missions, roster, reviews, findings, messages, reviews sequence |
264
- | `<git-common-dir>/dsh-tower/archive/index.json` | compact terminal-mission index (ids, status, branch, timestamps) |
266
+ | `<git-common-dir>/dsh-tower/archive/index.json` | compact terminal-mission index (ids, status, branch, timestamps, survey `readCommit` and bounded `result`) |
265
267
  | `<git-common-dir>/dsh-tower/archive/<year>.json` | full archived missions plus their linked reviews/findings |
266
268
  | `<git-common-dir>/dsh-tower/activity.jsonl` | append-only activity |
267
269
  | `<git-common-dir>/dsh-tower/state.lock` | single-writer state lock (heartbeat reclaim; a recycled PID cannot pin it) |
@@ -29,6 +29,32 @@ The Host plugin loads at DSH startup: **restart DSH after install or update**.
29
29
 
30
30
  ## Migration notes
31
31
 
32
+ - **2.6.0 → 2.7.0 (#148/#149) — mission shapes, survey tasks, and state schema v5**: a completed
33
+ survey is a read of a commit, not a write of a branch. `tower_plan({kind:"survey"})` records
34
+ `readCommit` and `readDirty`, creates no branch or worktree, and completes only while HEAD and the
35
+ tracked-file status still match that read. The optional `survey.sandbox: "detached"` mode gives a
36
+ writable inspection a disposable worktree pinned to the same commit. `tower-survey` is an optional
37
+ component row; survey presets are read-only by default, and surveys do not count against
38
+ `spawn.maxLiveWorkers`.
39
+ - **State migration is automatic and one-way.** The first write after upgrading migrates
40
+ `state.json` from v3 to v5 in one step, writes `state.json.v3.bak` first, advances `revision`
41
+ once, and logs `state.migrated` with steps v3→v4 and v4→v5. Legacy survey rows gain
42
+ `readCommit`/`readDirty` (anchored at `state.base` when the old row never started), and build
43
+ rows are stamped `kind:"build"`. Read-only loads migrate in memory without rewriting the file.
44
+ A newer document is still refused with `state-version-unsupported` and never downgraded.
45
+ - **Config key count 57 → 56.** `tests.autoChecks` is gone; test evidence is now the
46
+ `mission.testRuns` receipt read by `domain/evidence.js`. A config that still writes the key is
47
+ rejected as `config-unknown-key` with this note; there is no replacement switch.
48
+ - **`blocked` is derived.** Write `status:"blocked"` through `tower_mission` is refused; use
49
+ `blocker` to report and `clear_blockers` to resolve. Batch statuses are canonical
50
+ `open → closed|abandoned`; an expired foreign lease requires explicit `force` to recover.
51
+ - **Survey actions are explicit.** `tower_merge`, `tower_rebase`, `tower_preflight`, and
52
+ `tower_diff` refuse surveys with `invalid-transition`; a completed survey is terminal for
53
+ planning, archive, dependency satisfaction, and reanchor. `tower_complete` accepts an optional
54
+ structured `result` (`paths`, `symbols`, `notes`, `plan`) for surveys only.
55
+ - Reviewers are now spawned into a detached checkout bound to the recorded commit; a verdict is
56
+ tied to that commit, and teardown cleans the checkout. Existing build workflows are unchanged.
57
+ - No npm dist-tag or review-policy change is required. Restart DSH after upgrading.
32
58
  - **2.5.0 → 2.6.0 (#M39/#M40) — review modes replace the boolean**: the review policy is one
33
59
  tri-state control on the Plugins page in the `dsh-tower` configuration section, and the config key
34
60
  is `review.mode`: `off`, `optional` (the default) or `required`.
@@ -21,7 +21,7 @@
21
21
 
22
22
  `tower_rebase` first. If the branch is already contained in the base it returns `alreadyMerged` and
23
23
  leaves the branch untouched — then call `tower_merge` to record it. A conflict marks the mission
24
- `blocked`; resolve in the worktree, set the mission back to `active` (or `completed`), retry.
24
+ `blocked`; resolve in the worktree, then `tower_mission({clear_blockers:true})` resumes it to `active`, retry.
25
25
 
26
26
  ## Interrupt, stale roster and takeover
27
27
 
@@ -1,9 +1,13 @@
1
1
  # Blockers and failure codes
2
2
 
3
- `tower_preflight` returns `{ok:false, blockers:[{code, detail}]}`. `tower_merge` and `tower_rebase`
3
+ `tower_preflight` returns `{ok:false, blockers:[{code, detail}], warnings:[...]}`. `tower_merge` and `tower_rebase`
4
4
  return the same shape for operation failures. Codes are stable; the detail is human-facing. The
5
5
  tables below define 32 stable blocker/failure codes.
6
6
 
7
+ `survey-stale` is special: it is reported as a stable warning in `plan`/`preflight` by default and
8
+ becomes a merge blocker only when `gates.requireFreshSurvey` is `true`. Its warning object carries
9
+ `{code, missionId, depId, readCommit, baseCommit, message}` so a caller can act without parsing prose.
10
+
7
11
  The review blockers below (`no-review`, `review-mission-mismatch`, `not-clean`, `review-hold`,
8
12
  `review-not-approved`, `tip-moved`, `review-exemption-invalid`) exist only when `review.mode` is
9
13
  `required`. With `off` or `optional` no review blocker is produced; `off` additionally removes
@@ -20,10 +24,10 @@ The review blockers below (`no-review`, `review-mission-mismatch`, `not-clean`,
20
24
  | `no-check-evidence` | `gates.requireChecks` is enabled and the completion report has no `checks`. Return the completed mission to `active`, re-run `tower_complete` with checks, then review again. |
21
25
  | `branch-missing` | No worktree/branch yet: spawn a worker first. |
22
26
  | `stale-base` | The branch does not contain the base tip: run `tower_rebase`. |
23
- | `read-only-survey` | Survey mission changed files; survey missions must be read-only. |
24
27
  | `out-of-scope` | Changed files outside the mission scope; widen scope deliberately or revert. On a case-sensitive checkout a case-only mismatch is still rejected and the detail names the scope entry it would match if case were folded. |
25
28
  | `base-dirty` | The main checkout has uncommitted changes the merge would overwrite. |
26
29
  | `deps-unmerged` | A dependency mission is not satisfied: a build dependency must be `merged`/`abandoned`, while a survey dependency is satisfied at `completed` (archived entries included). |
30
+ | `survey-stale` | A build dependency on a survey whose `readCommit` is not ancestor-or-equal of the build base. Default: a `warnings[]` entry, plan and preflight only. With `gates.requireFreshSurvey:true` the same code blocks the merge; re-plan the survey against the current base, or record why the read is still valid. |
27
31
  | `no-review` | `review.mode:required` and no review exists on the branch: spawn a reviewer (or disable the review gate). |
28
32
  | `review-mission-mismatch` | `review.mode:required` and the latest review belongs to another mission. |
29
33
  | `not-clean` | `review.mode:required` and latest review is `p1-Nitems`/`p2-Nitems`; another round is required. Verdicts from later routes of the same round are appended to it instead of flipping the mission again. |
@@ -44,7 +48,7 @@ The review blockers below (`no-review`, `review-mission-mismatch`, `not-clean`,
44
48
 
45
49
  | Code | Meaning / next action |
46
50
  |---|---|
47
- | `merge-conflict` | Real index conflict; the mission is `blocked`. Resolve, restore the mission to `active`/`completed`, retry. |
51
+ | `merge-conflict` | Real index conflict; the mission is `blocked`. Resolve the conflict, then `tower_mission({clear_blockers:true})` resumes the mission to `active`; retry. |
48
52
  | `merge-hook-failed` | A hook/process failed before a commit existed; repository restored, mission stays `completed`. Retry; `timeout: true` means the git process timed out. Raise `timeoutMs` for slow hooks. |
49
53
  | `merge-shape` | Git produced unexpected merge parents; inspect the repository, do not force. |
50
54
  | `batch-invalid` | A batch id/member/receipt/check reference is invalid or close evidence is incomplete. |
@@ -57,7 +61,7 @@ A merge that already landed is **not** a blocker: `tower_merge` records it (`rec
57
61
  | Code | Meaning / next action |
58
62
  |---|---|
59
63
  | `worktree-dirty` | Commit or discard worktree changes, then retry. |
60
- | `rebase-conflict` | A real conflict; the mission is `blocked`. Resolve manually, return it to `active`, retry. |
64
+ | `rebase-conflict` | A real conflict; the mission is `blocked`. Resolve manually, then `clear_blockers` resumes it to `active`; retry. |
61
65
  | `worker-busy` | An agent is running for this mission; wait or retire it first. |
62
66
 
63
67
  `tower_rebase` returns `alreadyMerged: true` when the branch is already contained in the base; call
@@ -3,7 +3,7 @@
3
3
  > Generated by `npm run docs:generate:components` from `scripts/component-surface.json` and
4
4
  > `scripts/surface-baseline.json`. Do not edit by hand.
5
5
 
6
- Decision: [ADR 0005](../adr/0005-component-surface.md) · decision date: 2026-11-01 · baseline 2026-10-23 · visible components: 11 (`tower`, `tower-skills`, `tower-batch`, `tower-import`, `tower-leases`, `tower-tests`, `tower-board`, `tower-archive`, `tower-hmr`, `preset-tower-reviewer`, `tower-ui`)
6
+ Decision: [ADR 0005](../adr/0005-component-surface.md) · decision date: 2026-11-01 · baseline 2026-10-23 · visible components: 12 (`tower`, `tower-skills`, `tower-batch`, `tower-import`, `tower-leases`, `tower-tests`, `tower-board`, `tower-archive`, `tower-hmr`, `preset-tower-reviewer`, `tower-survey`, `tower-ui`)
7
7
 
8
8
  The component axis answers one question for every config key: may a user turn it
9
9
  off, and if so, must that switch be a component? Four classes cover every key, so
@@ -36,6 +36,9 @@ row is a section of its parent and is not independently switchable.
36
36
  | `tower-hmr` | visible | row | Development-only tooling: tower_reload and /tower reload are unavailable, so a linked-profile reload has to be triggered outside Tower. A normal profile behaves exactly as before; the row ships disabled on purpose. |
37
37
  | `preset-tower-worker` | internal | row | Declared by dsh-tower/preset-worker: tower_spawn fails without the worker preset. A mandatory dependency, never a switch. |
38
38
  | `preset-tower-reviewer` | visible | row | Declared by dsh-tower/preset-reviewer: the reviewer cannot be dispatched. Supported while review.mode is off or optional (merge is unaffected; off does not use the preset at all); with review.mode required, planning/mode entry is locked with an explanatory error. |
39
+ | `tower-survey` | visible | group | Declared by the tower-survey group: surveys cannot be planned (planning one is refused with an actionable code) and no survey agent is dispatched, because both the read-only and the sandbox preset are nested dependencies of this row. Build missions and every existing capability are unaffected. survey.sandbox stays tuning: it chooses shared vs detached placement, never whether surveys exist. |
40
+ | `preset-tower-survey` | internal | row | Declared by dsh-tower/preset-survey: the shared-checkout survey preset is a dependency of the tower-survey group, never its own switch. With the group off, a shared survey cannot be dispatched. |
41
+ | `preset-tower-survey-sandbox` | internal | row | Declared by dsh-tower/preset-survey-sandbox: the detached-sandbox survey preset is a dependency of the tower-survey group, never its own switch. It is selected only when survey.sandbox is detached; with the group off, a detached survey cannot be dispatched. |
39
42
  | `tower-ui` | visible | row | The Tower web UI plugin does not load in this profile: no Tower tool cards, composer-dock summary or status strip. Host tools, missions, state and the MISSIONS.md board are unaffected. |
40
43
 
41
44
  ## Forbidden to disable
@@ -110,6 +113,8 @@ All keys from `domain/config/schema.js`, classified on the component axis.
110
113
  | `spawn.provider` | `optional-config` | Provider route for spawned agents. |
111
114
  | `spawn.workerModel` | `optional-config` | Optional model route for workers. |
112
115
  | `spawn.reviewerModel` | `optional-config` | Optional independent model route for reviewers. |
116
+ | `spawn.surveyModel` | `optional-config` | Optional model route for survey workers. |
117
+ | `survey.sandbox` | `optional-config` | Tuning, not a capability: shared (read-only in the shared checkout) or detached (a disposable detached sandbox that may write and run commands). The tower-survey row is still the switch. |
113
118
  | `spawn.maxLiveWorkers` | `core` | Resource-safety cap on live workers. |
114
119
  | `spawn.staleMs` | `optional-config` | Roster staleness window before merge is blocked. |
115
120
  | `spawn.maxWaitMs` | `optional-config` | Upper bound for a blocking spawn wait. |
@@ -119,11 +124,11 @@ All keys from `domain/config/schema.js`, classified on the component axis.
119
124
  | `tests.admission` | `optional-config` | Chooses when discovery runs inside the optional tests capability. Tuning exemption: Tuning when the admission check runs inside the optional tower-tests row, which still owns whether test tooling exists. (2026-11-03 · #138 · [0005-component-surface.md](../adr/0005-component-surface.md)) |
120
125
  | `tests.timeoutMs` | `optional-config` | Per-command discovery timeout. |
121
126
  | `tests.maxBytes` | `optional-config` | Captured discovery output cap. |
122
- | `tests.autoChecks` | `optional-config` | Attaches a tests check to completion when discovery runs. Tuning exemption: Tuning the default receipt policy inside the optional tower-tests row, which still owns whether any test tooling exists. (2026-11-03 · #138 · [0005-component-surface.md](../adr/0005-component-surface.md)) |
123
127
  | `tests.allowPackageDiscovery` | `core` | Allows repository code to execute; a security contract, not a component. |
124
128
  | `gates.requireChecks` | `forbidden-to-disable` | Requires completion evidence before merge; a merge gate. |
125
129
  | `gates.requireTests` | `forbidden-to-disable` | Requires a test receipt before merge; a merge gate. With the tower-tests row off, planning/mode entry refuse the combination. |
126
130
  | `gates.allowReviewExemption` | `forbidden-to-disable` | The review-exemption path is part of the review and merge contract. |
131
+ | `gates.requireFreshSurvey` | `forbidden-to-disable` | Promotes the survey-stale dependency warning to a merge blocker; the gate itself is part of the merge contract and defaults to advisory. |
127
132
  | `gates.maxReportBytes` | `forbidden-to-disable` | The report cap is part of the completion contract; the refusal itself is never configurable away. |
128
133
  | `docs.boardLocation` | `optional-config` | Chooses where the board is written; whether it is written is the tower-board component row (#133). |
129
134
  | `archive.afterDays` | `optional-config` | Archive age threshold inside the optional tower-archive component (#135). |
@@ -1,7 +1,7 @@
1
1
  # Config reference (generated)
2
2
 
3
3
  > Generated by `npm run docs:generate:config` from `domain/config/schema.js`. Values are defaults before repo config.
4
- > Surface: config keys: 54 · sections: 14 · tools: 29 · baseline 2026-10-23. Generated from `scripts/surface-baseline.json`; do not edit by hand.
4
+ > Surface: config keys: 56 · sections: 15 · tools: 29 · baseline 2026-10-23. Generated from `scripts/surface-baseline.json`; do not edit by hand.
5
5
 
6
6
  | Key | Default | Description |
7
7
  |---|---|---|
@@ -32,26 +32,28 @@
32
32
  | `merge.maxBufferBytes` | `67108864` | Captured merge output buffer. |
33
33
  | `git.timeoutMs` | `15000` | Default timeout for Git commands other than merge; merge uses merge.timeoutMs. |
34
34
  | `worktrees.root` | `` | Default parent directory for mission worktrees; empty derives <main-checkout-parent>/.dsh-tower-worktrees/<hash>. |
35
- | `spawn.workerPreset` | `"tower-worker"` | Agent preset for worker missions. |
35
+ | `spawn.workerPreset` | `"tower-worker"` | Agent preset for build workers. |
36
36
  | `spawn.reviewerPreset` | `"tower-reviewer"` | Agent preset for reviewers. |
37
37
  | `spawn.maxDepth` | `1` | Subagent depth requested when spawning tower agents. |
38
38
  | `spawn.provider` | `` | Subagent provider id; empty picks spawn, then the first registered provider. |
39
39
  | `spawn.workerModel` | `{}` | Model route for workers; omitted inherits the parent session. |
40
40
  | `spawn.reviewerModel` | `{}` | Model route for reviewers; omitted inherits the parent session (recommended). |
41
+ | `spawn.surveyModel` | `{}` | Model route for survey workers; omitted inherits the parent session. |
41
42
  | `spawn.maxLiveWorkers` | `4` | Maximum live worker entries allowed per repository at once. |
42
43
  | `spawn.staleMs` | `15000` | Roster entries with no catalog activity after this age are marked stale and block merge (0 disables). |
43
44
  | `spawn.maxWaitMs` | `90000` | Upper bound for tower_spawn waitMs (default 90s, Kimi-compatible). |
44
45
  | `spawn.requireLease` | `` | Optional lease name tower_spawn must hold before starting a worker. |
45
46
  | `send.maxPerMinute` | `120` | Per-repository tower_send rate limit per minute (0 disables). |
46
47
  | `send.interruptDefault` | `false` | Default value of tower_send.interrupt when the caller omits it. |
48
+ | `survey.sandbox` | `"none"` | Where a survey reads. none = the shared checkout with a read-only preset (writes and shell commands are impossible); detached = a disposable detached git worktree created at the recorded commit, with a sandbox preset that may write and run commands inside it. A detached sandbox is removed when the survey completes or is abandoned; it has no branch, never participates in merge/stale-base, and cannot commit its evidence. |
47
49
  | `tests.admission` | `"off"` | Run discovery before spawning a worker, when completing, or only on explicit tower_test (default off: repository code runs only on an explicit tower_test). |
48
50
  | `tests.timeoutMs` | `60000` | Per discovery command timeout. |
49
51
  | `tests.maxBytes` | `16777216` | Maximum captured stdout bytes per command. |
50
- | `tests.autoChecks` | `true` | Attach a tests check to the completion report when discovery runs. |
51
52
  | `tests.allowPackageDiscovery` | `true` | Allow package.json tower.testDiscovery commands from the mission worktree (executes repository code; see security docs). |
52
- | `gates.requireChecks` | `false` | Require at least one completion check (tower_complete.checks) before a mission can merge. |
53
+ | `gates.requireChecks` | `false` | Require at least one recorded check (tower_complete.checks) or test-discovery receipt before a mission can merge. |
53
54
  | `gates.requireTests` | `false` | Require a passing test-discovery receipt for the reviewed worktree tip before merge. |
54
55
  | `gates.allowReviewExemption` | `false` | Allow reviewPolicy:none missions with a non-empty reviewExemptionReason to skip review blockers. |
56
+ | `gates.requireFreshSurvey` | `false` | Promote the advisory survey-stale dependency warning to a merge blocker: a build may only merge when every survey dependency read a commit that is an ancestor-or-equal of the build base. Default false (warning only). |
55
57
  | `gates.maxReportBytes` | `32768` | Hard cap, in UTF-8 bytes, for tower_complete.report; a larger report is rejected with an actionable nextAction, never truncated. |
56
58
  | `docs.boardLocation` | `"common"` | common = <git-common-dir>/dsh-tower/MISSIONS.md; repo = <repoRoot>/MISSIONS.md. Whether the board is written at all is the tower-board component row (#133). |
57
59
  | `archive.afterDays` | `30` | Terminal missions untouched for at least this many days are archived on the next mutation (0 archives them immediately). Archival itself is the tower-archive component row (#135). |
@@ -108,9 +108,8 @@ violates the declared schema (for example `merge.timeoutMs` below 1000) fail wit
108
108
  | `tests.admission` | `off` | `spawn` runs discovery before starting a worker, `complete` at `tower_complete`, `off` only on explicit `tower_test` (default; repository code is not executed automatically). |
109
109
  | `tests.timeoutMs` | `60000` | Per discovery command timeout. |
110
110
  | `tests.maxBytes` | `16777216` (16 MiB) | Captured stdout cap per command. |
111
- | `tests.autoChecks` | `true` | Attach an automatic `tests` check to the completion report. |
112
111
  | `tests.allowPackageDiscovery` | `true` | Allow repo-controlled `tower.testDiscovery` commands (executes worktree code; see security). |
113
- | `gates.requireChecks` | `false` | Require at least one `tower_complete.checks` entry before merge (`no-check-evidence`). |
112
+ | `gates.requireChecks` | `false` | Require at least one recorded check (`tower_complete.checks`) or a test-discovery receipt before merge (`no-check-evidence`). |
114
113
  | `gates.requireTests` | `false` | Require a passing discovery receipt for the current mission branch tip (`tests-missing`); independent of the review gate. With the `tower-tests` component row off, planning/mode entry refuse this combination with an explanation. |
115
114
  | `gates.allowReviewExemption` | `false` | With `review.mode:required`, allow `reviewPolicy:"none"` + a non-empty reason to skip review blockers. Off deliberately; irrelevant when `review.mode` is `off` or `optional` (no review blocker is produced). |
116
115
  | `gates.maxReportBytes` | `32768` (32 KiB) | Hard cap in UTF-8 bytes for `tower_complete.report`. A larger report is rejected with `invalid-argument` and an actionable `nextAction` (file the full text in the mission worktree and reference its path); it is never truncated. Existing larger reports still load. |
@@ -8,17 +8,17 @@ It returns:
8
8
  "ok": true,
9
9
  "checks": [
10
10
  { "id": "git", "status": "pass", "detail": "git version 2.55.0" },
11
- { "id": "surface", "status": "pass", "detail": "config keys: 53 · sections: 14 · tools: 29 (baseline 2026-10-23)" },
11
+ { "id": "surface", "status": "pass", "detail": "config keys: 57 · sections: 15 · tools: 29 (baseline 2026-10-23)" },
12
12
  { "id": "state", "status": "pass", "detail": ".../state.json revision 7" },
13
- { "id": "state-schema", "status": "pass", "detail": "schemaVersion 3 is current" },
13
+ { "id": "state-schema", "status": "pass", "detail": "schemaVersion 5 is current" },
14
14
  { "id": "case-mode", "status": "pass", "detail": "caseInsensitive: true; scope matching uses case-insensitive comparison" }
15
15
  ],
16
16
  "surface": {
17
- "configKeys": 53,
18
- "sections": 14,
17
+ "configKeys": 57,
18
+ "sections": 15,
19
19
  "tools": 29,
20
20
  "baselineDate": "2026-10-23",
21
- "line": "config keys: 53 · sections: 14 · tools: 29"
21
+ "line": "config keys: 57 · sections: 15 · tools: 29"
22
22
  },
23
23
  "stateLock": {
24
24
  "held": true,
@@ -38,8 +38,8 @@ It returns:
38
38
  "heldMs": 92000
39
39
  },
40
40
  "stateSchema": {
41
- "currentVersion": 3,
42
- "supportedVersion": 3,
41
+ "currentVersion": 5,
42
+ "supportedVersion": 5,
43
43
  "migrations": [],
44
44
  "status": "current"
45
45
  },
@@ -31,7 +31,7 @@ tower_resource({action:"release", name:"heavy-ci", token:"<token from acquire>"}
31
31
 
32
32
  - acquire is tower-only; the caller gets a token, pid, holder, created/expiry timestamps;
33
33
  - the same token renews; another holder gets `[tower:lease-busy]`;
34
- - release requires the token; an **expired** lease still requires the token and explicit `force:true`
34
+ - release requires the holder token; an **expired** lease is recovered by an explicit `force:true` release (a live lease still refuses a foreign token)
35
35
  (no silent takeover);
36
36
  - `spawn.requireLease` makes a worker spawn fail unless that lease is held.
37
37