dsh-tower 2.4.2 → 2.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (108) hide show
  1. package/CHANGELOG.md +72 -0
  2. package/COMPATIBILITY.md +2 -0
  3. package/CONTRIBUTING.md +5 -3
  4. package/GLOSSARY.md +73 -0
  5. package/PRINCIPLES.md +15 -7
  6. package/README.en.md +16 -15
  7. package/README.md +18 -10
  8. package/app/archive.js +16 -1
  9. package/app/board.js +16 -1
  10. package/app/gate.js +14 -0
  11. package/app/review.js +7 -0
  12. package/app/spawn-prechecks.js +30 -5
  13. package/app/tower.js +5 -5
  14. package/client.js +321 -0
  15. package/cordis.patch.yml +26 -9
  16. package/docs/adr/0004-surface-budget.md +10 -4
  17. package/docs/adr/0005-component-surface.md +222 -11
  18. package/docs/architecture/overview.md +12 -6
  19. package/docs/examples/first-mission.md +3 -2
  20. package/docs/faq.md +9 -7
  21. package/docs/guide/install-upgrade-migration.md +53 -7
  22. package/docs/guide/quickstart.md +91 -10
  23. package/docs/guide/release.md +60 -19
  24. package/docs/guide/scenarios-and-philosophy.md +35 -28
  25. package/docs/operations/security.md +11 -10
  26. package/docs/reference/blockers.md +12 -7
  27. package/docs/reference/component-surface.md +40 -21
  28. package/docs/reference/config.generated.md +6 -8
  29. package/docs/reference/config.md +36 -8
  30. package/docs/reference/doctor.md +11 -5
  31. package/docs/reference/errors.generated.md +1 -0
  32. package/docs/reference/errors.md +2 -1
  33. package/docs/reference/surface-budget.md +47 -12
  34. package/docs/reference/testing.md +1 -1
  35. package/docs/reference/tools.generated.md +5 -5
  36. package/docs/reference/tools.md +5 -5
  37. package/docs/zh/faq.md +7 -5
  38. package/docs/zh/guide/install-upgrade-migration.md +26 -7
  39. package/docs/zh/guide/quickstart.md +17 -10
  40. package/docs/zh/guide/scenarios-and-philosophy.md +23 -15
  41. package/docs/zh/operations/security.md +9 -8
  42. package/docs/zh/reference/blockers.md +11 -7
  43. package/docs/zh/reference/config.md +28 -8
  44. package/docs/zh/reference/doctor.md +7 -2
  45. package/docs/zh/reference/errors.md +2 -1
  46. package/docs/zh/reference/testing.md +2 -2
  47. package/docs/zh/reference/tools.md +6 -5
  48. package/domain/component-rules.js +216 -0
  49. package/domain/components.js +181 -53
  50. package/domain/config/schema.js +86 -28
  51. package/domain/contracts/outputs.js +2 -0
  52. package/domain/contracts/tool-inputs/missions.js +1 -1
  53. package/domain/errors.js +6 -0
  54. package/domain/gate.js +23 -5
  55. package/domain/integration/review.js +203 -9
  56. package/domain/scope.js +176 -5
  57. package/domain/surface.js +21 -7
  58. package/host/capability-archive.js +18 -0
  59. package/host/capability-board.js +18 -0
  60. package/host/capability-registry.js +4 -0
  61. package/host/config.js +40 -3
  62. package/host/doctor.js +68 -12
  63. package/host/presets.js +26 -8
  64. package/host/tool-registry.js +30 -5
  65. package/index.js +17 -6
  66. package/locale/archive/en.json +6 -0
  67. package/locale/archive/zh.json +6 -0
  68. package/locale/batch/en.json +1 -1
  69. package/locale/batch/zh.json +1 -1
  70. package/locale/board/en.json +6 -0
  71. package/locale/board/zh.json +6 -0
  72. package/locale/en.json +37 -22
  73. package/locale/hmr/en.json +2 -2
  74. package/locale/hmr/zh.json +1 -1
  75. package/locale/import/en.json +1 -1
  76. package/locale/import/zh.json +1 -1
  77. package/locale/leases/en.json +2 -2
  78. package/locale/leases/zh.json +2 -2
  79. package/locale/preset-reviewer/en.json +1 -1
  80. package/locale/preset-reviewer/zh.json +1 -1
  81. package/locale/preset-worker/en.json +1 -1
  82. package/locale/preset-worker/zh.json +1 -1
  83. package/locale/skills/en.json +1 -1
  84. package/locale/skills/zh.json +1 -1
  85. package/locale/tests/en.json +2 -2
  86. package/locale/tests/zh.json +2 -2
  87. package/locale/zh.json +36 -21
  88. package/package.json +17 -2
  89. package/scripts/check-components.mjs +698 -44
  90. package/scripts/check-pack.mjs +3 -0
  91. package/scripts/component-surface.json +101 -37
  92. package/scripts/generate-capability-locale.mjs +3 -0
  93. package/scripts/mount-smoke/surface.mjs +2 -0
  94. package/scripts/pack-manifest.txt +10 -0
  95. package/scripts/release-notes.mjs +256 -0
  96. package/scripts/surface-baseline.json +39 -3
  97. package/scripts/surface-baseline.mjs +15 -5
  98. package/scripts/types-coverage.mjs +7 -0
  99. package/skills.js +4 -1
  100. package/tools/complete.js +3 -1
  101. package/tools/preflight.js +1 -1
  102. package/tools/review.js +1 -1
  103. package/tools/spawn.js +7 -7
  104. package/tools/status.js +8 -1
  105. package/tools/test.js +7 -7
  106. package/types/index.d.ts +10 -0
  107. package/ui-bundle/locale/en.json +1 -1
  108. package/ui-bundle/locale/zh.json +1 -1
