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
@@ -1,11 +1,197 @@
1
- // Pure review-selection and exemption decisions shared by the merge/gate/read
2
- // projections. No repository I/O.
1
+ // Pure review-selection, review-policy resolution and exemption decisions
2
+ // shared by the merge/gate/read projections and the config layer. No repository
3
+ // I/O and no process state.
4
+ //
5
+ // REVIEW POLICY (#M39)
6
+ // --------------------
7
+ // `review.mode` is the single policy with exactly three independent values:
8
+ //
9
+ // off review is intentionally unavailable: recording a review is
10
+ // refused, `tower_spawn` refuses the reviewer role, and the merge
11
+ // gate ignores review blockers. Registration is profile-time: a
12
+ // profile-level off does not mount `tower_review`, while a
13
+ // repository-level off over a profile that mounted it refuses the
14
+ // submission with `review-disabled`;
15
+ // optional review is available but never blocks a merge (the default, and the
16
+ // 2.0.3+ behavior of `review.requireReview:false`);
17
+ // required a clean review bound to the branch tip is a merge blocker (the
18
+ // old `review.requireReview:true` behavior).
19
+ //
20
+ // `review.requireReview` stays accepted for one release as a normalized alias:
21
+ // true -> required, false -> optional. Precedence for one raw config object is
22
+ // deterministic:
23
+ //
24
+ // * only the alias set: the alias decides;
25
+ // * only the mode set: the mode decides;
26
+ // * both set: an alias of `true` next to any mode other than `required` is a
27
+ // contradiction and is rejected naming both keys. An alias of `false` stays
28
+ // compatible with `off` because both mean "not required by merge" and the
29
+ // mode is the more specific policy. That asymmetry is also what keeps the
30
+ // resolver idempotent: it materializes the alias from the mode it resolved,
31
+ // and `required` is the only mode that mirrors back to `true`.
3
32
  // @ts-check
33
+ import { TowerToolError } from '../errors.js'
4
34
 
5
35
  /** @typedef {import('../../types/index.js').Mission} Mission */
6
36
  /** @typedef {import('../../types/index.js').Review} Review */
7
37
  /** @typedef {import('../../types/index.js').TowerConfig} TowerConfig */
8
38
 
