dsh-tower 2.4.2 → 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 (108) hide show
  1. package/CHANGELOG.md +72 -0
  2. package/COMPATIBILITY.md +2 -0
  3. package/CONTRIBUTING.md +5 -3
  4. package/GLOSSARY.md +73 -0
  5. package/PRINCIPLES.md +15 -7
  6. package/README.en.md +16 -15
  7. package/README.md +18 -10
  8. package/app/archive.js +16 -1
  9. package/app/board.js +16 -1
  10. package/app/gate.js +14 -0
  11. package/app/review.js +7 -0
  12. package/app/spawn-prechecks.js +30 -5
  13. package/app/tower.js +5 -5
  14. package/client.js +321 -0
  15. package/cordis.patch.yml +26 -9
  16. package/docs/adr/0004-surface-budget.md +10 -4
  17. package/docs/adr/0005-component-surface.md +222 -11
  18. package/docs/architecture/overview.md +12 -6
  19. package/docs/examples/first-mission.md +3 -2
  20. package/docs/faq.md +9 -7
  21. package/docs/guide/install-upgrade-migration.md +53 -7
  22. package/docs/guide/quickstart.md +91 -10
  23. package/docs/guide/release.md +60 -19
  24. package/docs/guide/scenarios-and-philosophy.md +35 -28
  25. package/docs/operations/security.md +11 -10
  26. package/docs/reference/blockers.md +12 -7
  27. package/docs/reference/component-surface.md +40 -21
  28. package/docs/reference/config.generated.md +6 -8
  29. package/docs/reference/config.md +36 -8
  30. package/docs/reference/doctor.md +11 -5
  31. package/docs/reference/errors.generated.md +1 -0
  32. package/docs/reference/errors.md +2 -1
  33. package/docs/reference/surface-budget.md +47 -12
  34. package/docs/reference/testing.md +1 -1
  35. package/docs/reference/tools.generated.md +5 -5
  36. package/docs/reference/tools.md +5 -5
  37. package/docs/zh/faq.md +7 -5
  38. package/docs/zh/guide/install-upgrade-migration.md +26 -7
  39. package/docs/zh/guide/quickstart.md +17 -10
  40. package/docs/zh/guide/scenarios-and-philosophy.md +23 -15
  41. package/docs/zh/operations/security.md +9 -8
  42. package/docs/zh/reference/blockers.md +11 -7
  43. package/docs/zh/reference/config.md +28 -8
  44. package/docs/zh/reference/doctor.md +7 -2
  45. package/docs/zh/reference/errors.md +2 -1
  46. package/docs/zh/reference/testing.md +2 -2
  47. package/docs/zh/reference/tools.md +6 -5
  48. package/domain/component-rules.js +216 -0
  49. package/domain/components.js +181 -53
  50. package/domain/config/schema.js +86 -28
  51. package/domain/contracts/outputs.js +2 -0
  52. package/domain/contracts/tool-inputs/missions.js +1 -1
  53. package/domain/errors.js +6 -0
  54. package/domain/gate.js +23 -5
  55. package/domain/integration/review.js +203 -9
  56. package/domain/scope.js +176 -5
  57. package/domain/surface.js +21 -7
  58. package/host/capability-archive.js +18 -0
  59. package/host/capability-board.js +18 -0
  60. package/host/capability-registry.js +4 -0
  61. package/host/config.js +40 -3
  62. package/host/doctor.js +68 -12
  63. package/host/presets.js +26 -8
  64. package/host/tool-registry.js +30 -5
  65. package/index.js +17 -6
  66. package/locale/archive/en.json +6 -0
  67. package/locale/archive/zh.json +6 -0
  68. package/locale/batch/en.json +1 -1
  69. package/locale/batch/zh.json +1 -1
  70. package/locale/board/en.json +6 -0
  71. package/locale/board/zh.json +6 -0
  72. package/locale/en.json +37 -22
  73. package/locale/hmr/en.json +2 -2
  74. package/locale/hmr/zh.json +1 -1
  75. package/locale/import/en.json +1 -1
  76. package/locale/import/zh.json +1 -1
  77. package/locale/leases/en.json +2 -2
  78. package/locale/leases/zh.json +2 -2
  79. package/locale/preset-reviewer/en.json +1 -1
  80. package/locale/preset-reviewer/zh.json +1 -1
  81. package/locale/preset-worker/en.json +1 -1
  82. package/locale/preset-worker/zh.json +1 -1
  83. package/locale/skills/en.json +1 -1
  84. package/locale/skills/zh.json +1 -1
  85. package/locale/tests/en.json +2 -2
  86. package/locale/tests/zh.json +2 -2
  87. package/locale/zh.json +36 -21
  88. package/package.json +17 -2
  89. package/scripts/check-components.mjs +698 -44
  90. package/scripts/check-pack.mjs +3 -0
  91. package/scripts/component-surface.json +101 -37
  92. package/scripts/generate-capability-locale.mjs +3 -0
  93. package/scripts/mount-smoke/surface.mjs +2 -0
  94. package/scripts/pack-manifest.txt +10 -0
  95. package/scripts/release-notes.mjs +256 -0
  96. package/scripts/surface-baseline.json +39 -3
  97. package/scripts/surface-baseline.mjs +15 -5
  98. package/scripts/types-coverage.mjs +7 -0
  99. package/skills.js +4 -1
  100. package/tools/complete.js +3 -1
  101. package/tools/preflight.js +1 -1
  102. package/tools/review.js +1 -1
  103. package/tools/spawn.js +7 -7
  104. package/tools/status.js +8 -1
  105. package/tools/test.js +7 -7
  106. package/types/index.d.ts +10 -0
  107. package/ui-bundle/locale/en.json +1 -1
  108. package/ui-bundle/locale/zh.json +1 -1
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
@@ -8,11 +8,13 @@
8
8
  #