package/domain/scope.js CHANGED
@@ -155,6 +155,180 @@ function scopePrefix(pattern) {
155
155
  }
156
156
 
157
157
  /**
158
+ * The historical directory-prefix heuristic. Cheap, and deliberately wider
159
+ * than the exact glob intersection: it serializes `a/**` with `a` and
160
+ * `src/*.js` with `src/a/x.js`, and pairs anything with a leading wildcard.
161
+ * It is only an over-approximation, so it misses inline prefixes such as
162
+ * `a/b*` vs `a/bc*`; `globLanguagesIntersect` below covers those.
163
+ * @param {string} pa folded pattern
164
+ * @param {string} pb folded pattern
165
+ * @returns {boolean}
166
+ */
167
+ function prefixesOverlap(pa, pb) {
168
+ const x = scopePrefix(pa)
169
+ const y = scopePrefix(pb)
170
+ if (!x || !y) return true
171
+ if (x === y) return true
172
+ return x.startsWith(`${y}/`) || y.startsWith(`${x}/`)
173
+ }
174
+
175
+ // ---------------------------------------------------------------------------
176
+ // Exact glob-language intersection (F16, #130).
177
+ //
178
+ // Each pattern becomes an NFA over the documented grammar: a literal matches
179
+ // itself, `?` matches one non-`/` character, `*` matches zero or more non-`/`
180
+ // characters, and `**` matches zero or more of any character. Two patterns
181
+ // share a concrete path exactly when the product of their NFAs can reach the
182
+ // accepting states, so the check is complete for that grammar.
183
+
184
+ /** @typedef {{ kind: 'literal', char: string } | { kind: 'nonSlash' } | { kind: 'any' }} CharSet */
185
+ /** @typedef {{ charset: CharSet, to: number }} GlobTransition */
186
+ /** @typedef {{ kind: 'literal', char: string } | { kind: 'star' } | { kind: 'any' } | { kind: 'question' }} GlobToken */
187
+
188
+ /**
189
+ * Split a pattern into glob tokens exactly like `globToRegExp` walks it: a
190
+ * doubled `*` is one `**` token (anything, including `/`), every other `*` is
191
+ * one non-`/` token, `?` is one non-`/` character, and everything else is a
192
+ * literal (including `[`, `]`, `{` and `}` — they are not grammar, #130).
193
+ * @param {string} pattern
194
+ * @returns {GlobToken[]}
195
+ */
196
+ function globTokens(pattern) {
197
+ /** @type {GlobToken[]} */
198
+ const tokens = []
199
+ for (let index = 0; index < pattern.length; index += 1) {
200
+ const char = pattern[index]
201
+ if (char === '*') {
202
+ if (pattern[index + 1] === '*') {
203
+ tokens.push({ kind: 'any' })
204
+ index += 1
205
+ } else {
206
+ tokens.push({ kind: 'star' })
207
+ }
208
+ } else if (char === '?') {
209
+ tokens.push({ kind: 'question' })
210
+ } else {
211
+ tokens.push({ kind: 'literal', char })
212
+ }
213
+ }
214
+ return tokens
215
+ }
216
+
217
+ /**
218
+ * The characters one token can consume from `state`, and where they lead.
219
+ * A `*`/`**` token loops on itself; skipping it (consuming nothing) is the
220
+ * epsilon transition, handled by `epsilonClosure`.
221
+ * @param {GlobToken | undefined} token
222
+ * @param {number} state
223
+ * @returns {GlobTransition[]}
224
+ */
225
+ function tokenTransitions(token, state) {
226
+ if (token === undefined) return []
227
+ if (token.kind === 'literal') return [{ charset: { kind: 'literal', char: token.char }, to: state + 1 }]
228
+ if (token.kind === 'question') return [{ charset: { kind: 'nonSlash' }, to: state + 1 }]
229
+ if (token.kind === 'star') return [{ charset: { kind: 'nonSlash' }, to: state }]
230
+ return [{ charset: { kind: 'any' }, to: state }]
231
+ }
232
+
233
+ /**
234
+ * States reachable from `state` without consuming a character: a `*` or `**`
235
+ * token may be skipped, and consecutive skippable tokens chain.
236
+ * @param {GlobToken[]} tokens
237
+ * @param {number} state
238
+ * @returns {number[]}
239
+ */
240
+ function epsilonClosure(tokens, state) {
241
+ const out = [state]
242
+ let cursor = state
243
+ while (cursor < tokens.length) {
244
+ const token = tokens[cursor]
245
+ if (token === undefined || (token.kind !== 'star' && token.kind !== 'any')) break
246
+ cursor += 1
247
+ out.push(cursor)
248
+ }
249
+ return out
250
+ }
251
+
252
+ /**
253
+ * Whether one character can satisfy both transitions' character sets.
254
+ * `any` includes `/`, so it intersects every set.
255
+ * @param {CharSet} left
256
+ * @param {CharSet} right
257
+ * @returns {boolean}
258
+ */
259
+ function charsetsIntersect(left, right) {
260
+ if (left.kind === 'literal' && right.kind === 'literal') return left.char === right.char
261
+ if (left.kind === 'literal') return left.char !== '/' || right.kind === 'any'
262
+ if (right.kind === 'literal') return right.char !== '/' || left.kind === 'any'
263
+ return true
264
+ }
265
+
266
+ /**
267
+ * Whether the two patterns match at least one common path.
268
+ * @param {GlobToken[]} left
269
+ * @param {GlobToken[]} right
270
+ * @returns {boolean}
271
+ */
272
+ function globLanguagesIntersect(left, right) {
273
+ /** @type {number[][]} */
274
+ const leftClosures = []
275
+ for (let state = 0; state <= left.length; state += 1) leftClosures.push(epsilonClosure(left, state))
276
+ /** @type {number[][]} */
277
+ const rightClosures = []
278
+ for (let state = 0; state <= right.length; state += 1) rightClosures.push(epsilonClosure(right, state))
279
+
280
+ /** @type {[number, number][]} */
281
+ const queue = [[0, 0]]
282
+ const seen = new Set(['0:0'])
283
+ while (queue.length > 0) {
284
+ const pair = queue.pop()
285
+ if (pair === undefined) break
286
+ const [leftState, rightState] = pair
287
+ const leftClosure = leftClosures[leftState] ?? [leftState]
288
+ const rightClosure = rightClosures[rightState] ?? [rightState]
289
+ if (leftClosure.includes(left.length) && rightClosure.includes(right.length)) return true
290
+ for (const fromLeft of leftClosure) {
291
+ for (const fromRight of rightClosure) {
292
+ for (const leftMove of tokenTransitions(left[fromLeft], fromLeft)) {
293
+ for (const rightMove of tokenTransitions(right[fromRight], fromRight)) {
294
+ if (!charsetsIntersect(leftMove.charset, rightMove.charset)) continue
295
+ const key = `${leftMove.to}:${rightMove.to}`
296
+ if (seen.has(key)) continue
297
+ seen.add(key)
298
+ queue.push([leftMove.to, rightMove.to])
299
+ }
300
+ }
301
+ }
302
+ }
303
+ }
304
+ return false
305
+ }
306
+
307
+ /**
308
+ * Whether two scopes may own the same path, i.e. whether the missions that
309
+ * declare them must serialize. The contract is a *sound over-approximation*:
310
+ *
311
+ * * if one concrete path is in both scopes, this returns true;
312
+ * * it may also return true for scopes that only look adjacent, because the
313
+ * directory-prefix heuristic is deliberately conservative (`a/**` vs `a`,
314
+ * `src/*.js` vs `src/a/x.js`, anything with a leading wildcard).
315
+ *
316
+ * `false` therefore means "provably disjoint within the documented grammar"
317
+ * (`*` never crosses `/`, `**` does, `?` is one non-`/` character, everything
318
+ * else is literal, and only `*`, `**` and `?` are special).
319
+ *
320
+ * Each pair of entries is checked twice, and either check may return true:
321
+ * 1. the legacy directory-prefix heuristic (cheap, conservative);
322
+ * 2. an exact intersection of the two glob languages, which catches the
323
+ * inline prefixes the heuristic misses: `a/b*` and `a/bc*` both match
324
+ * `a/bcd`, and `src/a*.mjs` and `src/ab*.mjs` both match `src/abx.mjs`.
325
+ *
326
+ * Known limits: `[...]` and `{...}` are not grammar (they match literally,
327
+ * same as `fileInScope`); a leading wildcard is treated as a wildcard tree, so
328
+ * a scope that starts with a glob token overlaps every scope.
329
+ * `caseInsensitive` only decides the comparison — neither the stored scope
330
+ * text nor the result is rewritten.
331
+ *
158
332
  * @param {string[]} a
159
333
  * @param {string[]} b
160
334
  * @param {MatchOptions} [options]
@@ -166,11 +340,8 @@ export function scopesOverlap(a, b, { caseInsensitive = false } = {}) {
166
340
  const pa = foldCase(rawA, caseInsensitive)
167
341
  const pb = foldCase(rawB, caseInsensitive)
168
342
  if (pa === pb) return true
169
- const x = scopePrefix(pa)
170
- const y = scopePrefix(pb)
171
- if (!x || !y) return true
172
- if (x === y) return true
173
- if (x.startsWith(`${y}/`) || y.startsWith(`${x}/`)) return true
343
+ if (prefixesOverlap(pa, pb)) return true
344
+ if (globLanguagesIntersect(globTokens(pa), globTokens(pb))) return true
174
345
  }
175
346
  }
176
347
  return false
package/domain/surface.js CHANGED
@@ -20,8 +20,10 @@ export const SURFACE_KEYS = ['configKeys', 'sections', 'tools', 'components']
20
20
  // A count in this list is two-way: a contraction needs a decision record just
21
21
  // like growth does. The component surface is where the failure actually
22
22
  // happened (4 top-level rows -> 1 group row), so "a reduction is cheap" is
23
- // exactly the policy hole #120 closes.
24
- export const SURFACE_TWO_WAY_KEYS = ['components']
23
+ // exactly the policy hole #120 closes. #138 extends the same brake to the config
24
+ // keys: a removed switch is a capability decision too, so the key count may not
25
+ // shrink silently either. Sections and tools keep the ADR 0004 cheap-shrink rule.
26
+ export const SURFACE_TWO_WAY_KEYS = ['components', 'configKeys']
25
27
  export const CONFIG_CLASSES = ['core', 'tuning', 'niche']
26
28
  export const TOOL_CLASSES = ['core', 'optional']
27
29
  export const SURFACE_SCHEMA_VERSION = 1
@@ -240,8 +242,10 @@ function correctionEvidence(decision, where, record, exists) {
240
242
  * live surface, or a valid measurement-correction record.
241
243
  *
242
244
  * A two-way surface (see SURFACE_TWO_WAY_KEYS) also refuses a contraction whose
243
- * new count is not reached by a decision record; the other surfaces keep the
244
- * "a reduction is cheap" rule from ADR 0004.
245
+ * new count is not reached by a decision record, and every one of its decisions
246
+ * must carry a real `date` (YYYY-MM-DD): "needs a decision" means a dated
247
+ * decision, not a bare from/to step. The other surfaces keep the "a reduction is
248
+ * cheap" rule from ADR 0004 and only format-check a date when one is present.
245
249
  *
246
250
  * Extracted from `validateBaseline` so a narrower gate (`docs:check:components`,
247
251
  * #120) can validate just the component count without re-measuring the others.
@@ -268,14 +272,25 @@ export function validateSurfaceEntry(key, entry, live, liveNames, options = {})
268
272
  `${key} ${direction} from the committed baseline ${entry.value} to ${live}; update ${SURFACE_BASELINE_FILE} with an ADR or a deletion record before landing`,
269
273
  )
270
274
  }
275
+ const twoWay = SURFACE_TWO_WAY_KEYS.includes(key)
271
276
  let running = entry.seed
272
277
  entry.decisions.forEach((decision, index) => {
273
278
  const where = `${key}.decisions[${index}]`
274
279
  if (decision.from !== running) {
275
280
  record('broken-chain', `${where}.from ${decision.from} does not continue from ${running}`)
276
281
  }
277
- if (decision.date !== undefined && !ISO_DATE.test(String(decision.date))) {
278
- record('broken-chain', `${where}.date is not YYYY-MM-DD`)
282
+ const dated = typeof decision.date === 'string' && ISO_DATE.test(decision.date)
283
+ if (!dated) {
284
+ if (decision.date === undefined) {
285
+ if (twoWay) {
286
+ record(
287
+ 'broken-chain',
288
+ `${where}.date is required: a two-way surface needs a dated decision (YYYY-MM-DD), not just a from/to record`,
289
+ )
290
+ }
291
+ } else {
292
+ record('broken-chain', `${where}.date is not YYYY-MM-DD`)
293
+ }
279
294
  }
280
295
  const growth = decision.to - decision.from
281
296
  if (growth > 0) {
@@ -300,7 +315,6 @@ export function validateSurfaceEntry(key, entry, live, liveNames, options = {})
300
315
  }
301
316
  running = decision.to
302
317
  })
303
- const twoWay = SURFACE_TWO_WAY_KEYS.includes(key)
304
318
  if (running !== entry.value && !(entry.decisions.length === 0 && entry.value < running && !twoWay)) {
305
319
  record(
306
320
  'broken-chain',
@@ -0,0 +1,18 @@
1
+ // tower-archive component entry (#135): terminal-mission archival only.
2
+ //
3
+ // Archival used to be the `archive.enabled` tuning boolean. It is a capability —
4
+ // off means terminal missions stay hot in state.json — so it is its own row and
5
+ // its own switch. The row owns no tool: the sweep runs after mutations and asks
6
+ // this registry whether the row is mounted. Off means the hot state keeps
7
+ // growing; merges, gates and every other capability keep working and nothing is
8
+ // deleted. `archive.afterDays` / `archive.maxMissions` stay genuine tuning.
9
+ //
10
+ // The row is declared in cordis.patch.yml; the package export is `./archive`.
11
+ import { holdComponent } from './capability-registry.js'
12
+
13
+ export const name = 'dsh-tower/archive'
14
+ export const inject = ['tower']
15
+
16
+ export function apply(ctx) {
17
+ holdComponent(ctx, 'tower-archive')
18
+ }
@@ -0,0 +1,18 @@
1
+ // tower-board component entry (#133): the generated MISSIONS.md board only.
2
+ //
3
+ // The board used to be the `docs.board` tuning boolean. It is a capability — off
4
+ // means the human-readable artifact is simply not written — so it is its own row
5
+ // and its own switch. The row owns no tool: the board is written by the
6
+ // composition that registers tools, which asks this registry whether the row is
7
+ // mounted. Off leaves every mission tool, gate and other capability working; an
8
+ // existing board just goes stale. `docs.boardLocation` stays genuine tuning.
9
+ //
10
+ // The row is declared in cordis.patch.yml; the package export is `./board`.
11
+ import { holdComponent } from './capability-registry.js'
12
+
13
+ export const name = 'dsh-tower/board'
14
+ export const inject = ['tower']
15
+
16
+ export function apply(ctx) {
17
+ holdComponent(ctx, 'tower-board')
18
+ }
@@ -21,6 +21,8 @@ export const TOWER_COMPONENT_IDS = Object.freeze([
21
21
  'tower-import',
22
22
  'tower-leases',
23
23
  'tower-tests',
24
+ 'tower-board',
25
+ 'tower-archive',
24
26
  'tower-hmr',
25
27
  'preset-tower-reviewer',
26
28
  ])
@@ -32,6 +34,8 @@ export const COMPONENT_TOOLS = Object.freeze({
32
34
  'tower-import': Object.freeze(['tower_import_legacy', 'tower_legacy_map']),
33
35
  'tower-leases': Object.freeze(['tower_resource']),
34
36
  'tower-tests': Object.freeze(['tower_test']),
37
+ 'tower-board': Object.freeze([]),
38
+ 'tower-archive': Object.freeze([]),
35
39
  'tower-hmr': Object.freeze(['tower_reload']),
36
40
  'preset-tower-reviewer': Object.freeze([]),
37
41
  })
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
  )
@@ -331,6 +341,25 @@ export async function runDoctor({ tower, ctx, agent, hostVersion, compatibilityF
331
341
  : check('components', 'pass', componentSummary(components)),
332
342
  )
333
343
 
344
+ // #131: `spawn.requireLease` is acquired through `tower_resource`, which belongs
345
+ // to the tower-leases row. The runtime preconditions refuse this combination, so
346
+ // the doctor reports it first and names both remedies instead of letting it
347
+ // surface as a lease-busy from every spawn.
348
+ const requiredLease = String(config.spawn?.requireLease ?? '').trim()
349
+ if (requiredLease) {
350
+ const leasesDisabled = components.disabled.some((entry) => entry.id === 'tower-leases')
351
+ checks.push(
352
+ leasesDisabled
353
+ ? check(
354
+ 'spawn-lease',
355
+ 'fail',
356
+ `spawn.requireLease="${requiredLease}" is set but the tower-leases row is disabled, so tower_resource cannot acquire the lease`,
357
+ 're-enable the tower-leases row in the plugin page and restart DSH, or clear spawn.requireLease',
358
+ )
359
+ : check('spawn-lease', 'pass', `spawn.requireLease="${requiredLease}" is satisfiable (tower-leases row enabled)`),
360
+ )
361
+ }
362
+
334
363
  const subagents = ctx.get?.('subagents')
335
364
  checks.push(subagents ? check('subagents', 'pass', 'service mounted') : check('subagents', 'fail', 'subagents service is unavailable', 'enable the subagent bundle'))
336
365
  const hmr = ctx.get?.('hmr')
@@ -426,20 +455,41 @@ export async function runDoctor({ tower, ctx, agent, hostVersion, compatibilityF
426
455
  )
427
456
 
428
457
  const testConfigured = state.missions?.some((mission) => mission.testRuns !== undefined)
429
- const testsDisabled =
430
- config.tests?.enabled === false || components.disabled.some((entry) => entry.id === 'tower-tests')
458
+ // #136: the `tower-tests` component row is the only switch (there is no
459
+ // tests.enabled), so this check reads the registry and nothing else.
460
+ const testsDisabled = components.disabled.some((entry) => entry.id === 'tower-tests')
431
461
  checks.push(
432
462
  check(
433
463
  'tests',
434
464
  testsDisabled ? 'warn' : 'pass',
435
465
  testsDisabled
436
- ? `test discovery disabled${config.tests?.enabled === false ? ' (tests.enabled:false)' : ' (disabled by component tower-tests)'}`
466
+ ? 'test discovery disabled (disabled by component tower-tests; enable the tower-tests row and restart DSH)'
437
467
  : `admission=${config.tests?.admission ?? 'complete'} timeoutMs=${config.tests?.timeoutMs ?? 60000}${testConfigured ? '; receipts present' : ''}`,
438
468
  ),
439
469
  )
440
470
  checks.push(check('legacy', 'pass', `${state.legacySnapshots?.length ?? 0} legacy snapshot(s)`))
441
471
  checks.push(check('batches', 'pass', `${state.batches?.length ?? 0} batch(es)`))
442
472
 
473
+ // #133: whether MISSIONS.md is written is the `tower-board` row, not a config
474
+ // key, so name the component when it is off instead of a phantom key.
475
+ const boardDisabled = components.disabled.some((entry) => entry.id === 'tower-board')
476
+ const boardPath =
477
+ config.docs?.boardLocation === 'repo'
478
+ ? path.join(tower.root, 'MISSIONS.md')
479
+ : path.join(tower.paths.dir, 'MISSIONS.md')
480
+ checks.push(
481
+ check(
482
+ 'board',
483
+ boardDisabled ? 'warn' : 'pass',
484
+ boardDisabled
485
+ ? `MISSIONS.md is not written (disabled by component tower-board); expected at ${boardPath}`
486
+ : `MISSIONS.md refreshes after mission changes at ${boardPath}`,
487
+ boardDisabled
488
+ ? 'enable the tower-board row and restart DSH, or accept a stale board; every state tool and gate still works'
489
+ : undefined,
490
+ ),
491
+ )
492
+
443
493
  // Completion reports are the largest model-supplied strings in state.json;
444
494
  // report the biggest one so growth is visible before it becomes a problem.
445
495
  const reportSizes = (state.missions ?? [])
@@ -519,7 +569,12 @@ export async function runDoctor({ tower, ctx, agent, hostVersion, compatibilityF
519
569
  const archiveInventory = await inspectArchive(tower.paths)
520
570
  const stateBytes = await fsp.stat(tower.paths.state).then((stat) => stat.size).catch(() => null)
521
571
  const archivePolicy = config.archive ?? {}
522
- const policyText = `afterDays=${archivePolicy.afterDays ?? 30} maxMissions=${archivePolicy.maxMissions ?? 100}${archivePolicy.enabled === false ? ' disabled' : ''}`
572
+ // #135: archival is the `tower-archive` row. Name the component when it is off;
573
+ // the documented consequence is that terminal missions stay hot.
574
+ const archiveEnabled = !components.disabled.some((entry) => entry.id === 'tower-archive')
575
+ const policyText = `afterDays=${archivePolicy.afterDays ?? 30} maxMissions=${archivePolicy.maxMissions ?? 100}${
576
+ archiveEnabled ? '' : ' (disabled by component tower-archive: terminal missions stay hot and state.json keeps growing)'
577
+ }`
523
578
  if (!archiveInventory.exists) {
524
579
  checks.push(check('archive', 'pass', `no archived missions; ${policyText}`))
525
580
  } else if (archiveInventory.unreadable.length > 0) {
@@ -551,6 +606,7 @@ export async function runDoctor({ tower, ctx, agent, hostVersion, compatibilityF
551
606
  // reports (from #89) and archive (from #73) are both kept
552
607
  reports: reportsReport,
553
608
  surface: surfaceReport,
609
+ review: reviewPolicy,
554
610
  components,
555
611
  archive: {
556
612
  missions: state ? state.missions.length : null,
@@ -562,7 +618,7 @@ export async function runDoctor({ tower, ctx, agent, hostVersion, compatibilityF
562
618
  files: archiveInventory.files.length,
563
619
  unreadable: archiveInventory.unreadable,
564
620
  policy: {
565
- enabled: archivePolicy.enabled !== false,
621
+ enabled: archiveEnabled,
566
622
  afterDays: archivePolicy.afterDays ?? 30,
567
623
  maxMissions: archivePolicy.maxMissions ?? 100,
568
624
  },
package/host/presets.js CHANGED
@@ -6,14 +6,20 @@
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
15
15
  // here rather than left to fail at merge time with a misleading blocker.
16
+ //
17
+ // `tower-leases` adds a fourth (#131): `spawn.requireLease` is satisfied through
18
+ // `tower_resource`, the tool the `tower-leases` row owns. With that row off every
19
+ // spawn would fail at `lease-busy` advising a tool that is not registered, so the
20
+ // contradiction is refused up front instead.
16
21
  import { TowerToolError } from '../domain/errors.js'
22
+ import { reviewRequired } from '../domain/integration/review.js'
17
23
  import { componentEnabled, disabledReason } from './capability-registry.js'
18
24
 
19
25
  export function presetIds(config = {}) {
@@ -33,22 +39,22 @@ function rowId(preset) {
33
39
  * registered. If the service is unavailable, doctor reports that separately and
34
40
  * this check cannot make a claim either way.
35
41
  *
36
- * The reviewer preset is required only when `review.requireReview` is true; the
37
- * 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.
38
44
  */
