dsh-tower 2.5.0 → 2.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (142) hide show
  1. package/CHANGELOG.md +114 -0
  2. package/COMPATIBILITY.md +2 -0
  3. package/CONTRIBUTING.md +1 -1
  4. package/GLOSSARY.md +6 -0
  5. package/PRINCIPLES.md +15 -7
  6. package/README.en.md +18 -16
  7. package/README.md +18 -11
  8. package/adapters/leases.js +20 -6
  9. package/adapters/legacy.js +149 -37
  10. package/adapters/stateFile.js +11 -1
  11. package/app/archive.js +30 -5
  12. package/app/briefing.js +95 -12
  13. package/app/checkCompletion.js +107 -18
  14. package/app/enrichedMissions.js +1 -1
  15. package/app/freshness.js +77 -0
  16. package/app/gate.js +26 -12
  17. package/app/merge.js +7 -4
  18. package/app/mission.js +24 -1
  19. package/app/missionContext.js +2 -1
  20. package/app/plan.js +64 -1
  21. package/app/provision.js +133 -22
  22. package/app/read.js +38 -5
  23. package/app/rebase.js +10 -8
  24. package/app/review.js +104 -61
  25. package/app/reviewCheckout.js +133 -0
  26. package/app/sandbox.js +121 -0
  27. package/app/sharedCheckout.js +90 -0
  28. package/app/spawn-prechecks.js +31 -20
  29. package/app/spawn-support.js +59 -8
  30. package/app/spawn.js +3 -0
  31. package/app/teardown.js +119 -2
  32. package/app/tower.js +34 -6
  33. package/client.js +321 -0
  34. package/cordis.patch.yml +55 -3
  35. package/docs/adr/0005-component-surface.md +58 -13
  36. package/docs/adr/0006-mission-shapes.md +306 -0
  37. package/docs/architecture/overview.md +18 -10
  38. package/docs/examples/first-mission.md +3 -2
  39. package/docs/faq.md +9 -7
  40. package/docs/guide/install-upgrade-migration.md +58 -7
  41. package/docs/guide/quickstart.md +20 -10
  42. package/docs/guide/scenarios-and-philosophy.md +35 -28
  43. package/docs/operations/runbook.md +1 -1
  44. package/docs/operations/security.md +11 -10
  45. package/docs/reference/blockers.md +20 -11
  46. package/docs/reference/component-surface.md +20 -8
  47. package/docs/reference/config.generated.md +8 -5
  48. package/docs/reference/config.md +30 -5
  49. package/docs/reference/doctor.md +7 -7
  50. package/docs/reference/errors.generated.md +1 -0
  51. package/docs/reference/errors.md +2 -1
  52. package/docs/reference/operations.md +1 -1
  53. package/docs/reference/shapes.md +123 -0
  54. package/docs/reference/surface-budget.md +53 -4
  55. package/docs/reference/testing.md +5 -3
  56. package/docs/reference/tools.generated.md +7 -7
  57. package/docs/reference/tools.md +5 -5
  58. package/docs/zh/faq.md +7 -5
  59. package/docs/zh/guide/install-upgrade-migration.md +25 -6
  60. package/docs/zh/guide/quickstart.md +17 -10
  61. package/docs/zh/guide/scenarios-and-philosophy.md +23 -15
  62. package/docs/zh/operations/security.md +9 -8
  63. package/docs/zh/reference/blockers.md +13 -9
  64. package/docs/zh/reference/config.md +22 -5
  65. package/docs/zh/reference/errors.md +2 -1
  66. package/docs/zh/reference/testing.md +5 -4
  67. package/docs/zh/reference/tools.md +3 -3
  68. package/domain/batch.js +75 -105
  69. package/domain/collab/batch.js +30 -10
  70. package/domain/collab/spawn.js +53 -16
  71. package/domain/components.js +12 -4
  72. package/domain/config/schema.js +87 -22
  73. package/domain/contracts/outputs.js +2 -1
  74. package/domain/contracts/tool-inputs/missions.js +10 -2
  75. package/domain/errors.js +6 -0
  76. package/domain/evidence.js +283 -0
  77. package/domain/gate.js +17 -34
  78. package/domain/integration/gate.js +17 -43
  79. package/domain/integration/init.js +3 -2
  80. package/domain/integration/provision.js +51 -0
  81. package/domain/integration/rebase.js +16 -1
  82. package/domain/integration/review.js +203 -9
  83. package/domain/mission.js +16 -33
  84. package/domain/missions/completion.js +46 -8
  85. package/domain/missions/findings.js +2 -1
  86. package/domain/missions/lifecycle.js +131 -35
  87. package/domain/missions/plan.js +30 -6
  88. package/domain/missions/review.js +2 -18
  89. package/domain/planning.js +5 -5
  90. package/domain/read/missionContext.js +17 -3
  91. package/domain/read/views.js +5 -1
  92. package/domain/roles.js +222 -0
  93. package/domain/roster.js +9 -3
  94. package/domain/scope.js +51 -0
  95. package/domain/shapes/build.js +254 -0
  96. package/domain/shapes/index.js +171 -0
  97. package/domain/shapes/survey.js +445 -0
  98. package/domain/state/archive.js +28 -4
  99. package/domain/state/migrations.js +111 -4
  100. package/domain/state/schema.js +69 -7
  101. package/domain/state/semantics.js +93 -13
  102. package/domain/state.js +5 -1
  103. package/domain/summary.js +5 -11
  104. package/domain/vocabulary.js +15 -33
  105. package/host/capability-registry.js +25 -9
  106. package/host/config.js +40 -3
  107. package/host/doctor.js +20 -8
  108. package/host/preset-survey-sandbox.js +19 -0
  109. package/host/preset-survey.js +18 -0
  110. package/host/presets.js +17 -11
  111. package/host/tool-registry.js +23 -13
  112. package/locale/en.json +16 -1
  113. package/locale/preset-survey/en.json +6 -0
  114. package/locale/preset-survey/zh.json +6 -0
  115. package/locale/preset-survey-sandbox/en.json +6 -0
  116. package/locale/preset-survey-sandbox/zh.json +6 -0
  117. package/locale/zh.json +16 -1
  118. package/package.json +19 -2
  119. package/ports/state.js +8 -1
  120. package/scripts/architecture-waivers.json +3 -3
  121. package/scripts/check-components.mjs +120 -6
  122. package/scripts/check-docs.mjs +1 -1
  123. package/scripts/check-pack.mjs +5 -0
  124. package/scripts/component-surface.json +83 -29
  125. package/scripts/generate-capability-locale.mjs +5 -0
  126. package/scripts/generate-shapes-reference.mjs +171 -0
  127. package/scripts/mount-smoke/surface.mjs +3 -0
  128. package/scripts/pack-manifest.txt +22 -0
  129. package/scripts/shape-audit.mjs +361 -0
  130. package/scripts/surface-baseline.json +75 -4
  131. package/scripts/types-coverage.mjs +7 -0
  132. package/survey-sandbox.js +48 -0
  133. package/survey.js +40 -0
  134. package/tools/complete.js +5 -5
  135. package/tools/diff.js +5 -3
  136. package/tools/plan.js +1 -1
  137. package/tools/preflight.js +3 -2
  138. package/tools/review.js +1 -1
  139. package/tools/spawn.js +48 -50
  140. package/tools/status.js +8 -1
  141. package/tools/test.js +2 -1
  142. package/types/index.d.ts +76 -5
