dsh-tower 2.5.0 → 2.6.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 (63) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/COMPATIBILITY.md +1 -0
  3. package/CONTRIBUTING.md +1 -1
  4. package/GLOSSARY.md +6 -0
  5. package/PRINCIPLES.md +15 -7
  6. package/README.en.md +16 -15
  7. package/README.md +12 -10
  8. package/app/review.js +7 -0
  9. package/app/spawn-prechecks.js +12 -2
  10. package/app/tower.js +5 -5
  11. package/client.js +321 -0
  12. package/cordis.patch.yml +5 -3
  13. package/docs/adr/0005-component-surface.md +58 -13
  14. package/docs/architecture/overview.md +12 -6
  15. package/docs/examples/first-mission.md +3 -2
  16. package/docs/faq.md +9 -7
  17. package/docs/guide/install-upgrade-migration.md +32 -7
  18. package/docs/guide/quickstart.md +20 -10
  19. package/docs/guide/scenarios-and-philosophy.md +35 -28
  20. package/docs/operations/security.md +11 -10
  21. package/docs/reference/blockers.md +12 -7
  22. package/docs/reference/component-surface.md +14 -7
  23. package/docs/reference/config.generated.md +3 -2
  24. package/docs/reference/config.md +29 -3
  25. package/docs/reference/errors.generated.md +1 -0
  26. package/docs/reference/errors.md +2 -1
  27. package/docs/reference/surface-budget.md +20 -3
  28. package/docs/reference/testing.md +1 -1
  29. package/docs/reference/tools.generated.md +5 -5
  30. package/docs/reference/tools.md +3 -3
  31. package/docs/zh/faq.md +7 -5
  32. package/docs/zh/guide/install-upgrade-migration.md +25 -6
  33. package/docs/zh/guide/quickstart.md +17 -10
  34. package/docs/zh/guide/scenarios-and-philosophy.md +23 -15
  35. package/docs/zh/operations/security.md +9 -8
  36. package/docs/zh/reference/blockers.md +11 -7
  37. package/docs/zh/reference/config.md +21 -3
  38. package/docs/zh/reference/errors.md +2 -1
  39. package/docs/zh/reference/testing.md +2 -2
  40. package/docs/zh/reference/tools.md +3 -3
  41. package/domain/components.js +12 -4
  42. package/domain/config/schema.js +40 -5
  43. package/domain/contracts/outputs.js +2 -0
  44. package/domain/contracts/tool-inputs/missions.js +1 -1
  45. package/domain/errors.js +6 -0
  46. package/domain/gate.js +6 -3
  47. package/domain/integration/review.js +203 -9
  48. package/host/config.js +40 -3
  49. package/host/doctor.js +18 -7
  50. package/host/presets.js +9 -8
  51. package/host/tool-registry.js +9 -1
  52. package/package.json +7 -1
  53. package/scripts/check-components.mjs +120 -6
  54. package/scripts/check-pack.mjs +3 -0
  55. package/scripts/component-surface.json +16 -10
  56. package/scripts/pack-manifest.txt +1 -0
  57. package/scripts/surface-baseline.json +8 -1
  58. package/scripts/types-coverage.mjs +7 -0
  59. package/tools/preflight.js +1 -1
  60. package/tools/review.js +1 -1
  61. package/tools/spawn.js +2 -2
  62. package/tools/status.js +8 -1
  63. package/types/index.d.ts +10 -0
