dsh-tower 2.4.1 → 2.5.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 (82) hide show
  1. package/CHANGELOG.md +51 -0
  2. package/COMPATIBILITY.md +2 -0
  3. package/CONTRIBUTING.md +11 -5
  4. package/GLOSSARY.md +67 -0
  5. package/README.md +6 -0
  6. package/app/archive.js +16 -1
  7. package/app/board.js +16 -1
  8. package/app/gate.js +14 -0
  9. package/app/spawn-prechecks.js +18 -3
  10. package/cordis.patch.yml +27 -8
  11. package/docs/adr/0004-surface-budget.md +10 -4
  12. package/docs/adr/0005-component-surface.md +166 -0
  13. package/docs/architecture/overview.md +47 -20
  14. package/docs/guide/install-upgrade-migration.md +21 -0
  15. package/docs/guide/quickstart.md +71 -0
  16. package/docs/guide/release.md +60 -19
  17. package/docs/reference/component-surface.md +29 -17
  18. package/docs/reference/config.generated.md +4 -7
  19. package/docs/reference/config.md +7 -5
  20. package/docs/reference/doctor.md +11 -5
  21. package/docs/reference/surface-budget.md +29 -11
  22. package/docs/reference/tools.generated.md +1 -1
  23. package/docs/reference/tools.md +2 -2
  24. package/docs/zh/guide/install-upgrade-migration.md +1 -1
  25. package/docs/zh/reference/config.md +7 -5
  26. package/docs/zh/reference/doctor.md +7 -2
  27. package/docs/zh/reference/tools.md +3 -2
  28. package/domain/component-rules.js +216 -0
  29. package/domain/components.js +169 -49
  30. package/domain/config/schema.js +46 -23
  31. package/domain/gate.js +17 -2
  32. package/domain/scope.js +176 -5
  33. package/domain/surface.js +21 -7
  34. package/host/capability-archive.js +18 -0
  35. package/host/capability-board.js +18 -0
  36. package/host/capability-registry.js +4 -0
  37. package/host/doctor.js +50 -5
  38. package/host/preset-reviewer.js +27 -0
  39. package/host/preset-worker.js +32 -0
  40. package/host/presets.js +17 -0
  41. package/host/tool-registry.js +21 -4
  42. package/index.js +17 -6
  43. package/locale/archive/en.json +6 -0
  44. package/locale/archive/zh.json +6 -0
  45. package/locale/batch/en.json +1 -1
  46. package/locale/batch/zh.json +1 -1
  47. package/locale/board/en.json +6 -0
  48. package/locale/board/zh.json +6 -0
  49. package/locale/en.json +41 -21
  50. package/locale/hmr/en.json +2 -2
  51. package/locale/hmr/zh.json +1 -1
  52. package/locale/import/en.json +1 -1
  53. package/locale/import/zh.json +1 -1
  54. package/locale/leases/en.json +2 -2
  55. package/locale/leases/zh.json +2 -2
  56. package/locale/preset-reviewer/en.json +6 -0
  57. package/locale/preset-reviewer/zh.json +6 -0
  58. package/locale/preset-worker/en.json +6 -0
  59. package/locale/preset-worker/zh.json +6 -0
  60. package/locale/skills/en.json +1 -1
  61. package/locale/skills/zh.json +1 -1
  62. package/locale/tests/en.json +2 -2
  63. package/locale/tests/zh.json +2 -2
  64. package/locale/zh.json +40 -20
  65. package/package.json +15 -2
  66. package/scripts/check-components.mjs +642 -42
  67. package/scripts/check-pack.mjs +7 -3
  68. package/scripts/component-surface.json +87 -29
  69. package/scripts/generate-capability-locale.mjs +100 -21
  70. package/scripts/mount-smoke/probe.mjs +33 -3
  71. package/scripts/mount-smoke/surface.mjs +144 -1
  72. package/scripts/mount-smoke.mjs +24 -4
  73. package/scripts/pack-manifest.txt +15 -0
  74. package/scripts/release-notes.mjs +256 -0
  75. package/scripts/surface-baseline.json +32 -3
  76. package/scripts/surface-baseline.mjs +15 -5
  77. package/skills.js +4 -1
  78. package/tools/complete.js +3 -1
  79. package/tools/spawn.js +5 -5
  80. package/tools/test.js +7 -7
  81. package/ui-bundle/locale/en.json +1 -1
  82. package/ui-bundle/locale/zh.json +1 -1
@@ -12,6 +12,12 @@
12
12
  //