9
9
  # Policy: principle 11 in PRINCIPLES.md + docs/adr/0005-component-surface.md.
10
10
  # The per-key answer is docs/reference/component-surface.md, generated from
11
- # scripts/component-surface.json. `npm run docs:check:components` refuses a new
12
- # switch, a missing locale label, or a moved component count without a written
13
- # decision. `tower_doctor` and `tower_recover` are part of the host and are never
14
- # switches (#122); a capability row owns exactly one optional capability, and
15
- # `tower_status` / `tower_doctor` name any row that is off.
11
+ # scripts/component-surface.json. `npm run docs:check:components` reads every
12
+ # shipped patch (this file and ui-bundle/cordis.patch.yml, which ships the
13
+ # tower-ui row) and refuses a row with no classification, a missing locale label,
14
+ # or a moved component count without a written decision. `tower_doctor` and
15
+ # `tower_recover` are part of the host and are never switches (#122); a capability
16
+ # row owns exactly one optional capability, and `tower_status` / `tower_doctor`
17
+ # name any row that is off.
16
18
  - insert:
17
19
  # Mandatory host anchor. Not an optional capability: off == uninstall.
18
20
  - id: tower
@@ -66,13 +68,28 @@
66
68
  name: 'dsh-tower/leases'
67
69
  - id: tower-tests
68
70
  name: 'dsh-tower/tests'
69
- # dev hot reload: shipped off. Enable the row for a linked dev profile.
71
+ # #133: the MISSIONS.md board is a capability, not the old `docs.board` key.
72
+ # Off writes no board and leaves an existing one to go stale; every tool and
73
+ # gate keeps working. `docs.boardLocation` stays genuine tuning.
74
+ - id: tower-board
75
+ name: 'dsh-tower/board'
76
+ # #135: archival is a capability, not the old `archive.enabled` key. Off keeps
77
+ # terminal missions hot in state.json, so the hot state keeps growing; merges,
78
+ # gates and every other capability keep working and nothing is deleted.
79
+ # `archive.afterDays` / `archive.maxMissions` stay genuine tuning.
80
+ - id: tower-archive
81
+ name: 'dsh-tower/archive'
82
+ # #141: developer tooling, not an end-user capability switch. It stays shipped
83
+ # off; the locale copy states that this row exists for linked-profile
84
+ # development only, so the plugin page does not read as a missing feature.
70
85
  - id: tower-hmr
71
86
  name: 'dsh-tower/hmr'
72
87
  disabled: true
73
- # Conditional optional component: off is supported only while
74
- # review.requireReview is false; with the gate on planning/mode entry refuse
75
- # 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.
76
93
  - id: preset-tower-reviewer
77
94
  name: 'dsh-tower/preset-reviewer'
78
95
  config:
@@ -30,7 +30,9 @@ note for each niche key.
30
30
 
31
31
  - Growth is visible in a diff instead of arriving one reasonable-looking key at a
32
32
  time.
33
- - A reduction is cheap: the baseline can be ratcheted down with no ADR.
33
+ - A reduction is cheap for `sections` and `tools`: the baseline can be ratcheted
34
+ down with no ADR. Since #138 the config-key count is two-way like the component
35
+ count (see [ADR 0005](0005-component-surface.md)).
34
36
  - The classification table is auditable, not decorative: the check fails when a