package/client.js ADDED
@@ -0,0 +1,321 @@
1
+ // dsh-tower — core Web Client half.
2
+ //
3
+ // The Host half of the bundle declares the profile config schema; this module
4
+ // adds the one editable control the Plugins page needs: a section in the
5
+ // `plugins.bundle.config` keyed slot for the `dsh-tower` bundle. The section is
6
+ // a single three-option radio group (off / optional / required) rendered from
7
+ // the Host-owned settings form. There is deliberately no component row for the
8
+ // review policy: rows are on/off capabilities, while the policy is one value
9
+ // with three states.
10
+ //
11
+ // Saving goes through `ctx.configForms.get('tower-host')` (the Host entry id in
12
+ // cordis.patch.yml). One `mutate([...])` call writes both review keys:
13
+ // `review.mode` becomes the selected value and the deprecated scalar alias
14
+ // `review.requireReview` is normalized to `false`, so the stored profile patch
15
+ // can never contain a contradiction. The Host schema marks both keys volatile, so
16
+ // the accepted values are reflected in place without reloading the plugin.
17
+ //
18
+ // The displayed selection is the *resolved* policy, not just `review.mode`: the
19
+ // alias decides when `mode` is absent, exactly like `resolveReviewPolicy` in
20
+ // domain/integration/review.js.
21
+ //
22
+ // No build step, no new dependency, no Host/Client package imports: React comes
23
+ // from the browser module table and theme colors use `--dsw-alias-*` tokens.
24
+ window.__ModuleLoader__.load({
25
+ id: 'dsh-tower',
26
+ factory(require) {
27
+ const React = require('react')
28
+ const h = React.createElement
29
+ const NS = 'dsh-tower'
30
+ const HOST_ENTRY = 'tower-host'
31
+ const MODES = ['off', 'optional', 'required']
32
+ const DEFAULT_MODE = 'optional'
33
+
34
+ const EN = {
35
+ 'config.title': 'Review policy',
36
+ 'config.intro': 'Choose how Tower treats review for this profile. This choice is saved immediately.',
37
+ 'config.current': 'Current choice: {mode}',
38
+ 'config.loading': 'Loading the saved review policy…',
39
+ 'config.unavailable': 'The review policy cannot be changed here right now. Try again later, or open the Tower host settings.',
40
+ 'config.readonly': 'This review policy is read-only in the current profile.',
41
+ 'config.saving': 'Saving…',
42
+ 'config.saved': 'Saved.',
43
+ 'config.saveFailed': 'The new choice could not be saved; the previous policy is still in effect.',
44
+ 'config.off.label': 'Off',
45
+ 'config.off.description': 'Review is unavailable: no review can be recorded, and merges never wait for one.',
46
+ 'config.optional.label': 'Optional',
47
+ 'config.optional.description': 'Review is available, but it never blocks a merge.',
48
+ 'config.required.label': 'Required',
49
+ 'config.required.description': 'A clean review at the branch tip blocks merge; the reviewer preset is required.',
50
+ 'config.restartHint':
51
+ 'This profile started with review off, so the merge gate already uses the new choice but the review tool appears only after a restart (or a profile reload).',
52
+ }
53
+
54
+ const ZH = {
55
+ 'config.title': '审查策略',
56
+ 'config.intro': '选择 Tower 为此配置处理审查的方式,选择后会立即保存。',
57
+ 'config.current': '当前选择:{mode}',
58
+ 'config.loading': '正在加载已保存的审查策略…',
59
+ 'config.unavailable': '暂时无法在此修改审查策略,请稍后再试,或打开 Tower 主机设置。',
60
+ 'config.readonly': '当前配置下审查策略为只读。',
61
+ 'config.saving': '正在保存…',
62
+ 'config.saved': '已保存。',
63
+ 'config.saveFailed': '新选择未能保存,仍使用之前的审查策略。',
64
+ 'config.off.label': '关闭',
65
+ 'config.off.description': '审查不可用:无法记录审查,合并也不会等待审查。',
66
+ 'config.optional.label': '可选',
67
+ 'config.optional.description': '审查可用,但不会阻止合并。',
68
+ 'config.required.label': '必需',
69
+ 'config.required.description': '分支末端的干净审查会阻止合并;需要审查代理预设。',
70
+ 'config.restartHint':
71
+ '此配置启动时审查为关闭状态:合并门禁已使用新的选择,但审查工具需要重启(或重新加载配置)后才会出现。',
72
+ }
73
+
74
+ function fill(text, params) {
75
+ return String(text ?? '').replace(/\{([A-Za-z0-9_]+)\}/g, (match, key) =>
76
+ params && params[key] !== undefined ? String(params[key]) : match,
77
+ )
78
+ }
79
+
80
+ function makeT(props) {
81
+ const seat = props && typeof props.t === 'function' ? props.t : null
82
+ return function t(key, params) {
83
+ if (seat) {
84
+ try {
85
+ const value = seat(key, params)
86
+ if (typeof value === 'string') return value
87
+ } catch {
88
+ /* fall back to the bundled English dictionary */
89
+ }
90
+ }
91
+ return fill(EN[key] !== undefined ? EN[key] : key, params)
92
+ }
93
+ }
94
+
95
+ function readForm(props) {
96
+ return props && props.form && typeof props.form.getSnapshot === 'function' ? props.form : null
97
+ }
98
+
99
+ function safeSnapshot(form) {
100
+ if (!form) return null
101
+ try {
102
+ return form.getSnapshot() ?? null
103
+ } catch {
104
+ return null
105
+ }
106
+ }
107
+
108
+ // The resolved policy: an explicit `mode` wins; otherwise the deprecated
109
+ // boolean alias decides (true -> required, false -> optional), like
110
+ // `resolveReviewPolicy`. Only a literal `true` can raise the mode, which is
111
+ // also the only value the resolver accepts beside a non-required mode.
112
+ function currentMode(snapshot) {
113
+ const review = (snapshot && snapshot.value && snapshot.value.review) || {}
114
+ if (MODES.includes(review.mode)) return review.mode
115
+ return review.requireReview === true ? 'required' : DEFAULT_MODE
116
+ }
117
+
118
+ // Both review keys are written together: the selected mode, plus the alias
119
+ // normalized to `false`. An explicit `false` is required — leaving the key
120
+ // unset would restore the inherited default, and a stale `true` would
121
+ // contradict a non-required mode and make `resolveTowerConfig` throw.
122
+ function reviewOps(mode) {
123
+ return [
124
+ { op: 'set', path: ['review', 'mode'], value: mode },
125
+ { op: 'set', path: ['review', 'requireReview'], value: false },
126
+ ]
127
+ }
128
+
129
+ function ReviewPolicySection(props) {
130
+ const t = makeT(props)
131
+ const form = readForm(props)
132
+ const [snapshot, setSnapshot] = React.useState(function () {
133
+ return safeSnapshot(form)
134
+ })
135
+ const [pending, setPending] = React.useState(null)
136
+ const [notice, setNotice] = React.useState(null)
137
+ // The policy this page first saw. A session whose profile started with
138
+ // review off never registered the review tool, so switching to
139
+ // optional/required takes effect at the merge gate immediately but needs a
140
+ // restart (or profile reload) before the tool exists. The control states
141
+ // that limitation instead of pretending the tool appeared.
142
+ const startMode = React.useRef(null)
143
+
144
+ React.useEffect(
145
+ function () {
146
+ if (!form) return undefined
147
+ const update = function () {
148
+ const next = safeSnapshot(form)
149
+ if (startMode.current === null && next && next.status === 'ready') {
150
+ startMode.current = currentMode(next)
151
+ }
152
+ setSnapshot(next)
153
+ }
154
+ update()
155
+ let unsubscribe
156
+ try {
157
+ unsubscribe = form.subscribe(update)
158
+ } catch {
159
+ unsubscribe = undefined
160
+ }
161
+ return function () {
162
+ if (typeof unsubscribe === 'function') unsubscribe()
163
+ }
164
+ },
165
+ [form],
166
+ )
167
+
168
+ const status = snapshot && typeof snapshot.status === 'string' ? snapshot.status : 'unavailable'
169
+ const writable = status === 'ready' && snapshot.writable !== false
170
+ const selection = currentMode(snapshot)
171
+ const restartNeeded = notice === 'saved' && startMode.current === 'off' && selection !== 'off'
172
+
173
+ function choose(next) {
174
+ if (!form || !writable || pending || next === selection) return
175
+ setPending(next)
176
+ setNotice(null)
177
+ let result
178
+ try {
179
+ result = form.mutate(reviewOps(next), snapshot ? snapshot.revision : undefined)
180
+ } catch {
181
+ setPending(null)
182
+ setNotice('saveFailed')
183
+ return
184
+ }
185
+ Promise.resolve(result).then(
186
+ function (accepted) {
187
+ setPending(null)
188
+ setSnapshot(safeSnapshot(form))
189
+ setNotice(accepted === false ? 'saveFailed' : 'saved')
190
+ },
191
+ function () {
192
+ setPending(null)
193
+ setSnapshot(safeSnapshot(form))
194
+ setNotice('saveFailed')
195
+ },
196
+ )
197
+ }
198
+
199
+ const statusText =
200
+ status === 'loading'
201
+ ? t('config.loading')
202
+ : status !== 'ready'
203
+ ? t('config.unavailable')
204
+ : snapshot && snapshot.writable === false
205
+ ? t('config.readonly')
206
+ : ''
207
+
208
+ return h(
209
+ 'section',
210
+ {
211
+ 'data-dsh-tower-review-policy': '',
212
+ style: {
213
+ display: 'grid',
214
+ gap: '10px',
215
+ padding: '16px',
216
+ border: '1px solid var(--dsw-alias-border-l2)',
217
+ borderRadius: '8px',
218
+ background: 'var(--dsw-alias-bg-module-platform)',
219
+ },
220
+ },
221
+ h('div', { style: { color: 'var(--dsw-alias-label-primary)', fontWeight: 600 } }, t('config.title')),
222
+ h('p', { style: { margin: 0, color: 'var(--dsw-alias-label-secondary)', fontSize: '13px' } }, t('config.intro')),
223
+ h(
224
+ 'div',
225
+ {
226
+ role: 'radiogroup',
227
+ 'aria-label': t('config.title'),
228
+ style: { display: 'grid', gap: '8px' },
229
+ },
230
+ MODES.map(function (mode) {
231
+ const active = selection === mode
232
+ return h(
233
+ 'label',
234
+ {
235
+ key: mode,
236
+ 'data-mode': mode,
237
+ style: {
238
+ display: 'grid',
239
+ gridTemplateColumns: 'auto 1fr',
240
+ gap: '8px',
241
+ alignItems: 'start',
242
+ padding: '8px',
243
+ borderRadius: '6px',
244
+ cursor: writable && !pending ? 'pointer' : 'default',
245
+ border: active ? '1px solid var(--dsw-alias-label-primary)' : '1px solid var(--dsw-alias-border-l2)',
246
+ },
247
+ },
248
+ h('input', {
249
+ type: 'radio',
250
+ name: 'dsh-tower-review-policy',
251
+ value: mode,
252
+ checked: active,
253
+ disabled: !writable || Boolean(pending),
254
+ onChange: function () {
255
+ choose(mode)
256
+ },
257
+ }),
258
+ h(
259
+ 'span',
260
+ { style: { display: 'grid', gap: '2px' } },
261
+ h('span', { style: { color: 'var(--dsw-alias-label-primary)', fontWeight: 500 } }, t(`config.${mode}.label`)),
262
+ h('span', { style: { color: 'var(--dsw-alias-label-secondary)', fontSize: '13px' } }, t(`config.${mode}.description`)),
263
+ ),
264
+ )
265
+ }),
266
+ ),
267
+ statusText
268
+ ? h('p', { style: { margin: 0, color: 'var(--dsw-alias-label-secondary)', fontSize: '13px' } }, statusText)
269
+ : null,
270
+ h(
271
+ 'p',
272
+ {
273
+ role: 'status',
274
+ 'aria-live': 'polite',
275
+ style: { margin: 0, color: 'var(--dsw-alias-label-secondary)', fontSize: '13px', minHeight: '18px' },
276
+ },
277
+ pending ? t('config.saving') : notice ? t(`config.${notice}`) : t('config.current', { mode: t(`config.${selection}.label`) }),
278
+ ),
279
+ restartNeeded
280
+ ? h(
281
+ 'p',
282
+ {
283
+ 'data-dsh-tower-restart-hint': '',
284
+ style: { margin: 0, color: 'var(--dsw-alias-label-secondary)', fontSize: '13px' },
285
+ },
286
+ t('config.restartHint'),
287
+ )
288
+ : null,
289
+ )
290
+ }
291
+
292
+ return {
293
+ inject: ['slots', 'locale'],
294
+ async apply(ctx) {
295
+ ctx.effect(function () {
296
+ return ctx.locale.register(NS, { en: EN, zh: ZH })
297
+ }, 'dsh-tower: review policy settings')
298
+ ctx.slots.inject('plugins.bundle.config', function () {
299
+ return ctx.slots.register(
300
+ {
301
+ name: 'plugins.bundle.config',
302
+ key: 'dsh-tower',
303
+ locale: NS,
304
+ inject: function () {
305
+ let forms
306
+ try {
307
+ forms = typeof ctx.get === 'function' ? ctx.get('configForms') : undefined
308
+ } catch {
309
+ forms = undefined
310
+ }
311
+ const form = forms && typeof forms.get === 'function' ? forms.get(HOST_ENTRY) : undefined
312
+ return { form }
313
+ },
314
+ },
315
+ ReviewPolicySection,
316
+ )
317
+ })
318
+ },
319
+ }
320
+ },
321
+ })
package/cordis.patch.yml CHANGED
@@ -85,9 +85,11 @@
85
85
  - id: tower-hmr