39
45
  export async function assertPresetsAvailable(ctx, config = {}) {
40
46
  const service = ctx?.get?.('agentPresets')
41
47
  if (!service || typeof service.list !== 'function') return { checked: false, missing: [] }
42
48
  const presets = presetIds(config)
43
49
  const required = [presets.worker]
44
- if (config.review?.requireReview === true) required.push(presets.reviewer)
50
+ if (reviewRequired(config)) required.push(presets.reviewer)
45
51
  const available = new Set((await service.list()).map((entry) => String(entry.id)))
46
52
  const missing = required.filter((id) => !available.has(id))
47
53
  if (missing.length === 0) return { checked: true, missing: [] }
48
54
  const rows = [...new Set(missing.map(rowId))]
49
55
  const reviewLock =
50
- config.review?.requireReview === true && missing.includes(presets.reviewer)
51
- ? ` 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`
52
58
  : ''
53
59
  throw new TowerToolError(
54
60
  'preset-missing',
@@ -73,5 +79,17 @@ export async function assertRuntimePreconditions(ctx, config = {}) {
73
79
  `gates.requireTests is true but ${disabledReason('tower-tests')}; re-enable the tower-tests component or set gates.requireTests:false`,
74
80
  )
75
81
  }
82
+ // #131: `spawn.requireLease` is acquired through `tower_resource`, which only
83
+ // exists while the tower-leases row is mounted. Without this check the config
84
+ // looks valid and every spawn dies at `lease-busy` pointing at a tool that is
85
+ // not registered. Both remedies are named because either side may be the
86
+ // accidental one.
87
+ const requiredLease = String(config.spawn?.requireLease ?? '').trim()
88
+ if (requiredLease && !componentEnabled('tower-leases')) {
89
+ throw new TowerToolError(
90
+ 'config-invalid',
91
+ `spawn.requireLease="${requiredLease}" is set but ${disabledReason('tower-leases')}; tower_resource is not registered, so the lease can never be acquired. Re-enable the tower-leases component or clear spawn.requireLease`,
92
+ )
93
+ }
76
94
  return presets
77
95
  }