package/app/tower.js CHANGED
@@ -8,6 +8,7 @@ import { assertPorts } from '../ports/index.js'
8
8
  import { validateCreate as validateBatchCreate, validateClose as validateBatchClose, summarize as summarizeBatch } from '../domain/batch.js'
9
9
 
10
10
  import { rebaseWaived } from '../domain/gate.js'
11
+ import { isReviewExempt } from '../domain/integration/review.js'
11
12
  import { DEFAULT_MERGE_TIMEOUT_MS, gitLockTimeoutMs } from '../domain/lockBudget.js'
12
13
  export { ROSTER_INACTIVE_STATES } from '../domain/roster.js'
13
14
 
@@ -27,6 +28,7 @@ import { writeBoard, boardFile } from './board.js'
27
28
  import { archiveIndex, archivedMission } from './archive.js'
28
29
  import { recordSpawnOutcome, recordInterrupt, reserveRoster, recordSpawn, rosterView } from './spawn.js'
29
30
  import { recordTestRun, recordLegacySnapshot, recordLegacyMap } from './recording.js'
31
+ import { resolveLegacySurveyRead } from './sharedCheckout.js'
30
32
 
31
33
  export const ROLES = ['worker', 'reviewer']
32
34
  const MAX_REVIEW_ROUNDS = 5