39
+ export const REVIEW_MODES = Object.freeze(['off', 'optional', 'required'])
40
+ export const DEFAULT_REVIEW_MODE = 'optional'
41
+
42
+ // Cordis materializes a `.volatile()` schema field as a live reference object
43
+ // (`{ get() }` plus a non-enumerable `Symbol.for('cosmokit.volatile.write')`).
44
+ // Settings forms hand the runtime these references, not plain values, so every
45
+ // consumer below unwraps them before validating or comparing. Keeping the
46
+ // helpers here (instead of a new config module) lets the schema import them
47
+ // without a cycle: review.js has no dependency on schema.js.
48
+ const VOLATILE_WRITE = Symbol.for('cosmokit.volatile.write')
49
+
50
+ /**
51
+ * True for the live reference Cordis/Schemastery creates for a volatile field.
52
+ * @param {unknown} value
53
+ * @returns {value is { get: () => unknown }}
54
+ */
55
+ export function isVolatileRef(value) {
56
+ return (
57
+ value !== null &&
58
+ typeof value === 'object' &&
59
+ typeof /** @type {{ get?: unknown }} */ (value).get === 'function' &&
60
+ Object.getOwnPropertySymbols(value).includes(VOLATILE_WRITE)
61
+ )
62
+ }
63
+
64
+ /**
65
+ * Read a volatile reference once; other values pass through unchanged.
66
+ * @template T
67
+ * @param {T | { get: () => T }} value
68
+ * @returns {T}
69
+ */
70
+ export function unwrapVolatile(value) {
71
+ return /** @type {any} */ (isVolatileRef(value) ? value.get() : value)
72
+ }
73
+
74
+ /**
75
+ * Deep-copy a raw config object, replacing every volatile reference with its
76
+ * current value. Non-plain objects (for example a class instance) are returned
77
+ * as-is; Tower config is JSON-shaped plain data.
78
+ * @param {unknown} value
79
+ * @returns {any}
80
+ */
81
+ export function materializeVolatile(value) {
82
+ const plain = unwrapVolatile(value)
83
+ if (Array.isArray(plain)) return plain.map((entry) => materializeVolatile(entry))
84
+ if (plain === null || typeof plain !== 'object') return plain
85
+ const proto = Object.getPrototypeOf(plain)
86
+ if (proto !== Object.prototype && proto !== null) return plain
87
+ /** @type {Record<string, unknown>} */
88
+ const copy = {}
89
+ for (const [key, entry] of Object.entries(plain)) copy[key] = materializeVolatile(entry)
90
+ return copy
91
+ }
92
+
93
+ /**
94
+ * @param {unknown} value
95
+ * @returns {value is 'off' | 'optional' | 'required'}
96
+ */
97
+ export function isReviewMode(value) {
98
+ return typeof value === 'string' && REVIEW_MODES.includes(value)
99
+ }
100
+
101
+ /**
102
+ * The mode the deprecated boolean alias means.
103
+ * @param {boolean} requireReview
104
+ */
105
+ export function legacyReviewMode(requireReview) {
106
+ return requireReview === true ? 'required' : DEFAULT_REVIEW_MODE
107
+ }
108
+
109
+ /**
110
+ * The resolved mode for any config object the runtime may hold: a resolved
111
+ * config, a raw profile/repo layer, or a hand-built test fixture. An explicit
112
+ * `mode` always wins; otherwise the deprecated alias decides; otherwise the
113
+ * documented default applies.
114
+ * @param {{ review?: any } | null | undefined} [config]
115
+ * @returns {'off' | 'optional' | 'required'}
116
+ */
117
+ export function reviewModeOf(config) {
118
+ const review = config?.review ?? {}
119
+ const mode = unwrapVolatile(review.mode)
120
+ if (isReviewMode(mode)) return mode
121
+ const alias = unwrapVolatile(review.requireReview)
122
+ if (typeof alias === 'boolean') return legacyReviewMode(alias)
123
+ return DEFAULT_REVIEW_MODE
124
+ }
125
+
126
+ /**
127
+ * The review gate is enforced only in `required`. `off` and `optional` both
128
+ * ignore review at the merge gate; they differ in availability, not strictness.
129
+ * @param {{ review?: any } | null | undefined} [config]
130
+ */
131
+ export function reviewRequired(config) {
132
+ return reviewModeOf(config) === 'required'
133
+ }
134
+
135
+ /**
136
+ * True when review is intentionally switched off (`tower_review` absent,
137
+ * reviewer spawn refused, merge gate ignores review).
138
+ * @param {{ review?: any } | null | undefined} [config]
139
+ */
140
+ export function reviewUnavailable(config) {
141
+ return reviewModeOf(config) === 'off'
142
+ }
143
+
144
+ /**
145
+ * Resolve one raw `review` object (a profile, a repository layer, or a single
146
+ * written config) into its mode. Rejects contradictory explicit values and an
147
+ * unknown mode with an actionable `config-invalid` error.
148
+ *
149
+ * @param {any} rawReview
150
+ * @returns {{ mode: 'off' | 'optional' | 'required', explicit: boolean, legacy: boolean }}
151
+ */
152
+ export function resolveReviewPolicy(rawReview) {
153
+ const review = rawReview && typeof rawReview === 'object' && !Array.isArray(rawReview) ? rawReview : {}
154
+ const mode = unwrapVolatile(review.mode)
155
+ const alias = unwrapVolatile(review.requireReview)
156
+ if (mode !== undefined && !isReviewMode(mode)) {
157
+ throw new TowerToolError(
158
+ 'config-invalid',
159
+ `review.mode must be one of ${REVIEW_MODES.join('|')}, got ${JSON.stringify(mode)}`,
160
+ )
161
+ }
162
+ if (alias !== undefined && typeof alias !== 'boolean') {
163
+ throw new TowerToolError(
164
+ 'config-invalid',
165
+ 'review.requireReview must be a boolean (deprecated alias: true -> required, false -> optional)',
166
+ )
167
+ }
168
+ if (mode !== undefined && alias === true && mode !== 'required') {
169
+ throw new TowerToolError(
170
+ 'config-invalid',
171
+ `review.mode is ${JSON.stringify(mode)} but review.requireReview is true; set only review.mode (off|optional|required) — review.requireReview is a deprecated alias (true -> required, false -> optional) and may not contradict it`,
172
+ )
173
+ }
174
+ if (mode !== undefined) return { mode, explicit: true, legacy: alias !== undefined }
175
+ if (alias !== undefined) return { mode: legacyReviewMode(alias), explicit: true, legacy: true }
176
+ return { mode: DEFAULT_REVIEW_MODE, explicit: false, legacy: false }
177
+ }
178
+
179
+ /**
180
+ * The small read-only projection reported by `tower_status` and the doctor's
181
+ * `review` check. `detail` is the one sentence both surfaces share.
182
+ * @param {{ review?: any } | null | undefined} [config]
183
+ */
184
+ export function reviewPolicyView(config) {
185
+ const mode = reviewModeOf(config)
186
+ const detail =
187
+ mode === 'off'
188
+ ? 'review.mode:off — review is intentionally unavailable: recording a review is refused, reviewer spawn is refused, and the merge gate ignores review; a profile-level off does not register tower_review'
189
+ : mode === 'required'
190
+ ? 'review.mode:required — a clean review bound to the branch tip is a merge blocker'
191
+ : 'review.mode:optional — review is available but never blocks a merge'
192
+ return { mode, available: mode !== 'off', required: mode === 'required', detail }
193
+ }
194
+
9
195
  /**
10
196
  * Reviews are branch-scoped; a missing branch has no reviews.
11
197
  * @param {Review[] | null | undefined} reviews
@@ -26,27 +212,35 @@ export function latestReview(reviews) {
26
212
  }
27
213
 
28
214
  /**
29
- * A mission may skip review only with both the exemption gate and a reason.
215
+ * A mission skips the review gate unless `review.mode` is `required`. In
216
+ * `required`, it may still skip with both the exemption gate and a reason.
217
+ * `off` and `optional` both bypass the gate; they differ only in whether the
218
+ * review tools/spawn role exist.
30
219
  * @param {Mission} mission
31
220
  * @param {TowerConfig} [config]
32
221
  * @returns {boolean}
33
222
  */