13
13
  // This module is pure: no fs, no git. `scripts/check-components.mjs` supplies the
14
14
  // live schema, `cordis.patch.yml`, the locale files and the baseline.
15
+ //
16
+ // #138: the class/row-override rules live in `component-rules.js` so this module
17
+ // stays under the architecture aggregate-size brake; they are re-exported here.
18
+ import { collectConfigOverrides } from './component-rules.js'
19
+ export { checkKeyClassifications, checkRowOverrides } from './component-rules.js'
20
+
15
21
  export const COMPONENT_SURFACE_FILE = 'scripts/component-surface.json'
16
22
  export const COMPONENT_REFERENCE_FILE = 'docs/reference/component-surface.md'
17
23
  export const COMPONENT_SCHEMA_VERSION = 1
@@ -20,6 +26,13 @@ export const COMPONENT_SCOPES = ['visible', 'internal']
20
26
  export const COMPONENT_KINDS = ['group', 'row']
21
27
  export const LOCALE_FILES = ['locale/en.json', 'locale/zh.json']
22
28
  export const LOCALE_FIELDS = ['title', 'summary', 'disables']
29
+ // #134: every shipped Cordis patch has component rows, not only the root one.
30
+ // `ui-bundle/cordis.patch.yml` ships the `tower-ui` row. Labels for its rows live
31
+ // in that package's own locale files (a DSH row renders `meta.title` /
32
+ // `meta.description`, not the root `components` block).
33
+ export const COMPONENT_PATCH_FILES = ['cordis.patch.yml', 'ui-bundle/cordis.patch.yml']
34
+ export const META_LABEL_PATCHES = ['ui-bundle/cordis.patch.yml']
35
+ export const META_LOCALE_FILES = ['ui-bundle/locale/en.json', 'ui-bundle/locale/zh.json']
23
36
 
24
37
  const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/
25
38
 
@@ -58,6 +71,7 @@ function normalizeComponent(entry, index) {
58
71
  scope,
59
72
  kind,
60
73
  fallback: text(entry.fallback, `components[${index}].fallback`),
74
+ allowedOverrides: stringList(entry.allowedOverrides, `components[${index}].allowedOverrides`),
61
75
  }
62
76
  }
63
77
 
@@ -71,6 +85,10 @@ function normalizeKey(entry, index) {
71
85
  name: text(entry.name, `keys[${index}].name`),
72
86
  class: className,
73
87
  note: text(entry.note, `keys[${index}].note`),
88
+ // #138: an `optional-config` boolean (or a recorded behaviour knob) needs a
89
+ // dated reason for staying a tuning key; the gate refuses the classification
90
+ // without it.
91
+ exemption: entry.exemption === undefined ? null : normalizeDecision(entry.exemption, `keys[${index}].exemption`),
74
92
  }
75
93
  }
76
94
 
@@ -134,35 +152,6 @@ function normalizeGateableTools(value) {
134
152
  })
135
153
  }
136
154
 