86
86
  name: 'dsh-tower/hmr'
87
87
  disabled: true
88
- # Conditional optional component: off is supported only while
89
- # review.requireReview is false; with the gate on planning/mode entry refuse
90
- # the combination with an explanatory error.
88
+ # Optional capability row, usable while the review policy (`review.mode`) is
89
+ # off or optional. With `review.mode:required`, planning/mode entry refuse this
90
+ # row being off with an explanatory error. The row is not mutually exclusive
91
+ # with the policy: the policy is one tri-state control on the plugin page,
92
+ # not a component row.
91
93
  - id: preset-tower-reviewer
92
94
  name: 'dsh-tower/preset-reviewer'
93
95
  config:
@@ -42,14 +42,17 @@ Adopt this shape and record it as principle 11:
42
42
  a documented fallback stating what still works while it is off. A capability
43
43
  qualifies only when its off state leaves the rest of Tower intact and is not
44
44
  worse than uninstalling.
45
- - **The safety nets are never switches.** The health contract, the recovery
46
- escape, the review contract and the merge gates are configuration or code; no
47
- group, row or boolean key may gate them. Enforcement covers both routes: a
48
- `cordis.patch.yml` row that defaults any key classified `forbidden-to-disable`
49
- to `false` is refused, and every `tools.expose*` route in
50
- `host/tool-registry.js` must match the `gateableTools` allowlist in the source
51
- of truth, so a new route to a review, merge or host tool fails even when that
52
- capability lists no tools of its own.
45
+ - **The safety nets are never switches.** The health contract, the recovery escape
46
+ and the merge gates are configuration or code; no group, row or boolean key may
47
+ gate them. The review policy is configuration too — one tri-state control
48
+ (`off`/`optional`/`required`) on the plugin-page form, not a component row and
49
+ never one row per mode — while the review gate in required mode (clean,
50
+ tip-bound evidence and the exemption path) is contract and cannot be switched
51
+ off by a row. Enforcement covers both routes: a `cordis.patch.yml` row that
52
+ defaults any key classified `forbidden-to-disable` to `false` is refused, and
53
+ every `tools.expose*` route in `host/tool-registry.js` must match the
54
+ `gateableTools` allowlist in the source of truth, so a new route to a review,
55
+ merge or host tool fails even when that capability lists no tools of its own.
53
56
  - **Config keys stay on the component axis.** Every config key is classified
