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.
- package/CHANGELOG.md +32 -0
- package/COMPATIBILITY.md +1 -0
- package/CONTRIBUTING.md +1 -1
- package/GLOSSARY.md +6 -0
- package/PRINCIPLES.md +15 -7
- package/README.en.md +16 -15
- package/README.md +12 -10
- package/app/review.js +7 -0
- package/app/spawn-prechecks.js +12 -2
- package/app/tower.js +5 -5
- package/client.js +321 -0
- package/cordis.patch.yml +5 -3
- package/docs/adr/0005-component-surface.md +58 -13
- package/docs/architecture/overview.md +12 -6
- package/docs/examples/first-mission.md +3 -2
- package/docs/faq.md +9 -7
- package/docs/guide/install-upgrade-migration.md +32 -7
- package/docs/guide/quickstart.md +20 -10
- package/docs/guide/scenarios-and-philosophy.md +35 -28
- package/docs/operations/security.md +11 -10
- package/docs/reference/blockers.md +12 -7
- package/docs/reference/component-surface.md +14 -7
- package/docs/reference/config.generated.md +3 -2
- package/docs/reference/config.md +29 -3
- package/docs/reference/errors.generated.md +1 -0
- package/docs/reference/errors.md +2 -1
- package/docs/reference/surface-budget.md +20 -3
- package/docs/reference/testing.md +1 -1
- package/docs/reference/tools.generated.md +5 -5
- package/docs/reference/tools.md +3 -3
- package/docs/zh/faq.md +7 -5
- package/docs/zh/guide/install-upgrade-migration.md +25 -6
- package/docs/zh/guide/quickstart.md +17 -10
- package/docs/zh/guide/scenarios-and-philosophy.md +23 -15
- package/docs/zh/operations/security.md +9 -8
- package/docs/zh/reference/blockers.md +11 -7
- package/docs/zh/reference/config.md +21 -3
- package/docs/zh/reference/errors.md +2 -1
- package/docs/zh/reference/testing.md +2 -2
- package/docs/zh/reference/tools.md +3 -3
- package/domain/components.js +12 -4
- package/domain/config/schema.js +40 -5
- package/domain/contracts/outputs.js +2 -0
- package/domain/contracts/tool-inputs/missions.js +1 -1
- package/domain/errors.js +6 -0
- package/domain/gate.js +6 -3
- package/domain/integration/review.js +203 -9
- package/host/config.js +40 -3
- package/host/doctor.js +18 -7
- package/host/presets.js +9 -8
- package/host/tool-registry.js +9 -1
- package/package.json +7 -1
- package/scripts/check-components.mjs +120 -6
- package/scripts/check-pack.mjs +3 -0
- package/scripts/component-surface.json +16 -10
- package/scripts/pack-manifest.txt +1 -0
- package/scripts/surface-baseline.json +8 -1
- package/scripts/types-coverage.mjs +7 -0
- package/tools/preflight.js +1 -1
- package/tools/review.js +1 -1
- package/tools/spawn.js +2 -2
- package/tools/status.js +8 -1
- package/types/index.d.ts +10 -0
|
@@ -1,11 +1,197 @@
|
|
|
1
|
-
// Pure review-selection and exemption decisions
|
|
2
|
-
// projections. No repository
|
|
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
|
|
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
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
-
|
|
51
|
-
|
|
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 =
|
|
38
|
-
return resolveTowerConfig(
|
|
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
|
-
|
|
246
|
-
|
|
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
|
|
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
|
|
300
|
-
// is supported while review
|
|
301
|
-
// a fail, because no independent review can
|
|
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.
|
|
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.
|
|
10
|
-
//
|
|
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.
|
|
42
|
-
* off state is
|
|
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
|
|
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
|
|
56
|
-
? ` review.
|
|
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',
|
package/host/tool-registry.js
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
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,
|
|
12
|
-
//
|
|
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,
|
|
76
|
-
|
|
77
|
-
|
|
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))) {
|