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.
- package/CHANGELOG.md +51 -0
- package/COMPATIBILITY.md +2 -0
- package/CONTRIBUTING.md +11 -5
- package/GLOSSARY.md +67 -0
- package/README.md +6 -0
- package/app/archive.js +16 -1
- package/app/board.js +16 -1
- package/app/gate.js +14 -0
- package/app/spawn-prechecks.js +18 -3
- package/cordis.patch.yml +27 -8
- package/docs/adr/0004-surface-budget.md +10 -4
- package/docs/adr/0005-component-surface.md +166 -0
- package/docs/architecture/overview.md +47 -20
- package/docs/guide/install-upgrade-migration.md +21 -0
- package/docs/guide/quickstart.md +71 -0
- package/docs/guide/release.md +60 -19
- package/docs/reference/component-surface.md +29 -17
- package/docs/reference/config.generated.md +4 -7
- package/docs/reference/config.md +7 -5
- package/docs/reference/doctor.md +11 -5
- package/docs/reference/surface-budget.md +29 -11
- package/docs/reference/tools.generated.md +1 -1
- package/docs/reference/tools.md +2 -2
- package/docs/zh/guide/install-upgrade-migration.md +1 -1
- package/docs/zh/reference/config.md +7 -5
- package/docs/zh/reference/doctor.md +7 -2
- package/docs/zh/reference/tools.md +3 -2
- package/domain/component-rules.js +216 -0
- package/domain/components.js +169 -49
- package/domain/config/schema.js +46 -23
- package/domain/gate.js +17 -2
- package/domain/scope.js +176 -5
- package/domain/surface.js +21 -7
- package/host/capability-archive.js +18 -0
- package/host/capability-board.js +18 -0
- package/host/capability-registry.js +4 -0
- package/host/doctor.js +50 -5
- package/host/preset-reviewer.js +27 -0
- package/host/preset-worker.js +32 -0
- package/host/presets.js +17 -0
- package/host/tool-registry.js +21 -4
- package/index.js +17 -6
- package/locale/archive/en.json +6 -0
- package/locale/archive/zh.json +6 -0
- package/locale/batch/en.json +1 -1
- package/locale/batch/zh.json +1 -1
- package/locale/board/en.json +6 -0
- package/locale/board/zh.json +6 -0
- package/locale/en.json +41 -21
- package/locale/hmr/en.json +2 -2
- package/locale/hmr/zh.json +1 -1
- package/locale/import/en.json +1 -1
- package/locale/import/zh.json +1 -1
- package/locale/leases/en.json +2 -2
- package/locale/leases/zh.json +2 -2
- package/locale/preset-reviewer/en.json +6 -0
- package/locale/preset-reviewer/zh.json +6 -0
- package/locale/preset-worker/en.json +6 -0
- package/locale/preset-worker/zh.json +6 -0
- package/locale/skills/en.json +1 -1
- package/locale/skills/zh.json +1 -1
- package/locale/tests/en.json +2 -2
- package/locale/tests/zh.json +2 -2
- package/locale/zh.json +40 -20
- package/package.json +15 -2
- package/scripts/check-components.mjs +642 -42
- package/scripts/check-pack.mjs +7 -3
- package/scripts/component-surface.json +87 -29
- package/scripts/generate-capability-locale.mjs +100 -21
- package/scripts/mount-smoke/probe.mjs +33 -3
- package/scripts/mount-smoke/surface.mjs +144 -1
- package/scripts/mount-smoke.mjs +24 -4
- package/scripts/pack-manifest.txt +15 -0
- package/scripts/release-notes.mjs +256 -0
- package/scripts/surface-baseline.json +32 -3
- package/scripts/surface-baseline.mjs +15 -5
- package/skills.js +4 -1
- package/tools/complete.js +3 -1
- package/tools/spawn.js +5 -5
- package/tools/test.js +7 -7
- package/ui-bundle/locale/en.json +1 -1
- package/ui-bundle/locale/zh.json +1 -1
package/domain/components.js
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
291
|
-
for
|
|
292
|
-
|
|
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
|
|
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
|
|
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',
|
|
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
|
-
|
|
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
|
|
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
|
package/domain/config/schema.js
CHANGED
|
@@ -74,14 +74,36 @@ const SECTION_KEYS = {
|
|
|
74
74
|
'requireLease',
|
|
75
75
|
],
|
|
76
76
|
send: ['maxPerMinute', 'interruptDefault'],
|
|
77
|
-
tests: ['
|
|
77
|
+
tests: ['admission', 'timeoutMs', 'maxBytes', 'autoChecks', 'allowPackageDiscovery'],
|
|
78
78
|
gates: ['requireChecks', 'requireTests', 'allowReviewExemption', 'maxReportBytes'],
|
|
79
|
-
docs: ['
|
|
80
|
-
archive: ['
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
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
|