54
57
  `core`, `optional-component`, `optional-config` or `forbidden-to-disable` in
55
58
  `docs/reference/component-surface.md`, generated from the single source of
@@ -80,9 +83,10 @@ The one `tower-bundle` group is gone. `cordis.patch.yml` now ships the mandatory
80
83
  `tower` host anchor (the Host plugin and the worker preset are its internal
81
84
  sections) plus the individually switchable top-level rows `tower-skills`,
82
85
  `tower-batch`, `tower-import`, `tower-leases`, `tower-tests`, `tower-hmr`
83
- (shipped off) and `preset-tower-reviewer` (conditional on
84
- `review.requireReview`). The visible component count moved 1 → 8 and is recorded
85
- as a dated decision in `scripts/surface-baseline.json`.
86
+ (shipped off) and `preset-tower-reviewer` (conditional on the review policy;
87
+ today the reviewer row may be off unless `review.mode` is `required`). The
88
+ visible component count moved 1 → 8 and is recorded as a dated decision in
89
+ `scripts/surface-baseline.json`.
86
90
 
87
91
  The capability switches became rows: the seven `tools.expose*` keys and
88
92
  `dev.hotReload` were deleted from the schema and are now rejected as unknown
@@ -235,8 +239,9 @@ keys, 11 visible components.
235
239
  - Decisions: #137