35
37
  live key or tool is missing from it.
36
38
  - The numbers are three lines in `tower_doctor` output, not a new dashboard.
@@ -53,8 +55,10 @@ extends the budget with a fourth — the number of user-visible component rows i
53
55
  `cordis.patch.yml` — and records it as its own `components` baseline entry with a
54
56
  two-way decision rule (a contraction needs a decision just like growth). The
55
57
  three original counts are still measured by `npm run surface:check`; the
56
- component count is validated by `npm run docs:check:components`. The historical
57
- decision above is unchanged: "three surfaces" describes the 2.3.0 rule.
58
+ component count is validated by `npm run docs:check:components`. ADR 0005's wave
59
+ 3 (#138) also makes `configKeys` two-way, so the cheap-shrink consequence above
60
+ now applies only to `sections` and `tools`. The historical decision above is
61
+ unchanged: "three surfaces" describes the 2.3.0 rule.
58
62
 
59
63
  ## Rejected alternatives
60
64
 
@@ -65,6 +69,8 @@ decision above is unchanged: "three surfaces" describes the 2.3.0 rule.
65
69
  - **A per-key count in each generated reference** — two references can disagree;
66
70
  one shared baseline plus one check keeps the counts equal.
67
71
  - **Only an ADR requirement** — a pure deletion would need paperwork even though it
68
- shrinks the surface; the matching-deletion path keeps reductions cheap.
72
+ shrinks the surface; the matching-deletion path keeps `sections`/`tools`
73
+ reductions cheap. The config-key surface is the exception since #138: a removed
74
+ switch is itself a capability decision, so it needs a dated record (ADR 0005).
69
75
  - **A GitHub issue label instead of a repo file** — the brake must run offline in
70
76
  `npm test` and in CI without a token.
@@ -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
@@ -128,3 +132,210 @@ this wave makes the shipped surface obey it instead of carrying debt.
128
132
  - **A GitHub issue label or a docs-only policy.** The brake must run offline in
129
133
  `npm test` and in CI without a token, and the policy must survive the closure
130
134
  of the issue that introduced it.
135
+
136
+ ## Addendum (wave 2): board, archive, UI and explicit development tooling
137
+
138
+ - Date: 2026-11-02
139
+ - Decisions: #132, #133, #134, #135, #141
140
+
141
+ Four components were component-shaped but sat outside the policy: `docs.board` and
142
+ `archive.enabled` were booleans in the config form, `tower-ui` shipped as a real
143
+ plugin-page row that no source-of-truth entry described, and the group rows never
144
+ stated their off consequence where a reader sees it. Wave 2 closes those gaps
145
+ without widening the mechanism.
146
+
147
+ - **`tower-board` (#133).** `docs.board` is deleted; the board is the optional
148
+ `tower-board` row with its own capability module. Its fallback: no `MISSIONS.md`
149
+ is written or refreshed, an existing board goes stale, and every state tool and
150
+ gate keeps working. `docs.boardLocation` stays a tuning key because it only
151
+ chooses a path. With the row off, the composition behaves like an all-Tower-off
152
+ profile for the board (nothing is written) while the rest of Tower keeps working.
153
+ - **`tower-archive` (#135).** `archive.enabled` is deleted; archival is the
154
+ optional `tower-archive` row. Its fallback is deliberately honest: terminal
155
+ missions stay hot in `state.json`, the hot state keeps growing, and `status` /
156
+ `doctor` keep loading their full history; merges and gates are unaffected and
157
+ nothing is deleted. `archive.afterDays` / `archive.maxMissions` stay tuning.
158
+ - **Removed keys fail loudly.** A config that still sets `docs.board` or
159
+ `archive.enabled` is rejected with `config-unknown-key` and an upgrade note that
160
+ names the replacing row and points here. There is no silent alias: a switch that
161
+ moved to the plugin page must not keep working from a config file.
162
+ - **`tower-ui` is classified (#134).** The UI plugin row is now a visible
163
+ component with a documented fallback (the web UI plugin does not load: no tool
164
+ cards, composer-dock summary or status strip; host tools, missions and the board
165
+ are unaffected). Because it ships in `ui-bundle/`, the gate reads **every**
166
+ shipped `cordis.patch.yml` (root and `ui-bundle/`), classifies every visible row
167
+ and enforces a label source for each: either a generated `<specifier>/locale`
168
+ row, or the row package's own `meta` label when the labels live there. A row
169
+ added to any shipped patch without a source-of-truth entry fails
170
+ `npm run docs:check:components`.
171
+ - **A group row states its consequence where it is read (#132).** The plugin page
172
+ renders the group row from the package `meta` block, so the off-equals-uninstall
173
+ consequence lives in the root `meta.description` in both languages, and the gate
174
+ pins it there instead of trusting only the `components` block. This is a label
175
+ fix, not a new mechanism.
176
+ - **`tower-hmr` stays shipped off (#141).** Moving it to a separate install path
177
+ would hide the switch and re-open the orphan-mount hazard; the row is kept as
178
+ explicit development tooling instead: its locale copy and its source-of-truth
179
+ fallback say that it exists for a linked dev profile, that a normal profile
180
+ behaves exactly as before, and that it is off on purpose.
181
+ - **Count decision.** The visible component count moved 8 → 9 → 10 → 11 across
182
+ #133, #135 and #134; the three dated steps are recorded in
183
+ `scripts/surface-baseline.json`. The config surface contracted 55 → 53, which the
184
+ other surfaces allow without a decision record.
185
+
186
+ ## Addendum (wave 3): close the gate's blind spots
187
+
188
+ - Date: 2026-11-03
189
+ - Decisions: #138
190
+
191
+ Wave 2 made the gate read every shipped row, but four ways to evade it remained:
192
+ a class was free text, the operational route table was scraped with a regex, only
193
+ the patches named in `COMPONENT_PATCH_FILES` were read, and only booleans were
194
+ reviewed. Wave 3 closes them without changing the shipped decisions: 53 config
195
+ keys, 11 visible components.
196
+
197
+ - **The class is checked against the key's shape (#138.1).** A boolean that
198
+ removes a capability cannot file as `optional-config` by assertion. Every live
199
+ boolean must be `core`, `optional-component` or `forbidden-to-disable`, or an
200
+ `optional-config` key carrying a dated `exemption` whose reason names its
201
+ tuning-only nature. The same rule covers the non-boolean behaviour knobs below.
202
+ An exemption is only legal on a reviewed boolean/knob, and its `ref` must exist.
203
+ - **The config-key count is two-way (#138.1).** Wave 2's closing note ("the config
204
+ surface contracted 55 → 53, which the other surfaces allow without a decision
205
+ record") is retracted. Removing a switch is a capability decision, so
206
+ `configKeys` now joins `components` in `SURFACE_TWO_WAY_KEYS`: any later increase
207
+ or decrease needs a dated decision in `scripts/surface-baseline.json`. The gate
208
+ requires the `date` field itself (`YYYY-MM-DD`) on every decision of a two-way
209
+ surface, so a bare from/to record fails even when its chain reaches the value
210
+ (review of #138). The 64 → 53 history is recorded as one dated decision there;
211
+ sections and tools keep the ADR 0004 cheap-shrink rule.
212
+ - **The route table is imported, not scraped (#138.2).** `host/tool-registry.js`
213
+ exports `OPERATIONAL_TOOLS`, and the gate imports that module instead of
214
+ regex-matching its source. A missing file, a failed import, or a missing/non-Map
215
+ export is `registry-unreadable` and fails the gate. An empty Map is still the
216
+ correct determined state (#122): "nothing is gated" is distinct from "could not
217
+ read".
218
+ - **Every shipped patch is discovered (#138.3).** The gate walks the package for
219
+ `cordis.patch.yml` and compares the result with `COMPONENT_PATCH_FILES` in both
220
+ directions. An unlisted patch fails `patch-unclassified`; a listed patch that is
221
+ missing fails `patch-missing`. The row checks stay bidirectional: every shipped
222
+ row needs a source-of-truth entry, and every source-of-truth row needs a shipped
223
+ row.
224
+ - **Behaviour knobs and row overrides are reviewed (#138.4).** `spawn.requireLease`
225
+ and `tests.admission` are recorded in `behaviorKnobs` and carry tuning-only
226
+ exemptions: the optional `tower-leases` / `tower-tests` row owns the capability,
227
+ while these keys choose the safety level or timing inside it. A row's scalar
228
+ `config:` overrides are now parsed and must name a live config key and be listed
229
+ in that component's `allowedOverrides`. A row that sets `docs.board`,
230
+ `archive.enabled` or `tests.enabled` therefore fails as `row-override-unknown`
231
+ (those capabilities are rows), a row that enables or disables a key it does not
232
+ own fails as `row-override-foreign`, and `allowedOverrides` may never name a
233
+ forbidden-to-disable key. Fixtures reproduce the #133/#135/#136 shapes and a
234
+ foreign override, proving they fail before the fix passes.
235
+
236
+ ## Addendum (wave 4): broad-audience labels and a copy gate
237
+
238
+ - Date: 2026-11-04
239
+ - Decisions: #137
240
+
241
+ Wave 3 made the gate fail closed, but every label it admitted still passed. 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
245
+ Chinese copy mixed 咨询式租约 / 重型工作 / untranslated `mission` and `preset`, and
246
+ the Web UI used 任务线 while the plugin page left `mission` in English. The counts
247
+ do not move: 53 config keys, 13 components, 11 visible.
248
+
249
+ - **The labels are rewritten for a reader, not for an operator (#137.1).** Every
250
+ visible row, the group row and the UI row now say what the capability is for
251
+ and what stops when it is off, in plain language. Config keys, tool ids, slash
252
+ commands and module specifiers are gone from `title`, `summary`, `disables` and
253
+ the two `meta` descriptions. The off copy says "refused with an explanation",
254
+ not "locked", and names Tower mode. The `tower-leases` fix also removes the F19
255
+ claim that "nothing else depends on a lease": a lease is coordination only, but
256
+ a worker configured to require one cannot start while the row is off; planning
257
+ and Tower-mode entry are refused with an explanation until the setting is
258
+ cleared or the row is re-enabled, and everything else keeps working.
259
+ - **One bilingual vocabulary, recorded (#137.2).** `GLOSSARY.md` is the single
260
+ table for mission/任务线, batch/批次, doctor/体检, board/看板, archive/归档,
261
+ preset/预设, worktree/工作树, review/审查, lease/租约, worker/执行代理 and
262
+ reviewer/审查代理. It also records the copy rules (two sentences, no
263
+ identifiers, truthful off effect, natural Chinese) and is a gate input: a label
264
+ that uses a term the table does not pair with its other-language field fails.
265
+ The Web client's own string catalogue shares this table but is migrated
266
+ separately; the runtime error copy in `host/presets.js` is likewise a follow-up.
267
+ - **The copy is checked mechanically (#137.3).** `checkLocaleCopy` runs over the
268
+ root locale pair, the ui-bundle meta pair and every generated row-locale pair.
269
+ It fails on config-key syntax, `tower_*` ids, `/tower` commands, `dsh-tower/*`
270
+ specifiers, empty fields, zh/en path drift and glossary mismatches in both
271
+ directions; the Chinese side may not leave glossary words untranslated.
272
+ `checkMetaRowCopy` pins the hand-written ui-bundle `meta.title` / `description`
273
+ to the root `components['tower-ui']` entry and the same composition the
274
+ generator renders, so the one row that ships its own label cannot drift.
275
+ `checkGlossaryFile` keeps `GLOSSARY.md` present and complete. Negative
276
+ fixtures in `tests/locale-copy.test.mjs` prove a raw key, a bare tool id and a
277
+ term mismatch fail in both languages before the fixed copy passes.
278
+ - **Review of #137 corrected three claims and hardened the guard.** The
279
+ `tower-tests` off copy promised that "merges … keep working", but
280
+ `domain/gate.js` emits the `tests-missing` merge blocker while the test
281
+ requirement is on and `host/presets.js` refuses planning and Tower-mode entry,
282
+ so the copy now says planning and entry are refused and merges stay blocked
283
+ until the requirement is cleared. The reviewer preset summary said "only used
284
+ when review before merging is required"; the row is usable while review is
285
+ optional and becomes a precondition for merging only when the requirement is
286
+ on, so the summary says that instead. The Chinese calques 锁状态的恢复出口,
287
+ 收口, 一项命名工作, 在线状态文件 and 关闭该行 were replaced with 卡住的锁如何
288
+ 恢复, 关闭该批次, 某一具体工作, 当前状态文件 and 关闭这一项. The guard now
289
+ normalizes full-width punctuation and matches case-insensitively, so
290
+ `Gates.requireTests`, `Tower_batch` and `gates。requireTests` fail; the worker
291
+ and reviewer role nouns accept an initial capital but exclude the product names
292
+ "Tower Worker" and "Tower Reviewer", in both the en→zh and zh→en directions.
293
+ `GLOSSARY.md` ships in the package (`package.json` `files`, pinned by
294
+ `scripts/pack-manifest.txt`) because it is a gate input, not repository-only
295
+ prose.
296
+ - **Rejected: a prose style guide with no gate.** It is what let wave 1–3 pass;
297
+ a reviewer's attention does not survive the next rename.
298
+ - **Rejected: translating the developer reference table.** The fallback text in
299
+ `scripts/component-surface.json` and the generated
300
+ `docs/reference/component-surface.md` is an operator reference for auditors, not
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.