34
223
  export function isReviewExempt(mission, config = {}) {
224
+ if (!reviewRequired(config)) return true
35
225
  return (
36
- config.review?.requireReview === false ||
37
- (mission.reviewPolicy === 'none' &&
38
- config.gates?.allowReviewExemption === true &&
39
- String(mission.reviewExemptionReason ?? '').trim().length > 0)
226
+ mission.reviewPolicy === 'none' &&
227
+ config.gates?.allowReviewExemption === true &&
228
+ String(mission.reviewExemptionReason ?? '').trim().length > 0
40
229
  )
41
230
  }
42
231
 
43
232
  /**
44
233
  * `reviewPolicy: none` is invalid when the reviewer gate does not allow it.
234
+ * Only `review.mode:required` can make the request invalid; with review off or
235
+ * optional no review blocker is produced at all.
45
236
  * @param {Mission} mission
46
237
  * @param {TowerConfig} [config]
47
238
  * @returns {boolean}
48
239
  */
49
240
  export function reviewExemptionInvalid(mission, config = {}) {
50
- const requireReview = config.review?.requireReview !== false
51
- return requireReview && mission.reviewPolicy === 'none' && config.gates?.allowReviewExemption !== true
241
+ return (
242
+ reviewRequired(config) &&
243
+ mission.reviewPolicy === 'none' &&
244
+ config.gates?.allowReviewExemption !== true
245
+ )
52
246
  }
package/host/config.js CHANGED
@@ -24,6 +24,7 @@ import {
24
24
  resolveTowerConfig,
25
25
  } from '../domain/config/schema.js'
26
26
  import { TowerToolError } from '../domain/errors.js'
27
+ import { materializeVolatile, resolveReviewPolicy, reviewUnavailable } from '../domain/integration/review.js'
27
28
 
28
29
  export { Config, DEFAULT_DENY_TOOLS, resolveTowerConfig }
29
30
 
@@ -31,11 +32,16 @@ const REPO_CONFIG_MAX_BYTES = 1024 * 1024
31
32
 
32
33
  // The last raw profile config `apply()` received; repository config is merged
33
34
  // over it so a repo can tune spawn/merge/review without editing the profile.
35
+ //
36
+ // #M40: this must stay the *raw* object. A `.volatile()` field arrives as a
37
+ // live reference that Cordis updates in place on a settings write, and cloning
38
+ // (JSON) would freeze a copy and hide the new value from runtimeConfig(). Only
39
+ // readers that need plain data (profileConfig) materialize it.
34
40
  let profileRaw = {}
35
41
 
36
42
  export function configureRuntime(config) {
37
- profileRaw = clone(config ?? {}) ?? {}
38
- return resolveTowerConfig(config)
43
+ profileRaw = config ?? {}
44
+ return resolveTowerConfig(profileRaw)
39
45
  }
40
46
 