236
240
 
237
241
  Wave 3 made the gate fail closed, but every label it admitted still passed. The
238
- plugin page leaked config keys (`gates.requireTests:true`, `review.requireReview`)
239
- and bare tool ids (`tower_batch`, `tower_resource`) into the off-state copy, the
242
+ plugin page leaked config keys (`gates.requireTests:true`, the deprecated
243
+ `review.requireReview` alias) and bare tool ids (`tower_batch`, `tower_resource`)
244
+ into the off-state copy, the
240
245
  Chinese copy mixed 咨询式租约 / 重型工作 / untranslated `mission` and `preset`, and
241
246
  the Web UI used 任务线 while the plugin page left `mission` in English. The counts
242
247
  do not move: 53 config keys, 13 components, 11 visible.
@@ -294,3 +299,43 @@ do not move: 53 config keys, 13 components, 11 visible.
294
299
  `scripts/component-surface.json` and the generated
295
300
  `docs/reference/component-surface.md` is an operator reference for auditors, not
296
301
  a plugin-page label. It may name keys and tools; the locale fields may not.
302
+
303
+ ## Addendum (wave 5): the review policy is a tri-state control, not a component row
304
+
305
+ - Date: 2026-11-01
306
+ - Decisions: #M39, #M40, #M42
307
+
308
+ Waves 1–4 listed "the review contract" among the things that are never switches.
309
+ That wording was honest while review was a bare boolean, but it became wrong when
310
+ #M39 made the policy a three-value mode and #M40 put one control for it on the
311
+ plugin page. Wave 5 states the actual contract:
312
+
313
+ - **`review.mode` is the policy, not a capability row.** It has exactly three
314
+ values — `off`, `optional` (the default) and `required` — and the plugin-pages
315
+ control is one tri-state radio group on the `dsh-tower` configuration form.
316
+ There is no review-policy component row, and there are no three mutually
317
+ exclusive rows. `off` is supported and documented: `tower_review` is not
318
+ registered, reviewer spawn is refused, and the merge gate ignores review.
319
+ `optional` keeps `tower_review` and never blocks a merge. `required` keeps the
320
+ merge gate: a completed mission needs a clean review bound to the branch tip,
321
+ with the existing exemption path.
322
+ - **`review.requireReview` is a deprecated one-release alias.** It is classified
323
+ `core` (not `forbidden-to-disable`) because it is policy configuration and a
324
+ compatibility alias, not a switch a row may own. `true` resolves to `required`,
325
+ `false` to `optional`, and leaving it unset means `optional`. `true` next to an
326
+ explicit non-required mode is rejected; `false` never overrides an explicit
327
+ mode. The alias is removed after the 2.6 window.
328
+ - **What stays non-disableable is the gate in `required` mode.** The merge gate's
329
+ review evidence and tip binding, and the exemption path, may not be removed by
330
+ any group, row or boolean. The reviewer preset row stays an optional
331
+ capability: it may be off while the policy is `off` or `optional`, and under
332
+ `required` planning and mode entry refuse that combination with an explanation.
333
+ It is not a mutually exclusive row — the policy is not a row at all.
334
+ - **The gate enforces the shape.** A review-policy-shaped component row, a
335
+ component `allowedOverrides` or shipped row `config:` that names `review.mode`
336
+ (or the alias), a missing required-mode gate record, or a forbidden capability
337
+ that lists `preset-tower-reviewer` all fail
338
+ `npm run docs:check:components`.
339
+ - **Counts do not move.** 54 config keys, 11 visible components; only the class of
340
+ one deprecated key changes. The source of truth carries the decision date
341
+ (`2026-11-01`) and this addendum is its written decision.
@@ -12,9 +12,14 @@ escape keep working.
12
12
 