@@ -98,6 +100,7 @@ import { rebase } from './rebase.js'
98
100
  import { merge } from './merge.js'
99
101
  import { init } from './init.js'
100
102
  import { provision } from './provision.js'
103
+ import { provisionReviewCheckout } from './reviewCheckout.js'
101
104
  import { addFinding, readFindings, findings } from './findings.js'
102
105
  import { recordMessage, recordDelivery, inbox } from './messages.js'
103
106
  import { createBatch, closeBatch, abandonBatch, batchStatus } from './batch.js'
@@ -169,6 +172,20 @@ export class Tower {
169
172
  return (await this.git(cwd, ['rev-parse', ref])).trim()
170
173
  }
171
174
 
175
+ /**
176
+ * The commit a mission branch points at, resolved from the branch ref. Never
177
+ * reads a live worktree checkout: a checkout is a view, the ref is the fact
178
+ * (#149 W3 review evidence and every tip comparison use this).
179
+ * @param {{ id?: string, worktree?: { branch?: string } | null }} mission
180
+ */
181
+ async branchTip(mission) {
182
+ const branch = mission?.worktree?.branch
183
+ if (typeof branch !== 'string' || branch.length === 0) {
184
+ fail(`mission ${mission?.id ?? ''} has no branch to resolve`.replace(' ', ' '), 'invalid-argument')
185
+ }
186
+ return this.revParse(this.root, `refs/heads/${branch}`)
187
+ }
188
+
172
189
  async isClean(cwd) {
173
190
  return (await this.git(cwd, ['status', '--porcelain'])).trim().length === 0
174
191
  }
@@ -192,6 +209,9 @@ export class Tower {
192
209
  paths: this.paths,
193
210
  now: this.deps.clock?.now ? this.deps.clock.now() : Date.now(),
194
211
  persist: options.persist,
212
+ // A legacy survey planned but never provisioned recorded no base; the
213
+ // loader resolves it once, from this checkout, before the pure migration.
214
+ resolveLegacySurvey: (rawState) => resolveLegacySurveyRead(this, rawState),
195
215
  })
196
216
  this.stateLoad = { ...loaded.version, migration: loaded.migration }
197
217
  return loaded.state
@@ -278,6 +298,15 @@ export class Tower {
278
298
  return provision(this, input)
279
299
  }
280
300
 
301
+ /**
302
+ * Provision the disposable detached review checkout for a reviewer (#149 W3).
303
+ * The commit it returns is the reviewer's evidence for every later read and
304
+ * the verdict stamp.
305
+ */
306
+ async provisionReviewCheckout(input = {}) {
307
+ return provisionReviewCheckout(this, input)
308
+ }
309
+
281
310
  async missionContext(missionId) {
282
311
  return missionContext(this, missionId)
283
312
  }