41
47
  export function runtimeConfig() {
@@ -44,7 +50,7 @@ export function runtimeConfig() {
44
50
 
45
51
  /** Raw profile config before defaults are materialized (for doctor warnings). */
46
52
  export function profileConfig() {
47
- return clone(profileRaw) ?? {}
53
+ return clone(materializeVolatile(profileRaw)) ?? {}
48
54
  }
49
55
 
50
56
  export function repoConfigPath(commonDir) {
@@ -92,6 +98,10 @@ export async function readRepoConfig(commonDir) {
92
98
  // Validate value types without letting the schema's defaults win over the
93
99
  // profile layer: merge into an empty raw object and discard the result.
94
100
  Config(parsed)
101
+ // #M39: the schema accepts both review keys; only the resolver knows that an
102
+ // explicit `requireReview:true` next to a conflicting `review.mode` is a
103
+ // contradiction. Reject it here, before the layer can be merged.
104
+ resolveReviewPolicy(parsed.review)
95
105
  } catch (error) {
96
106
  throw new TowerToolError('config-invalid', `repo config at ${file} is invalid: ${String(error?.message ?? error)}`)
97
107
  }
@@ -130,6 +140,33 @@ export async function resolveRepoConfig(repo) {
130
140
  if (!Array.isArray(merged.mode?.denyTools) || merged.mode.denyTools.length === 0) {
131
141
  merged.mode = { ...(merged.mode ?? {}), denyTools: [...DEFAULT_DENY_TOOLS] }
132
142
  }
143
+ // #M39: the review policy is resolved per layer instead of deep-merging two
144
+ // resolved shapes. A resolved base always carries both `review.mode` and its
145
+ // mirrored alias, so a repository layer that sets only one of the two would
146
+ // otherwise pair a stale inherited `mode` with the new alias. The repository
147
+ // layer wins as a whole when it names either key; a layer that names neither
148
+ // inherits the profile policy untouched.
149
+ const repoReview = repoLayer.review
150
+ if (repoReview && typeof repoReview === 'object' && !Array.isArray(repoReview)) {
151
+ const explicit = repoReview.mode !== undefined || repoReview.requireReview !== undefined
152
+ if (explicit) {
153
+ const repoPolicy = resolveReviewPolicy(repoReview)
154
+ if (reviewUnavailable(base) && repoPolicy.mode !== 'off') {
155
+ // The tool surface is fixed when the plugin applies: a profile with
156
+ // review off never registered tower_review, so a repository may relax
157
+ // review further but cannot re-enable a tool that does not exist.
158
+ throw new TowerToolError(
159
+ 'config-invalid',
160
+ `review.mode is off in the profile, so tower_review is not registered; repository config cannot set review.mode:${repoPolicy.mode}. Set review.mode in the profile instead.`,
161
+ )
162
+ }
163
+ merged.review = {
164
+ ...(merged.review ?? {}),
165
+ mode: repoPolicy.mode,
166
+ requireReview: repoPolicy.mode === 'required',
167
+ }
168
+ }
169
+ }
133
170
  merged.section = merged.mode?.promptSection ?? merged.section ?? ''
134
171
  return {
135
172
  ...merged,
package/host/doctor.js CHANGED
@@ -13,6 +13,7 @@ import { SCHEMA_VERSION } from '../domain/mission.js'
13
13
  import { DEFAULT_MAX_REPORT_BYTES, completionReportBytes } from '../domain/missions/completion.js'
14
14
  import { comparisonMode } from '../domain/scope.js'
15
15
  import { configSurface } from '../domain/config/schema.js'
16
+ import { reviewPolicyView, reviewRequired } from '../domain/integration/review.js'
16
17
  import { parseBaseline, surfaceLine, SURFACE_BASELINE_FILE } from '../domain/surface.js'
17
18
  import { assessHostVersion, detectHostVersion, readCompatibility } from './compatibility.js'
18
19
  import { profileConfig } from './config.js'
@@ -242,8 +243,11 @@ export async function runDoctor({ tower, ctx, agent, hostVersion, compatibilityF
242
243
  const config = tower.config ?? {}
243
244
  const profile = profileConfig()
244
245
  const staleDefaults = []
245
- if (profile.review && profile.review.requireReview === undefined) {
246
- staleDefaults.push('review.requireReview now defaults to false (2.0.3)')
246
+ // #M39: `review.mode` replaces the deprecated boolean. A profile that names
247
+ // neither key relies on the default (optional), so say so rather than letting
248
+ // a reader assume review is enforced.
249
+ if (profile.review && profile.review.mode === undefined && profile.review.requireReview === undefined) {
250
+ staleDefaults.push('review.mode defaults to optional; review.requireReview is a deprecated alias')
247
251
  }
248
252
  if (profile.mode && profile.mode.allowAgentControl === undefined) {
249
253
  staleDefaults.push('mode.allowAgentControl now defaults to true (2.0.4)')
@@ -261,6 +265,11 @@ export async function runDoctor({ tower, ctx, agent, hostVersion, compatibilityF
261
265
  'optional: add <git-common-dir>/dsh-tower/config.json',
262
266
  ),
263
267
  )
268
+ // #M39: the resolved review mode is its own diagnostic fact. `off` is a
269
+ // supported, intentional posture (not a degradation), so it is a pass whose
270
+ // detail says exactly what is unavailable.
271
+ const reviewPolicy = reviewPolicyView(config)
272
+ checks.push(check('review', 'pass', reviewPolicy.detail))
264
273
  // #84: report the concept-budget surface instead of implying it. The counts
265
274
  // are the live schema/tool surface; the date is the committed baseline, so a
266
275
  // reader sees both the number and the decision it was frozen at.
@@ -288,7 +297,7 @@ export async function runDoctor({ tower, ctx, agent, hostVersion, compatibilityF
288
297
  ),
289
298
  )
290
299
  const presets = ctx.get?.('agentPresets')
291
- const reviewerRequired = config.review?.requireReview === true
300
+ const reviewerRequired = reviewRequired(config)
292
301
  if (presets && typeof presets.list === 'function') {
293
302
  try {
294
303
  const available = (await presets.list()).map((entry) => String(entry.id))
@@ -296,9 +305,10 @@ export async function runDoctor({ tower, ctx, agent, hostVersion, compatibilityF
296
305
  const reviewer = config.spawn?.reviewerPreset ?? 'tower-reviewer'
297
306
  const required = reviewerRequired ? [worker, reviewer] : [worker]
298
307
  const missing = required.filter((id) => !available.includes(id))
299
- // #121: `preset-tower-reviewer` is a conditional component. Its off state
300
- // is supported while review.requireReview is false; with the gate on it is
301
- // a fail, because no independent review can be dispatched.
308
+ // #121/#M39: `preset-tower-reviewer` is a conditional component. Its off
309
+ // state is supported while review is off or optional; with
310
+ // `review.mode:required` it is a fail, because no independent review can
311
+ // be dispatched.
302
312
  if (missing.length === 0) {
303
313
  const reviewerNote = available.includes(reviewer)
304
314
  ? `reviewer=${reviewer}`
@@ -309,7 +319,7 @@ export async function runDoctor({ tower, ctx, agent, hostVersion, compatibilityF
309
319
  check(
310
320
  'presets',
311
321
  'fail',
312
- `missing preset(s): ${missing.join(', ')}${reviewerRequired && missing.includes(reviewer) ? ' (review.requireReview is true, so preset-tower-reviewer may not be off)' : ''}`,
322
+ `missing preset(s): ${missing.join(', ')}${reviewerRequired && missing.includes(reviewer) ? ' (review.mode is required, so preset-tower-reviewer may not be off)' : ''}`,
313
323
  'enable the Tower preset component and retry',
314
324
  ),
315
325
  )
@@ -596,6 +606,7 @@ export async function runDoctor({ tower, ctx, agent, hostVersion, compatibilityF
596
606
  // reports (from #89) and archive (from #73) are both kept
597
607
  reports: reportsReport,
598
608
  surface: surfaceReport,
609
+ review: reviewPolicy,
599
610
  components,
600
611
  archive: {
601
612
  missions: state ? state.missions.length : null,
package/host/presets.js CHANGED
@@ -6,9 +6,9 @@
6
6
  // 1. `preset-tower-worker` is a mandatory dependency: planning, mode entry and
7
7
  // spawn must refuse when the worker preset is absent.
8
8
  // 2. `preset-tower-reviewer` is a *conditional* component. It may be off while
9
- // `review.requireReview` is false (merge is unaffected); with the review
10
- // gate on it must be locked with an explanatory error, never silently
11
- // allowed.
9
+ // `review.mode` is `off` or `optional` (merge is unaffected; `off` does not
10
+ // use the preset at all); with `review.mode:required` it must be locked with
11
+ // an explanatory error, never silently allowed.
12
12
  //
13
13
  // `tower-tests` adds a third: with the component row off there is no
14
14
  // `tower_test` tool and no receipt, so `gates.requireTests: true` is refused
@@ -19,6 +19,7 @@
19
19
  // spawn would fail at `lease-busy` advising a tool that is not registered, so the
20
20
  // contradiction is refused up front instead.
21
21
  import { TowerToolError } from '../domain/errors.js'
22
+ import { reviewRequired } from '../domain/integration/review.js'
22
23
  import { componentEnabled, disabledReason } from './capability-registry.js'
23
24
 
24
25
  export function presetIds(config = {}) {
@@ -38,22 +39,22 @@ function rowId(preset) {
38
39
  * registered. If the service is unavailable, doctor reports that separately and
39
40
  * this check cannot make a claim either way.
40
41
  *
41
- * The reviewer preset is required only when `review.requireReview` is true; the
42
- * off state is otherwise supported by the component contract.
42
+ * The reviewer preset is required only when `review.mode` is `required`; with
43
+ * `off`/`optional` the off state is supported by the component contract.
43
44
  */
44
45
  export async function assertPresetsAvailable(ctx, config = {}) {
45
46
  const service = ctx?.get?.('agentPresets')
46
47
  if (!service || typeof service.list !== 'function') return { checked: false, missing: [] }
47
48
  const presets = presetIds(config)
48
49
  const required = [presets.worker]
49
- if (config.review?.requireReview === true) required.push(presets.reviewer)
50
+ if (reviewRequired(config)) required.push(presets.reviewer)
50
51
  const available = new Set((await service.list()).map((entry) => String(entry.id)))
51
52
  const missing = required.filter((id) => !available.has(id))
52
53
  if (missing.length === 0) return { checked: true, missing: [] }
53
54
  const rows = [...new Set(missing.map(rowId))]
54
55
  const reviewLock =
55
- config.review?.requireReview === true && missing.includes(presets.reviewer)
56
- ? ` review.requireReview is true, so the reviewer row may not be off; ${disabledReason(rowId(presets.reviewer))} is rejected`
56
+ reviewRequired(config) && missing.includes(presets.reviewer)
57
+ ? ` review.mode is required, so the reviewer row may not be off; ${disabledReason(rowId(presets.reviewer))} is rejected`
57
58
  : ''
58
59
  throw new TowerToolError(
59
60
  'preset-missing',
@@ -16,6 +16,7 @@ import {
16
16
  } from '../domain/vocabulary.js'
17
17
  import { git, resolveRepo, hasAnyCommit } from '../adapters/git.js'
18
18
  import { runtimeConfig } from './config.js'
19
+ import { reviewUnavailable } from '../domain/integration/review.js'
19
20
  import { runDoctor } from './doctor.js'
20
21
  import { createHmrBridge } from './hmr.js'
21
22
  import { assertRuntimePreconditions } from './presets.js'
@@ -652,7 +653,14 @@ export function registerCoreTowerTools(ctx, options = {}) {
652
653
 
653
654
  registerFindingsTool(ctx, { openTower, define, actorFor, requireKnownActor })
654
655
 
655
- registerReviewTool(ctx, { openTower, define, actorFor })
656
+ // #M39: `review.mode:off` is the one configuration that removes tower_review
657
+ // from the mounted surface. The name is still recorded before the decision so
658
+ // the concept budget keeps measuring the build surface (the same rule the
659
+ // shipped-off component rows follow), and status/doctor report the mode as
660
+ // intentionally unavailable. A repository layer cannot re-enable the tool
661
+ // (host/config.js refuses that); the profile owns the mounted surface.
662
+ recordToolRegistration('tower_review')
663
+ if (!reviewUnavailable(runtimeConfig())) registerReviewTool(ctx, { openTower, define, actorFor })
656
664
 
657
665
  registerPreflightTool(ctx, { openTower, define, observeActivity })
658
666
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-tower",
3
- "version": "2.5.0",
3
+ "version": "2.6.0",
4
4
  "type": "module",
5
5
  "description": "DSH-native Tower: mission planning, worktree isolation, review-gated merges, and a human-owned Tower mode.",
6
6
  "license": "MIT",
@@ -28,6 +28,7 @@
28
28
  },
29
29
  "exports": {
30
30
  ".": "./index.js",
31
+ "./client": "./client.js",
31
32
  "./worker": "./worker.js",
32
33
  "./reviewer": "./reviewer.js",
33
34
  "./skills": "./skills.js",
@@ -55,6 +56,7 @@
55
56
  "./package.json": "./package.json"
56
57
  },
57
58
  "files": [
59
+ "client.js",
58
60
  "index.js",
59
61
  "worker.js",
60
62
  "reviewer.js",
@@ -86,6 +88,10 @@
86
88
  "dsh": {
87
89
  "bundle": {
88
90
  "patch": "./cordis.patch.yml"
91
+ },
92
+ "client": {
93
+ "platform": "web",
94
+ "immediately": true
89
95
  }
90
96
  },
91
97
  "scripts": {
@@ -8,14 +8,19 @@
8
8
  // locale/en.json and locale/zh.json — and the same three strings for every
9
9
  // component that ships a generated row label, including the internal
10
10
  // worker preset (#121);
11
- // 2. a forbidden-to-disable capability (host, health, recovery, review
12
- // contract, merge gates) that any group, row or boolean key can disable;
11
+ // 2. a forbidden-to-disable capability (host, health, recovery, merge gates)
12
+ // that any group, row or boolean key can disable;
13
13
  // 3. a component count that moved without a decision in
14
14
  // scripts/surface-baseline.json (the same brake `surface:check` applies);
15
15
  // 4. #137: user-facing label copy that leaks a config key, an internal tool id,
16
16
  // a slash command or a module specifier, leaves a term untranslated in
17
17
  // Chinese, drifts from the recorded glossary, or diverges between zh and en
18
- // in the root locale, the generated row locales and the ui-bundle locale.
18
+ // in the root locale, the generated row locales and the ui-bundle locale;
19
+ // 5. #M42: the review policy presented as a row. `review.mode` is one
20
+ // tri-state configuration control (off/optional/required) on the plugin
21
+ // page, not a component row and not three mutually exclusive rows; no row
22
+ // may own the policy, and the reviewer preset row may not be made
23
+ // incompatible with the off/optional modes.
19
24
  //
20
25
  // It also refuses the quiet forms of the same drift: a shipped component row
21
26
  // missing from the single source of truth, a live config key without a
@@ -72,9 +77,10 @@ a visible row with no label source at all, a group row whose plugin-page
72
77
  description omits its off consequence, a forbidden-to-disable capability disabled
73
78
  by a group, row or boolean key, a component count that moved without a
74
79
  scripts/surface-baseline.json decision, an unclassified config key or component
75
- row, a stale generated reference, and (#137) any label copy that names a config
76
- key or internal tool id, leaves a glossary term untranslated, or diverges between
77
- zh and en.
80
+ row, a stale generated reference, (#137) any label copy that names a config key or
81
+ internal tool id, leaves a glossary term untranslated, or diverges between zh and
82
+ en, and (#M42) the review policy exposed as a row instead of one tri-state
83
+ configuration control.
78
84
  Regenerate the table with \`npm run docs:generate:components\` and the row labels
79
85
  with \`npm run docs:generate:locale\`.`
80
86
 
@@ -650,6 +656,112 @@ export function checkRowLabelSources(patch, rowLocales) {
650
656
  return findings
651
657
  }
652
658
 
659
+ // #M42: the review policy is one tri-state configuration control on the plugin
660
+ // page. These names are the policy-shaped rows the gate refuses to see: a row
661
+ // per mode (or one "review policy" row) is the shape the product replaced.
662
+ export const REVIEW_POLICY_KEY = 'review.mode'
663
+ export const REVIEW_ALIAS_KEY = 'review.requireReview'
664
+ export const REVIEW_REVIEWER_ROW = 'preset-tower-reviewer'
665
+ const REVIEW_POLICY_ROW_IDS = Object.freeze([
666
+ 'review',
667
+ 'review-policy',
668
+ 'review-mode',
669
+ 'review-modes',
670
+ 'tower-review',
671
+ 'tower-review-policy',
672
+ ])
673
+
674
+ /**
675
+ * #M42: hold the review control to its declared shape. The policy is one
676
+ * tri-state control (`off`/`optional`/`required`) on the plugin-page
677
+ * configuration form, not a component row and not three mutually exclusive rows.
678
+ *
679
+ * The gate fails when a review-policy-shaped row exists, when a component
680
+ * `allowedOverrides` or a shipped row's `config:` block owns `review.mode` (or
681
+ * the deprecated alias), when the non-disableable required-mode gate is not
682
+ * recorded on the forbidden list, and when a forbidden capability claims the
683
+ * reviewer preset row as a disabler. The reviewer row is an optional capability:
684
+ * it may be off while the policy is off or optional, and is only a precondition
685
+ * (with an explanatory refusal) under required.
686
+ *
687
+ * @param {ReturnType<typeof parseComponentSurface>} surface
688
+ * @param {{ patch?: ReturnType<typeof parseCordisPatch> }} live
689
+ */
690
+ export function checkReviewPolicySurface(surface, live) {
691
+ const findings = []
692
+ const record = (code, message) => findings.push(finding(code, 'error', message))
693
+ const components = surface?.components ?? []
694
+ const byName = new Map((surface?.keys ?? []).map((key) => [key.name, key]))
695
+
696
+ for (const name of [REVIEW_POLICY_KEY, REVIEW_ALIAS_KEY]) {
697
+ const key = byName.get(name)
698
+ if (!key) {
699
+ record('review-policy-key', `${name} is missing from the component surface; the review policy needs an explicit class`)
700
+ continue
701
+ }
702
+ if (key.class !== 'core') {
703
+ record(
704
+ 'review-policy-class',
705
+ `${name} is classified "${key.class}"; the review policy is one tri-state configuration control and its keys must be core, never a switch a row can turn off`,
706
+ )
707
+ }
708
+ }
709
+
710
+ const policyRow = components.find((component) => REVIEW_POLICY_ROW_IDS.includes(component.id))
711
+ if (policyRow) {
712
+ record(
713
+ 'review-policy-row',
714
+ `component "${policyRow.id}" is a review-policy row; the review policy is one tri-state control on the plugin-page configuration form, not a component row and never one row per mode`,
715
+ )
716
+ }
717
+
718
+ const reviewerRow = components.find((component) => component.id === REVIEW_REVIEWER_ROW)
719
+ if (!reviewerRow) {
720
+ record(
721
+ 'review-policy-reviewer-row',
722
+ `${REVIEW_REVIEWER_ROW} is missing from the component surface; the reviewer preset must stay an optional capability the review policy may leave unused`,
723
+ )
724
+ }
725
+
726
+ for (const component of components) {
727
+ for (const override of component.allowedOverrides ?? []) {
728
+ if (override !== REVIEW_POLICY_KEY && override !== REVIEW_ALIAS_KEY) continue
729
+ record(
730
+ 'review-policy-switch',
731
+ `component "${component.id}" lists ${override} in allowedOverrides; the review policy is one tri-state configuration control and no component row may own it`,
732
+ )
733
+ }
734
+ }
735
+
736
+ for (const row of live?.patch?.rows ?? []) {
737
+ for (const override of row.configOverrides ?? []) {
738
+ if (override.key !== REVIEW_POLICY_KEY && override.key !== REVIEW_ALIAS_KEY) continue
739
+ record(
740
+ 'review-policy-switch',
741
+ `row "${row.id}" sets ${override.key} in cordis.patch.yml; the review policy is one tri-state configuration control, not a per-row switch`,
742
+ )
743
+ }
744
+ }
745
+
746
+ const gate = (surface?.forbidden ?? []).find((capability) => (capability.gatedBy ?? []).includes(REVIEW_POLICY_KEY))
747
+ if (!gate) {
748
+ record(
749
+ 'review-policy-gate',
750
+ `no forbidden capability records the required-mode review gate via gatedBy ${REVIEW_POLICY_KEY}; required mode must keep its review evidence and exemption path non-disableable`,
751
+ )
752
+ }
753
+
754
+ for (const capability of surface?.forbidden ?? []) {
755
+ if (!(capability.rowIds ?? []).includes(REVIEW_REVIEWER_ROW)) continue
756
+ record(
757
+ 'review-policy-exclusive',
758
+ `forbidden capability "${capability.id}" lists ${REVIEW_REVIEWER_ROW} as a row it may disable; a component may not be mutually exclusive with the review policy, which is one control, not a row`,
759
+ )
760
+ }
761
+
762
+ return findings
763
+ }
764
+
653
765
  /**
654
766
  * Run every component-surface rule against one repository root.
655
767
  * @param {string} root
@@ -735,6 +847,8 @@ export async function checkComponents(root) {
735
847
  findings.push(finding('registry-unreadable', 'error', live.registryError))
736
848
  }
737
849
  findings.push(...checkForbidden(surface, { booleans: live.booleans, registry: live.registry, patch: live.patch }))
850
+ // #M42: the review policy is one tri-state configuration control, not a row.
851
+ findings.push(...checkReviewPolicySurface(surface, live))
738
852
 
739
853
  // The decision record must exist: the ADR is where the policy is argued.
740
854
  if (!fs.existsSync(path.join(root, surface.adr))) {