13
13
  [Principle 11](../../PRINCIPLES.md) and
14
14
  [ADR 0005](../adr/0005-component-surface.md) say a component may exist only for
15
- a capability whose off state leaves the rest intact, and that the host, health,
16
- recovery, review and merge-gate capabilities are never switches. Which keys are
17
- allowed to be switches is a table lookup in
15
+ a capability whose off state leaves the rest intact, and that the host, the
16
+ health contract, the recovery escape and the merge gates are never switches.
17
+ The review policy is not one of them: `review.mode` is a single tri-state
18
+ control (`off`, `optional` or `required`) on the plugin-page configuration
19
+ form, not a component row and never one row per mode, and only the review gate
20
+ it turns on in `required` mode — the clean, tip-bound evidence and the
21
+ exemption path — is non-disableable. Which keys are allowed to be switches is a
22
+ table lookup in
18
23
  [component-surface.md](../reference/component-surface.md), generated from
19
24
  `scripts/component-surface.json`; the component count is baselined and checked by
20
25
  `npm run docs:check:components`. That gate also refuses a `tools.expose*` route
@@ -236,9 +241,10 @@ set by `tower_merge`/reconciliation.
236
241
  3. Survey missions must not change files; build missions must stay inside `scope`.
237
242
  4. The main checkout must be clean for the files this merge would overwrite and on the base branch.