137
- /**
138
- * Parse the static `host/tool-registry.js` route table that lets a boolean
139
- * remove a tool. This is the denylist's live input, so it is deliberately
140
- * tolerant about formatting (F11): it accepts a tuple with a trailing comma and
141
- * a `Map.set(...)` route, so a reformatted or rewritten table cannot fall out of
142
- * the allowlist and silently legalise a forbidden switch.
143
- *
144
- * @param {string} source the registry module text
145
- * @returns {Record<string, string>} tool name -> expose flag (without `tools.`)
146
- */
147
- export function parseOperationalRegistry(source) {
148
- const text = String(source ?? '')
149
- /** @type {Record<string, string>} */
150
- const registry = {}
151
- // Tuple form: ['tower_batch', 'exposeBatch'] with an optional trailing comma.
152
- for (const match of text.matchAll(
153
- /\[\s*['"](tower_[a-z0-9_]+)['"]\s*,\s*['"](expose[A-Za-z0-9]+)['"]\s*,?\s*\]/g,
154
- )) {
155
- registry[match[1]] = match[2]
156
- }
157
- // Map form: OPERATIONAL_TOOLS.set('tower_batch', 'exposeBatch').
158
- for (const match of text.matchAll(
159
- /OPERATIONAL_TOOLS\s*\.\s*set\(\s*['"](tower_[a-z0-9_]+)['"]\s*,\s*['"](expose[A-Za-z0-9]+)['"]\s*\)/g,
160
- )) {
161
- registry[match[1]] = match[2]
162
- }
163
- return registry
164
- }
165
-
166
155
  /**
167
156
  * Parse and validate the single source of truth. Structural problems throw with
168
157
  * an actionable message; semantic problems are findings from the validators
@@ -203,6 +192,9 @@ export function parseComponentSurface(source) {
203
192
  const keys = value.keys.map(normalizeKey)
204
193
  const forbidden = Array.isArray(value.forbidden) ? value.forbidden.map(normalizeForbidden) : []
205
194
  const gateableTools = normalizeGateableTools(value.gateableTools)
195
+ // #138: non-boolean keys that gate behaviour cannot hide behind
196
+ // `optional-config`; they are reviewed on the same axis as booleans.
197
+ const behaviorKnobs = stringList(value.behaviorKnobs, 'component surface behaviorKnobs')
206
198
  const seen = (list, label) => {
207
199
  const names = new Set()
208
200
  for (const entry of list) {
@@ -220,7 +212,17 @@ export function parseComponentSurface(source) {
220
212
  if (!components.some((component) => component.scope === 'visible')) {
221
213
  throw componentError('component surface needs at least one visible component')
222
214
  }
223
- return { schema: COMPONENT_SCHEMA_VERSION, date, adr, classes, components, keys, gateableTools, forbidden }
215
+ return {
216
+ schema: COMPONENT_SCHEMA_VERSION,
217
+ date,
218
+ adr,
219
+ classes,
220
+ components,
221
+ keys,
222
+ behaviorKnobs,
223
+ gateableTools,
224
+ forbidden,
225
+ }
224
226
  }
225
227
 
226
228
  /**
@@ -232,13 +234,13 @@ export function parseComponentSurface(source) {
232
234
  * sections rather than a single plugin.
233
235
  *
234
236
  * @param {string} text
235
- * @returns {{ rows: Array<{ id: string, scope: string, kind: string, group: boolean, overrides: string[] }>, visible: string[] }}
237
+ * @returns {{ rows: Array<{ id: string, scope: string, kind: string, group: boolean, overrides: string[], configOverrides: Array<{ key: string, value: string }>, patch?: string }>, visible: string[] }}
236
238
  */
237
239
  export function parseCordisPatch(text) {
238
240
  const lines = String(text ?? '').split(/\r?\n/)
239
241
  const insertIndex = lines.findIndex((line) => /^\s*-\s+insert:\s*$/.test(line))
240
242
  if (insertIndex < 0) return { rows: [], visible: [] }
241
- /** @type {Array<{ id: string, scope: string, kind: string, group: boolean, overrides: string[], indent: number, line: number }>} */
243
+ /** @type {Array<{ id: string, scope: string, kind: string, group: boolean, overrides: string[], configOverrides: Array<{ key: string, value: string }>, indent: number, line: number }>} */
242
244
  const rows = []
243
245
  const stack = []
244
246
  let rowIndent = null
@@ -263,6 +265,7 @@ export function parseCordisPatch(text) {
263
265
  kind: 'row',
264
266
  group: false,
265
267
  overrides: [],
268
+ configOverrides: [],
266
269
  indent,
267
270
  line: index,
268
271
  })
@@ -287,11 +290,10 @@ export function parseCordisPatch(text) {
287
290
  }
288
291
  row.group = block.some((line) => /^\s*group:\s*true\s*$/.test(line))
289
292
  row.kind = row.group ? 'group' : 'row'
290
- const overrides = new Set()
291
- for (const line of block) {
292
- for (const match of line.matchAll(/([A-Za-z0-9_-]+)\s*:\s*false\b/g)) overrides.add(match[1])
293
- }
294
- row.overrides = [...overrides]
293
+ row.configOverrides = row.group ? [] : collectConfigOverrides(lines, row)
294
+ // #138: keep the false-valued paths for the forbidden denylist, and the full
295
+ // override set for the "row matches the capability it owns" check.
296
+ row.overrides = row.configOverrides.filter((entry) => entry.value === 'false').map((entry) => entry.key)
295
297
  }
296
298
  return {
297
299
  rows: rows.map(({ line, indent, ...row }) => row),
@@ -299,13 +301,32 @@ export function parseCordisPatch(text) {
299
301
  }
300
302
  }
301
303
 
304
+ /**
305
+ * Merge every shipped patch into the one live shape the policy checks compare
306
+ * against. Rows keep the file they came from, so a finding can name it.
307
+ *
308
+ * @param {Record<string, ReturnType<typeof parseCordisPatch>>} patches file -> parsed patch
309
+ * @returns {{ rows: Array<{ id: string, scope: string, kind: string, group: boolean, overrides: string[], patch: string }>, visible: string[] }}
310
+ */
311
+ export function mergeCordisPatches(patches = {}) {
312
+ const files = Object.keys(patches)
313
+ const rows = []
314
+ for (const file of files) {
315
+ for (const row of patches[file]?.rows ?? []) rows.push({ ...row, patch: file })
316
+ }
317
+ return {
318
+ rows,
319
+ visible: rows.filter((row) => row.scope === 'visible').map((row) => row.id),
320
+ }
321
+ }
322
+
302
323
  /**
303
324
  * The shipped shape and the classification must describe the same components and
304
325
  * config keys. This is the parity half of the gate: a row without a class, or a
305
326
  * class without a row, is an unowned decision.
306
327
  *
307
328
  * @param {ReturnType<typeof parseComponentSurface>} surface
308
- * @param {{ configKeys: string[], patch: ReturnType<typeof parseCordisPatch> }} live
329
+ * @param {{ configKeys: string[], patch: ReturnType<typeof mergeCordisPatches> }} live
309
330
  */
310
331
  export function validateComponentSurface(surface, live) {
311
332
  const findings = []
@@ -315,25 +336,25 @@ export function validateComponentSurface(surface, live) {
315
336
  for (const component of surface.components) {
316
337
  const row = shipped.get(component.id)
317
338
  if (!row) {
318
- record('component-missing', `component surface lists "${component.id}" but cordis.patch.yml does not ship it`)
339
+ record('component-missing', `component surface lists "${component.id}" but no shipped cordis.patch.yml defines it`)
319
340
  continue
320
341
  }
321
342
  if (row.scope !== component.scope) {
322
343
  record(
323
344
  'component-scope',
324
- `component "${component.id}" is ${component.scope} in the source of truth but ${row.scope} in cordis.patch.yml`,
345
+ `component "${component.id}" is ${component.scope} in the source of truth but ${row.scope} in ${row.patch ?? 'cordis.patch.yml'}`,
325
346
  )
326
347
  }
327
348
  if (row.kind !== component.kind) {
328
349
  record(
329
350
  'component-kind',
330
- `component "${component.id}" is a ${component.kind} in the source of truth but a ${row.kind} in cordis.patch.yml`,
351
+ `component "${component.id}" is a ${component.kind} in the source of truth but a ${row.kind} in ${row.patch ?? 'cordis.patch.yml'}`,
331
352
  )
332
353
  }
333
354
  }
334
355
  for (const row of live.patch.rows) {
335
356
  if (!classified.has(row.id)) {
336
- record('component-unclassified', `cordis.patch.yml ships component "${row.id}" but the source of truth does not classify it`)
357
+ record('component-unclassified', `${row.patch ?? 'cordis.patch.yml'} ships component "${row.id}" but the source of truth does not classify it`)
337
358
  }
338
359
  }
339
360
  const declared = new Set(surface.keys.map((key) => key.name))
@@ -364,15 +385,22 @@ export function validateComponentSurface(surface, live) {
364
385
  * shipped locale. The locale set itself must only name known components, so a
365
386
  * typo cannot pass as coverage.
366
387
  *
388
+ * `options.ids` restricts the required set to the components whose labels live in
389
+ * these locale files. #134: the `tower-ui` row ships in `ui-bundle/` and is
390
+ * labelled by that package's own `meta` block, not by the root `components` block.
391
+ *
367
392
  * @param {ReturnType<typeof parseComponentSurface>} surface
368
393
  * @param {Record<string, any>} locales file path -> parsed locale JSON
394
+ * @param {{ ids?: string[] }} [options]
369
395
  */
370
- export function checkLocaleCoverage(surface, locales) {
396
+ export function checkLocaleCoverage(surface, locales, options = {}) {
371
397
  const findings = []
372
398
  const record = (code, message) => findings.push({ code, level: 'error', message })
373
399
  const known = new Set(surface.components.map((component) => component.id))
400
+ const required = options.ids === undefined ? null : new Set(options.ids)
374
401
  for (const component of surface.components) {
375
402
  if (component.scope !== 'visible') continue
403
+ if (required && !required.has(component.id)) continue
376
404
  for (const file of LOCALE_FILES) {
377
405
  const entry = locales?.[file]?.components?.[component.id]
378
406
  if (!entry || typeof entry !== 'object') {
@@ -393,6 +421,75 @@ export function checkLocaleCoverage(surface, locales) {
393
421
  return findings
394
422
  }
395
423
 
424
+ /**
425
+ * #132: the plugin page renders a group row from the root locale `meta.title` /
426
+ * `meta.description`. The off consequence of the `tower` group (off equals
427
+ * uninstalling Tower) must therefore appear in `meta.description`, in every
428
+ * shipped language — otherwise the most important consequence is dead copy.
429
+ *
430
+ * @param {ReturnType<typeof parseComponentSurface>} surface
431
+ * @param {Record<string, any>} locales file path -> parsed locale JSON
432
+ */
433
+ export function checkGroupLabelCoverage(surface, locales) {
434
+ const findings = []
435
+ const record = (code, message) => findings.push({ code, level: 'error', message })
436
+ for (const component of surface.components) {
437
+ if (component.scope !== 'visible' || component.kind !== 'group') continue
438
+ for (const file of LOCALE_FILES) {
439
+ const meta = locales?.[file]?.meta
440
+ const title = typeof meta?.title === 'string' ? meta.title.trim() : ''
441
+ const description = typeof meta?.description === 'string' ? meta.description.trim() : ''
442
+ if (!title) record('group-locale', `${file} misses meta.title for group component "${component.id}"`)
443
+ if (!description) {
444
+ record('group-locale', `${file} misses meta.description for group component "${component.id}"`)
445
+ continue
446
+ }
447
+ const disables = locales?.[file]?.components?.[component.id]?.disables
448
+ const consequence = typeof disables === 'string' ? disables.trim() : ''
449
+ if (!consequence) continue
450
+ if (!description.includes(consequence)) {
451
+ record(
452
+ 'group-locale',
453
+ `${file} meta.description does not state the off consequence of group component "${component.id}"`,
454
+ )
455
+ }
456
+ }
457
+ }
458
+ return findings
459
+ }
460
+
461
+ /**
462
+ * #134: a row that ships in its own package is labelled by that package's
463
+ * `meta.title` / `meta.description`, and DSH shows the "when off" consequence in
464
+ * the description. Require both meta strings and the off marker for every
465
+ * visible row of a meta-labelled patch, in every language file it ships.
466
+ *
467
+ * @param {string[]} ids row ids whose labels live in `locales`
468
+ * @param {Record<string, any>} locales file path -> parsed locale JSON
469
+ * @param {Record<string, string>} markers language -> off-effect marker
470
+ */
471
+ export function checkMetaLabelCoverage(ids, locales, markers) {
472
+ const findings = []
473
+ const record = (code, message) => findings.push({ code, level: 'error', message })
474
+ for (const id of ids) {
475
+ for (const [file, payload] of Object.entries(locales ?? {})) {
476
+ const lang = /([a-z]{2})\.json$/.exec(file)?.[1] ?? ''
477
+ const marker = markers?.[lang] ?? ''
478
+ const title = typeof payload?.meta?.title === 'string' ? payload.meta.title.trim() : ''
479
+ const description = typeof payload?.meta?.description === 'string' ? payload.meta.description.trim() : ''
480
+ if (!title) record('meta-locale', `row "${id}" misses meta.title in ${file}`)
481
+ if (!description) {
482
+ record('meta-locale', `row "${id}" misses meta.description in ${file}`)
483
+ continue
484
+ }
485
+ if (marker && !description.includes(marker)) {
486
+ record('meta-locale', `row "${id}" meta.description does not state the off consequence in ${file}`)
487
+ }
488
+ }
489
+ }
490
+ return findings
491
+ }
492
+
396
493
  /**
397
494
  * The denylist. The host, the health contract, the recovery escape, the review
398
495
  * contract and the merge gates may not be turned off by any group, row or
@@ -427,15 +524,17 @@ export function checkForbidden(surface, live) {
427
524
  // row, whatever capability owns it. `gatedBy` is folded in so a contract key
428
525
  // recorded only on the capability still resolves to its full name.
429
526
  const forbiddenLeaves = new Map()
527
+ const remember = (name) => {
528
+ const leaf = name.split('.').pop()
529
+ if (!forbiddenLeaves.has(name)) forbiddenLeaves.set(name, name)
530
+ if (!forbiddenLeaves.has(leaf)) forbiddenLeaves.set(leaf, name)
531
+ }
430
532
  for (const key of surface.keys) {
431
533
  if (key.class !== 'forbidden-to-disable') continue
432
- forbiddenLeaves.set(key.name.split('.').pop(), key.name)
534
+ remember(key.name)
433
535
  }
434
536
  for (const capability of surface.forbidden) {
435
- for (const key of capability.gatedBy ?? []) {
436
- const leaf = key.split('.').pop()
437
- if (!forbiddenLeaves.has(leaf)) forbiddenLeaves.set(leaf, key)
438
- }
537
+ for (const key of capability.gatedBy ?? []) remember(key)
439
538
  }
440
539
  for (const row of rows) {
441
540
  if (row.scope === 'visible' && forbiddenRowIds.has(row.id)) {
@@ -547,7 +646,17 @@ export function renderComponentSurface(surface, options = {}) {
547
646
  `| \`${component.id}\` | ${component.scope} | ${component.kind} | ${component.fallback} |`,
548
647
  )
549
648
  .join('\n')
550
- const keyRows = surface.keys.map((key) => `| \`${key.name}\` | \`${key.class}\` | ${key.note} |`).join('\n')
649
+ const keyRows = surface.keys
650
+ .map((key) => {
651
+ const exemption = key.exemption
652
+ ? ` Tuning exemption: ${key.exemption.reason} (${key.exemption.date}${key.exemption.issue ? ` · ${key.exemption.issue}` : ''} · [${decidedIn(key.exemption.ref)}](${relativeAdr(key.exemption.ref)}))`
653
+ : ''
654
+ return `| \`${key.name}\` | \`${key.class}\` | ${key.note}${exemption} |`
655
+ })
656
+ .join('\n')
657
+ const behaviorRows = (surface.behaviorKnobs ?? [])
658
+ .map((name) => `| \`${name}\` |`)
659
+ .join('\n') || '| _(none)_ |'
551
660
  const forbiddenRows = surface.forbidden
552
661
  .map((capability) => {
553
662
  const decision = capability.acceptedSwitches?.decision
@@ -621,6 +730,17 @@ All keys from \`domain/config/schema.js\`, classified on the component axis.
621
730
  |---|---|---|
622
731
  ${keyRows}
623
732
 
733
+ ## Behaviour knobs
734
+
735
+ Non-boolean keys that gate behaviour are reviewed on the same axis as booleans
736
+ (#138): each must be \`core\`, \`optional-component\`, \`forbidden-to-disable\`, or an
737
+ \`optional-config\` key with a dated tuning-only exemption. A string or union that
738
+ hides a capability cannot use the tuning class without that record.
739
+
740
+ | Key |
741
+ |---|
742
+ ${behaviorRows}
743
+
624
744
  ## Non-goals
625
745
 
626
746
  This page does not decide whether a key is useful, and it is not an approval
@@ -74,14 +74,36 @@ const SECTION_KEYS = {
74
74
  'requireLease',
75
75
  ],
76
76
  send: ['maxPerMinute', 'interruptDefault'],
77
- tests: ['enabled', 'admission', 'timeoutMs', 'maxBytes', 'autoChecks', 'allowPackageDiscovery'],
77
+ tests: ['admission', 'timeoutMs', 'maxBytes', 'autoChecks', 'allowPackageDiscovery'],
78
78
  gates: ['requireChecks', 'requireTests', 'allowReviewExemption', 'maxReportBytes'],
79
- docs: ['board', 'boardLocation'],
80
- archive: ['enabled', 'afterDays', 'maxMissions'],
79
+ docs: ['boardLocation'],
80
+ archive: ['afterDays', 'maxMissions'],
81
81
  leases: ['ttlMs'],
82
82
  dev: ['reloadDelayMs', 'reloadRequiredIds'],
83
83
  }
84
84
 
85
+ // #133/#135: `docs.board` and `archive.enabled` were component-shaped switches
86
+ // hiding in the config form. They are component rows now, so a legacy config
87
+ // keeps failing as an unknown key — with an upgrade note naming the row that
88
+ // replaced it.
89
+ /** @type {Record<string, string>} */
90
+ const REMOVED_KEYS = {
91
+ 'docs.board': 'tower-board',
92
+ 'archive.enabled': 'tower-archive',
93
+ }
94
+
95
+ /**
96
+ * Upgrade note for a removed component switch, appended to the unknown-key error.
97
+ * @param {string} group
98
+ * @param {string} key
99
+ * @returns {string}
100
+ */
101
+ function removedKeyNote(group, key) {
102
+ const component = REMOVED_KEYS[`${group}.${key}`]
103
+ if (!component) return ''
104
+ return ` — ${group}.${key} was removed: the ${component} component row in cordis.patch.yml is the switch (see docs/adr/0005-component-surface.md)`
105
+ }
106
+
85
107
  const MODEL_KEYS = ['provider', 'model', 'reasoningEffort', 'maxTokens']
86
108
 
87
109
  const ModelSchema = () =>
@@ -207,7 +229,9 @@ export const Config = Schema.object({
207
229
  .default({})
208
230
  .description('Message delivery policy.'),
209
231
  tests: Schema.object({
210
- enabled: Schema.boolean().default(true).description('Run declared test discovery when a mission configures it.'),
232
+ // #136: no `enabled` switch. The `tower-tests` component row *is* the
233
+ // switch; a second boolean could only disagree with it, and a row-off
234
+ // profile has no tower_test tool and no receipt to gate on.
211
235
  admission: Schema.union(['spawn', 'complete', 'off'])
212
236
  .default('off')
213
237
  .description('Run discovery before spawning a worker, when completing, or only on explicit tower_test (default off: repository code runs only on an explicit tower_test).'),
@@ -239,30 +263,24 @@ export const Config = Schema.object({
239
263
  .default({})
240
264
  .description('Merge-gate strictness.'),
241
265
  docs: Schema.object({
242
- board: Schema.boolean()
243
- .default(true)
244
- .description('Write a human-readable MISSIONS.md after mission changes.'),
245
266
  boardLocation: Schema.union(['common', 'repo'])
246
267
  .default('common')
247
- .description('common = <git-common-dir>/dsh-tower/MISSIONS.md; repo = <repoRoot>/MISSIONS.md.'),
268
+ .description('common = <git-common-dir>/dsh-tower/MISSIONS.md; repo = <repoRoot>/MISSIONS.md. Whether the board is written at all is the tower-board component row (#133).'),
248
269
  })
249
270
  .default({})
250
- .description('Human-readable artifacts.'),
271
+ .description('Human-readable artifact location. Turning the board off is a component row, not a tuning key.'),
251
272
  archive: Schema.object({
252
- enabled: Schema.boolean()
253
- .default(true)
254
- .description('Move terminal (merged/abandoned) missions whose worktree is gone into <git-common-dir>/dsh-tower/archive/ at the next mutation, so state.json stops carrying their full history.'),
255
273
  afterDays: Schema.natural()
256
274
  .min(0)
257
275
  .default(30)
258
- .description('Terminal missions untouched for at least this many days are archived on the next mutation (0 archives them immediately).'),
276
+ .description('Terminal missions untouched for at least this many days are archived on the next mutation (0 archives them immediately). Archival itself is the tower-archive component row (#135).'),
259
277
  maxMissions: Schema.natural()
260
278
  .min(0)
261
279
  .default(100)
262
280
  .description('Maximum terminal missions kept hot; once exceeded, the oldest eligible terminal missions are archived until the cap holds (0 archives every eligible terminal mission).'),
263
281
  })
264
282
  .default({})
265
- .description('Terminal-mission archival policy. Archived missions are moved, never deleted.'),
283
+ .description('Terminal-mission archival tuning. Whether archival runs at all is the tower-archive component row; archived missions are moved, never deleted.'),
266
284
  leases: Schema.object({
267
285
  ttlMs: Schema.natural().min(1000).max(86400000).default(900000).description('Default advisory lease lifetime; expiry never auto-releases.'),
268
286
  })
@@ -376,9 +394,10 @@ export function assertKnownKeys(raw, label) {
376
394
  if (!value || typeof value !== 'object' || Array.isArray(value)) continue
377
395
  const bad = Object.keys(value).filter((key) => !allowed.includes(key))
378
396
  if (bad.length > 0) {
397
+ const notes = bad.map((key) => removedKeyNote(group, key)).join('')
379
398
  throw new TowerToolError(
380
399
  'config-unknown-key',
381
- `${label} has unknown key(s) ${bad.map((key) => `${group}.${key}`).join(', ')}`,
400
+ `${label} has unknown key(s) ${bad.map((key) => `${group}.${key}`).join(', ')}${notes}`,
382
401
  )
383
402
  }
384
403
  if ((group === 'spawn') && value.workerModel && typeof value.workerModel === 'object') {
@@ -423,13 +442,10 @@ export function resolveTowerConfig(rawConfig) {
423
442
  throw new TowerToolError('config-invalid', `Tower config is invalid: ${String(error?.message ?? error)}`)
424
443
  }
425
444
  const mode = clone(parsed.mode ?? {})
426
- if (parsed.gates?.requireTests === true && parsed.tests?.enabled === false) {
427
- // #121: the tower-tests *component row* is the capability switch now; a
428
- // repository that turns the row off cannot satisfy gates.requireTests. The
429
- // component-level half of this invariant is enforced at plan/mode entry,
430
- // where the mounted rows are known (host/presets.js).
431
- throw new TowerToolError('config-invalid', 'Tower config is invalid: gates.requireTests is true but test discovery is disabled (tests.enabled:false)')
432
- }
445
+ // #136: `tests.enabled` is gone, so this resolver can no longer disagree with
446
+ // the mounted rows. The component-level invariant (`gates.requireTests:true`
447
+ // with the `tower-tests` row off) is enforced where the rows are known:
448
+ // host/presets.js at plan/mode entry, and the merge gate reports its cause.
433
449
  // Canonical keys win; the legacy top-level aliases only fill gaps.
434
450
  const rawMode = raw.mode ?? {}
435
451
  if (rawMode.promptSection === undefined && raw.section !== undefined) mode.promptSection = raw.section
@@ -440,9 +456,16 @@ export function resolveTowerConfig(rawConfig) {
440
456
  // Cordis config may omit this key, and an explicit legacy top-level
441
457
  // `allowAgentControl:true` must not be shadowed by a schema default.
442
458
  if (mode.allowAgentControl === undefined) mode.allowAgentControl = true
459
+ // F17/#130: materialize the resolved prompt section as well. `section` always
460
+ // mirrors `mode.promptSection`, so leaving the canonical field absent for a
461
+ // config with neither key would make a *second* pass copy the just-added
462
+ // `section:''` back into `mode` (`promptSection:''`): one pass would not be a
463
+ // fixed point. Filling it here makes `resolve(resolve(raw))` equal
464
+ // `resolve(raw)` for every valid config without changing any resolved value.
465
+ if (mode.promptSection === undefined) mode.promptSection = ''
443
466
  if (!Array.isArray(mode.denyTools) || mode.denyTools.length === 0) mode.denyTools = [...DEFAULT_DENY_TOOLS]
444
467
  return {
445
- section: mode.promptSection ?? '',
468
+ section: mode.promptSection,
446
469
  init: clone(parsed.init ?? {}),
447
470
  mode,
448
471
  review: clone(parsed.review ?? {}),
package/domain/gate.js CHANGED
@@ -47,7 +47,11 @@ export function completionBlockers(mission, config = {}) {
47
47
 
48
48
  /**
49
49
  * Review, roster, and test-evidence blockers; all inputs are snapshots.
50
- * @param {MergeGateOptions} options
50
+ * `testsComponent` is the optional `tower-tests` row state (#140). It is passed
51
+ * through the options bag rather than declared on `MergeGateOptions` because the
52
+ * domain may not read the component registry itself: the composition root
53
+ * publishes the probe on the port bag and `app/gate.js` forwards the snapshot.
54
+ * @param {MergeGateOptions & { testsComponent?: ({ enabled?: boolean, reason?: string } | null) }} options
51
55
  * @returns {Blocker[]}
52
56
  */
53
57
  export function mergeGateBlockers({
@@ -61,6 +65,7 @@ export function mergeGateBlockers({
61
65
  parentStatus = null,
62
66
  inactiveStates = [],
63
67
  rebaseWaived = false,
68
+ testsComponent = null,
64
69
  }) {
65
70
  /** @type {Blocker[]} */
66
71
  const blockers = []
@@ -106,7 +111,17 @@ export function mergeGateBlockers({
106
111
  if (config.gates?.requireTests === true) {
107
112
  const runs = (mission.testRuns ?? []).filter((run) => run.ok === true && run.treeTip && branchTip && run.treeTip === branchTip)
108
113
  if (runs.length === 0) {
109
- push('tests-missing', `mission ${mission.id} has no passing test discovery for tip ${branchTip ? branchTip.slice(0, 7) : '(none)'}`)
114
+ // #140: name the component cause when the row is off. Without this the
115
+ // blocker reads as a repository mistake ("no passing discovery") when the
116
+ // real cause is a disabled row and no tower_test tool exists to produce a
117
+ // receipt. The component state is a snapshot on the options bag.
118
+ const tip = branchTip ? branchTip.slice(0, 7) : '(none)'
119
+ const cause =
120
+ testsComponent && testsComponent.enabled !== true
121
+ ? `; ${testsComponent.reason ?? 'disabled by component tower-tests'} so no receipt can exist: ` +
122
+ 're-enable the tower-tests component (or set gates.requireTests:false) and re-record discovery'
123
+ : ''
124
+ push('tests-missing', `mission ${mission.id} has no passing test discovery for tip ${tip}${cause}`)
110
125
  }
111
126
  }
112
127
  return blockers