@@ -508,11 +537,10 @@ export class Tower {
508
537
  if (!mission.worktree) return null
509
538
  const branch = mission.worktree.branch
510
539
  const reviews = (state.reviews ?? []).filter((review) => review.branch === branch)
511
- const exempt =
512
- this.config.review?.requireReview === false ||
513
- (mission.reviewPolicy === 'none' &&
514
- this.config.gates?.allowReviewExemption === true &&
515
- String(mission.reviewExemptionReason ?? '').trim().length > 0)
540
+ // #M39: reconciliation follows the same policy as the gate — only
541
+ // `review.mode:required` demands review evidence; `off`/`optional` reconcile
542
+ // a landed branch without one.
543
+ const exempt = isReviewExempt(mission, this.config)
516
544
  if (reviews.length === 0 && !exempt) return null
517
545
  const latest = reviews.length > 0 ? reviews.reduce((a, b) => (a.round >= b.round ? a : b)) : null
518
546
  if (latest && !exempt && (latest.status !== 'clean' || latest.mergeVerdict !== 'merge')) return null
@@ -521,7 +549,7 @@ export class Tower {
521
549
  let branchTip
522
550
  try {
523
551
  baseTip = await this.revParse(this.root, state.base)
524
- branchTip = await this.revParse(mission.worktree.path, 'HEAD')
552
+ branchTip = await this.branchTip(mission)
525
553
  } catch {
526
554
  return null
527
555
  }
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:
@@ -108,3 +110,53 @@
108
110
  name: '@deepseek-ai/dsh-tool-fs-search'
109
111
  config:
110
112
  sampleOverCapGlobResults: false
113
+ # #148 D7/W2: one visible survey capability row. The group is the switch:
114
+ # both the read-only preset and the detached-sandbox preset are nested
115
+ # dependencies of it, so the sandbox placement is tuning and adds no second
116
+ # visible component. Off means a survey cannot be planned (tower_plan refuses
117
+ # with an actionable code) and no survey agent can be dispatched; build
118
+ # missions are unaffected. Read-only remains structural for the shared
119
+ # placement (the preset allow-list has no write, edit, or shell tool); the
120
+ # sandbox preset exists only for `survey.sandbox: 'detached'`.
121
+ - id: tower-survey
122
+ name: cordis:group
123
+ group: true
124
+ config:
125
+ - id: preset-tower-survey
126
+ name: 'dsh-tower/preset-survey'
127
+ config:
128
+ id: tower-survey
129
+ name: Tower Survey
130
+ description: Read-only inspection of one recorded commit in the shared checkout.
131
+ order: 62
132
+ plugins:
133
+ - id: tower-survey-policy
134
+ name: 'dsh-tower/survey'
135
+ - id: tower-survey-fs
136
+ name: '@deepseek-ai/dsh-tool-fs'
137
+ config:
138
+ write: false
139
+ edit: false
140
+ readImage: false
141
+ - id: tower-survey-fs-search
142
+ name: '@deepseek-ai/dsh-tool-fs-search'
143
+ config:
144
+ sampleOverCapGlobResults: false
145
+ - id: preset-tower-survey-sandbox
146
+ name: 'dsh-tower/preset-survey-sandbox'
147
+ config:
148
+ id: tower-survey-sandbox
149
+ name: Tower Survey Sandbox
150
+ description: Disposable detached checkout for a survey that may write or run commands while reading one recorded commit.
151
+ order: 63
152
+ plugins:
153
+ - id: tower-survey-sandbox-policy
154
+ name: 'dsh-tower/survey-sandbox'
155
+ - id: tower-survey-sandbox-fs
156
+ name: '@deepseek-ai/dsh-tool-fs'
157
+ - id: tower-survey-sandbox-fs-search
158
+ name: '@deepseek-ai/dsh-tool-fs-search'
159
+ config:
160
+ sampleOverCapGlobResults: false
161
+ - id: tower-survey-sandbox-pwsh
162
+ name: '@deepseek-ai/dsh-tool-pwsh'
@@ -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.