238
243
  5. Dependency missions must be merged/abandoned, except a completed survey dependency which is its terminal success state (#107).
239
- 6. If `review.requireReview:true` (default false), a review must exist, belong to this mission, be
240
- clean, approve merge, and be bound to the tip (unless a clean rebase waived it). No worker may be
241
- running.
244
+ 6. If the review policy is `required` (`review.mode:required`; the default is `optional`), a review
245
+ must exist, belong to this mission, be clean, approve merge, and be bound to the tip (unless a clean
246
+ rebase waived it). With `off`, review is intentionally unavailable and the gate ignores it. No
247
+ worker may be running.
242
248
 
243
249
  ## Merge
244
250
 
@@ -23,8 +23,9 @@ Wait for the worker's final report (delivered to this session) or check `tower_r
23
23
  creates `src/greet.txt`, commits on `tower/m1-add-greeting`, marks both tasks done, and calls
24
24
  `tower_complete`.
25
25
 
26
- This example follows the strict workflow: enable `review.requireReview:true` in the profile or
27
- repository config before the review step (default is optional review). Spawn the reviewer:
26
+ This example follows the strict workflow: set the review policy to `required` (`review.mode:required`
27
+ in the profile or repository config, or the one tri-state control on the Plugins page) before the
28
+ review step; the default is `optional`. Spawn the reviewer:
28
29
 
29
30
  ```json
30
31
  {"repoRoot":"D:\\work\\demo","missionId":"M1","role":"reviewer"}
package/docs/faq.md CHANGED
@@ -14,9 +14,9 @@ phase instead—use Workflow/Team to analyze and decide, then start Tower to exe
14
14
  review) the repository change. See the [full guide](guide/scenarios-and-philosophy.md#4-how-tower-differs-from-other-dsh-workflow-patterns).
15
15
 
16
16
  **Why did `tower_preflight` fail with `no-review` right after the worker finished?**
17
- That happens only when `review.requireReview:true` is enabled: a completed mission awaits an
18
- independent review. Spawn a reviewer; after a clean review the gate turns green. With the default
19
- `review.requireReview:false`, review is optional and no `no-review` blocker is produced.
17
+ That happens only when the review policy is `required` (`review.mode:required`): a completed mission
18
+ awaits an independent review. Spawn a reviewer; after a clean review the gate turns green. With the
19
+ default `review.mode:optional`, review is optional and no `no-review` blocker is produced.
20
20
 
21
21
  **Do I need to poll the worker?**
22
22
  No. The worker's final report is delivered to this session; `tower_roster` shows live activity. Polling
@@ -40,10 +40,12 @@ Correct: the branch is already contained in the base. Call `tower_merge` to reco
40
40
  instead of rebasing.
41
41
 
42
42
  **Can I require independent review?**
43
- Yes. Set `review.requireReview:true` in the profile or repository config to enable the global review
44
- gate (default is `false`, review optional). When enabled, mission-level `reviewPolicy:"none"` + a
45
- reason + `gates.allowReviewExemption:true` is the narrower exemption. `tower_review` works and records
46
- verdicts either way.
43
+ Yes. Set the review policy to `required` — the one tri-state control on the Plugins page, or
44
+ `review.mode:required` in the profile or repository config (the default is `optional`). When required,
45
+ mission-level `reviewPolicy:"none"` + a reason + `gates.allowReviewExemption:true` is the narrower
46
+ exemption. `tower_review` works and records verdicts in `optional` mode too. The old boolean toggle is
47
+ a deprecated one-release alias; the mapping is in
48
+ [install, upgrade and migration](guide/install-upgrade-migration.md).
47
49
 
48
50
  **Can a reviewer edit files?**
49
51
  No. Reviewers use the read-only `tower-reviewer` preset (no write/edit/pwsh); a branch changed after the
@@ -29,6 +29,29 @@ The Host plugin loads at DSH startup: **restart DSH after install or update**.
29
29
 
30
30
  ## Migration notes
31
31
 
32
+ - **2.5.0 → 2.6.0 (#M39/#M40) — review modes replace the boolean**: the review policy is one
33
+ tri-state control on the Plugins page in the `dsh-tower` configuration section, and the config key
34
+ is `review.mode`: `off`, `optional` (the default) or `required`.
35
+ - `off` is supported and documented: `tower_review` is not registered, reviewer spawn is refused,
36
+ and the merge gate ignores review.
37
+ - `optional` keeps `tower_review` but never blocks a merge.
38
+ - `required` keeps the merge gate: a completed mission needs a clean review bound to the branch tip
39
+ (the existing per-mission exemption still applies).
40
+ `review.requireReview` is a deprecated one-release alias: `true` resolves to `required`, `false`
41
+ to `optional`, and leaving it unset means `optional`. A `true` next to an explicit non-`required`
42
+ mode is rejected with `config-invalid`; `false` never overrides an explicit mode. Saving from the
43
+ plugin page always writes `review.mode` and normalizes the alias to `false`. The policy is not a
44
+ component row and there are no mutually exclusive review rows: `preset-tower-reviewer` remains an
45
+ optional capability the policy may leave unused. The row may be off while `review.mode` is `off`
46
+ or `optional`; when `review.mode` is `required` and the reviewer row is off, planning and mode
47
+ entry refuse the combination with an explanatory `preset-missing` error.
48
+ A profile that starts with the policy `off` never registered `tower_review`; switching to
49
+ `optional` or `required` changes the merge gate immediately, but the tool appears only after a DSH
50
+ restart (or a profile reload), and the plugin page states that after such a save.
51
+ Upgrade: replace `review.requireReview: true|false` with `review.mode: required|optional`; keep the
52
+ alias only if the same config is shared with older 2.5 installations, and remove it after this
53
+ release. No state migration.
54
+
32
55
  - **2.4.2 → 2.5.0 (#131/#133/#135/#136/#142)**: three component-shaped config
33
56
  keys are gone — `tests.enabled`, `docs.board`, and `archive.enabled`. The
34
57
  `tower-tests`, `tower-board`, and `tower-archive` component rows are the only
@@ -70,14 +93,14 @@ The Host plugin loads at DSH startup: **restart DSH after install or update**.
70
93
  now ships the mandatory `tower` host anchor (the Host plugin and the worker preset are its internal
71
94
  sections) plus individually switchable top-level rows: `tower-skills`, `tower-batch`, `tower-import`,
72
95
  `tower-leases`, `tower-tests`, `tower-hmr` (shipped off) and `preset-tower-reviewer` (conditional on
73
- `review.requireReview`). The `tools` config group and `dev.hotReload` were removed: a profile that
74
- still carries them is now rejected as an unknown key (`config-unknown-key`) and the host stops
96
+ the review policy, today `review.mode`). The `tools` config group and `dev.hotReload` were removed: a
97
+ profile that still carries them is now rejected as an unknown key (`config-unknown-key`) and the host stops
75
98
  mounting until they are deleted, so no silent no-op switch remains. Move each capability to its row
76
99
  in the plugin page instead; `tower-hmr` ships off, so `tower_reload` and `/tower reload` are reported
77
100
  as "disabled by component tower-hmr" until you enable it for a linked dev profile. `tower_doctor` and
78
101
  `tower_recover` are registered for every configuration and need no key. The reviewer row may be off
79
- only while `review.requireReview` is false; with the gate on, planning and mode entry refuse the
80
- combination with an explanatory error. No state migration is needed.
102
+ while the policy is `off` or `optional`; with `review.mode:required`, planning and mode entry refuse
103
+ the combination with an explanatory error. No state migration is needed.
81
104
 
82
105
  - **2.0.4 → 2.1.0-beta.1**: no config migration; the state file migrates automatically (v1/v2 → v3) as
83
106
  described above. This is a **pre-release** published on the
@@ -89,10 +112,12 @@ The Host plugin loads at DSH startup: **restart DSH after install or update**.
89
112
  runs `npm publish --tag next`, and `latest` stays on 2.0.4 until a stable release.
90
113
  - **2.0.3 → 2.0.4**: no state/config migration. This is a security-hardening and integrity release
91
114
  (authorization, merge SHA binding, dirty-worktree completion, stale-lock recovery, limited test/git
92
- process environment, `tower_recover`, UI 0.2.2). Review `review.requireReview`, `tests.admission` and
93
- worker tool permissions against your threat model after upgrading.
115
+ process environment, `tower_recover`, UI 0.2.2). Review the review policy (`review.mode`, or the
116
+ deprecated `review.requireReview` alias), `tests.admission` and worker tool permissions against your
117
+ threat model after upgrading.
94
118
  - **2.0.1 → 2.0.3**: no state migration. Independent review became **optional by default** in 2.0.3
95
- (`review.requireReview:false`); set it to `true` to keep the old review-gated merges. 2.0.1 added
119
+ (today's `review.mode:optional`); set `review.mode:required` to keep the old review-gated merges.
120
+ 2.0.1 added
96
121
  `tower_batch` `abandon`, accepted merged members in batch close, made doctor skip torn-down worktrees,
97
122
  and fixed terminal-mission spawn/preflight edge cases. The optional `ui-bundle` 0.2.1 adds the
98
123
  persistent Tower-mode chip and mode-aware denied-tool rows.
@@ -33,15 +33,25 @@ session. Use `tower_roster` to observe activity; do not poll in a loop.
33
33
 
34
34
  ## 4. Optional: review
35
35
 
36
- Independent review is optional by default. If you want the strict workflow, enable it in the profile
37
- or repository config first (`review.requireReview:true`), then:
36
+ The review policy is one tri-state control — `off`, `optional` (the default) or `required` — on the
37
+ Plugins page in the `dsh-tower` configuration section, or `review.mode` in the profile/repository
38
+ config. It is one control, not a component row and not three rows. Set it to `required` for the strict
39
+ workflow, then:
38
40
 
39
41
  ```
40
42
  tower_spawn repoRoot=D:\work\repo missionId=M1 role=reviewer
41
43
  ```
42
44
 
43
45
  Reviewers use the read-only `tower-reviewer` preset. They inspect `tower_diff` and submit
44
- `tower_review`; with `review.requireReview:true`, a `clean` + `merge` verdict is required to merge.
46
+ `tower_review`; with `review.mode:required`, a `clean` + `merge` verdict is required to merge.
47
+ `optional` records reviews but never blocks a merge, and `off` is a supported mode that does not
48
+ register `tower_review`.
49
+
50
+ The old boolean review toggle is a one-release alias (`true` → `required`, `false` → `optional`;
51
+ unset → `optional`). Prefer `review.mode`. A profile that started with
52
+ `off` only registers `tower_review` after a restart/reload, although the merge gate changes
53
+ immediately. See [install, upgrade and migration](install-upgrade-migration.md) for the full
54
+ migration.
45
55
 
46
56
  ## 5. Gate, merge, clean up
47
57
 
@@ -51,10 +61,10 @@ tower_merge repoRoot=D:\work\repo missionId=M1
51
61
  tower_teardown repoRoot=D:\work\repo
52
62
  ```
53
63
 
54
- With the review gate enabled, `tower_preflight` before review reports `no-review`, and after a clean
55
- review it should be `ok:true`. With the default (`review.requireReview:false`), no review blocker is
56
- required. `tower_merge` refuses while blockers remain. `tower_teardown` removes merged/abandoned
57
- worktrees and branches.
64
+ With `review.mode:required`, `tower_preflight` before review reports `no-review`, and after a clean
65
+ review it should be `ok:true`. With the default (`optional`), no review blocker is required.
66
+ `tower_merge` refuses while blockers remain. `tower_teardown` removes merged/abandoned worktrees and
67
+ branches.
58
68
 
59
69
  ```
60
70
  /tower off
@@ -63,8 +73,8 @@ worktrees and branches.
63
73
  ## If something fails
64
74
 
65
75
  Read the blocker code and use [the blocker reference](../reference/blockers.md). The most common cases:
66
- `stale-base` (run `tower_rebase`), `no-review` (only with `review.requireReview:true`; spawn a
67
- reviewer), `merge-hook-failed` (retry, or raise `timeoutMs`), `tip-moved` (only with the review gate;
76
+ `stale-base` (run `tower_rebase`), `no-review` (only with `review.mode:required`; spawn a reviewer),
77
+ `merge-hook-failed` (retry, or raise `timeoutMs`), `tip-moved` (only when the policy is `required`;
68
78
  the branch changed after review, review again).
69
79
 
70
80
  Thrown tool failures carry a `[tower:<code>]` prefix; see [error codes](../reference/errors.md)
@@ -78,7 +88,7 @@ JSON file at `<git-common-dir>/dsh-tower/config.json` (usually `.git/dsh-tower/c
78
88
 
79
89
  ```json
80
90
  {
81
- "review": { "requireReview": true, "maxRoundsPerReviewer": 3 },
91
+ "review": { "mode": "required", "maxRoundsPerReviewer": 3 },
82
92
  "merge": { "timeoutMs": 1800000 },
83
93
  "worktrees": { "root": "D:/tower-worktrees" }
84
94
  }