dsh-tower 2.4.2 → 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 +40 -0
- package/COMPATIBILITY.md +1 -0
- package/CONTRIBUTING.md +5 -3
- 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 +21 -6
- package/docs/adr/0004-surface-budget.md +10 -4
- package/docs/adr/0005-component-surface.md +166 -0
- 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 +27 -15
- 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/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 +37 -22
- 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 +1 -1
- package/locale/preset-reviewer/zh.json +1 -1
- package/locale/preset-worker/en.json +1 -1
- package/locale/preset-worker/zh.json +1 -1
- 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 +36 -21
- package/package.json +11 -2
- package/scripts/check-components.mjs +582 -42
- package/scripts/component-surface.json +85 -27
- package/scripts/generate-capability-locale.mjs +3 -0
- package/scripts/mount-smoke/surface.mjs +2 -0
- package/scripts/pack-manifest.txt +9 -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/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
|
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
|
-
|
|
170
|
-
|
|
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
|
-
|
|
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
|
|
244
|
-
*
|
|
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
|
-
|
|
278
|
-
|
|
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/doctor.js
CHANGED
|
@@ -331,6 +331,25 @@ export async function runDoctor({ tower, ctx, agent, hostVersion, compatibilityF
|
|
|
331
331
|
: check('components', 'pass', componentSummary(components)),
|
|
332
332
|
)
|
|
333
333
|
|
|
334
|
+
// #131: `spawn.requireLease` is acquired through `tower_resource`, which belongs
|
|
335
|
+
// to the tower-leases row. The runtime preconditions refuse this combination, so
|
|
336
|
+
// the doctor reports it first and names both remedies instead of letting it
|
|
337
|
+
// surface as a lease-busy from every spawn.
|
|
338
|
+
const requiredLease = String(config.spawn?.requireLease ?? '').trim()
|
|
339
|
+
if (requiredLease) {
|
|
340
|
+
const leasesDisabled = components.disabled.some((entry) => entry.id === 'tower-leases')
|
|
341
|
+
checks.push(
|
|
342
|
+
leasesDisabled
|
|
343
|
+
? check(
|
|
344
|
+
'spawn-lease',
|
|
345
|
+
'fail',
|
|
346
|
+
`spawn.requireLease="${requiredLease}" is set but the tower-leases row is disabled, so tower_resource cannot acquire the lease`,
|
|
347
|
+
're-enable the tower-leases row in the plugin page and restart DSH, or clear spawn.requireLease',
|
|
348
|
+
)
|
|
349
|
+
: check('spawn-lease', 'pass', `spawn.requireLease="${requiredLease}" is satisfiable (tower-leases row enabled)`),
|
|
350
|
+
)
|
|
351
|
+
}
|
|
352
|
+
|
|
334
353
|
const subagents = ctx.get?.('subagents')
|
|
335
354
|
checks.push(subagents ? check('subagents', 'pass', 'service mounted') : check('subagents', 'fail', 'subagents service is unavailable', 'enable the subagent bundle'))
|
|
336
355
|
const hmr = ctx.get?.('hmr')
|
|
@@ -426,20 +445,41 @@ export async function runDoctor({ tower, ctx, agent, hostVersion, compatibilityF
|
|
|
426
445
|
)
|
|
427
446
|
|
|
428
447
|
const testConfigured = state.missions?.some((mission) => mission.testRuns !== undefined)
|
|
429
|
-
|
|
430
|
-
|
|
448
|
+
// #136: the `tower-tests` component row is the only switch (there is no
|
|
449
|
+
// tests.enabled), so this check reads the registry and nothing else.
|
|
450
|
+
const testsDisabled = components.disabled.some((entry) => entry.id === 'tower-tests')
|
|
431
451
|
checks.push(
|
|
432
452
|
check(
|
|
433
453
|
'tests',
|
|
434
454
|
testsDisabled ? 'warn' : 'pass',
|
|
435
455
|
testsDisabled
|
|
436
|
-
?
|
|
456
|
+
? 'test discovery disabled (disabled by component tower-tests; enable the tower-tests row and restart DSH)'
|
|
437
457
|
: `admission=${config.tests?.admission ?? 'complete'} timeoutMs=${config.tests?.timeoutMs ?? 60000}${testConfigured ? '; receipts present' : ''}`,
|
|
438
458
|
),
|
|
439
459
|
)
|
|
440
460
|
checks.push(check('legacy', 'pass', `${state.legacySnapshots?.length ?? 0} legacy snapshot(s)`))
|
|
441
461
|
checks.push(check('batches', 'pass', `${state.batches?.length ?? 0} batch(es)`))
|
|
442
462
|
|
|
463
|
+
// #133: whether MISSIONS.md is written is the `tower-board` row, not a config
|
|
464
|
+
// key, so name the component when it is off instead of a phantom key.
|
|
465
|
+
const boardDisabled = components.disabled.some((entry) => entry.id === 'tower-board')
|
|
466
|
+
const boardPath =
|
|
467
|
+
config.docs?.boardLocation === 'repo'
|
|
468
|
+
? path.join(tower.root, 'MISSIONS.md')
|
|
469
|
+
: path.join(tower.paths.dir, 'MISSIONS.md')
|
|
470
|
+
checks.push(
|
|
471
|
+
check(
|
|
472
|
+
'board',
|
|
473
|
+
boardDisabled ? 'warn' : 'pass',
|
|
474
|
+
boardDisabled
|
|
475
|
+
? `MISSIONS.md is not written (disabled by component tower-board); expected at ${boardPath}`
|
|
476
|
+
: `MISSIONS.md refreshes after mission changes at ${boardPath}`,
|
|
477
|
+
boardDisabled
|
|
478
|
+
? 'enable the tower-board row and restart DSH, or accept a stale board; every state tool and gate still works'
|
|
479
|
+
: undefined,
|
|
480
|
+
),
|
|
481
|
+
)
|
|
482
|
+
|
|
443
483
|
// Completion reports are the largest model-supplied strings in state.json;
|
|
444
484
|
// report the biggest one so growth is visible before it becomes a problem.
|
|
445
485
|
const reportSizes = (state.missions ?? [])
|
|
@@ -519,7 +559,12 @@ export async function runDoctor({ tower, ctx, agent, hostVersion, compatibilityF
|
|
|
519
559
|
const archiveInventory = await inspectArchive(tower.paths)
|
|
520
560
|
const stateBytes = await fsp.stat(tower.paths.state).then((stat) => stat.size).catch(() => null)
|
|
521
561
|
const archivePolicy = config.archive ?? {}
|
|
522
|
-
|
|
562
|
+
// #135: archival is the `tower-archive` row. Name the component when it is off;
|
|
563
|
+
// the documented consequence is that terminal missions stay hot.
|
|
564
|
+
const archiveEnabled = !components.disabled.some((entry) => entry.id === 'tower-archive')
|
|
565
|
+
const policyText = `afterDays=${archivePolicy.afterDays ?? 30} maxMissions=${archivePolicy.maxMissions ?? 100}${
|
|
566
|
+
archiveEnabled ? '' : ' (disabled by component tower-archive: terminal missions stay hot and state.json keeps growing)'
|
|
567
|
+
}`
|
|
523
568
|
if (!archiveInventory.exists) {
|
|
524
569
|
checks.push(check('archive', 'pass', `no archived missions; ${policyText}`))
|
|
525
570
|
} else if (archiveInventory.unreadable.length > 0) {
|
|
@@ -562,7 +607,7 @@ export async function runDoctor({ tower, ctx, agent, hostVersion, compatibilityF
|
|
|
562
607
|
files: archiveInventory.files.length,
|
|
563
608
|
unreadable: archiveInventory.unreadable,
|
|
564
609
|
policy: {
|
|
565
|
-
enabled:
|
|
610
|
+
enabled: archiveEnabled,
|
|
566
611
|
afterDays: archivePolicy.afterDays ?? 30,
|
|
567
612
|
maxMissions: archivePolicy.maxMissions ?? 100,
|
|
568
613
|
},
|
package/host/presets.js
CHANGED
|
@@ -13,6 +13,11 @@
|
|
|
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'
|
|
17
22
|
import { componentEnabled, disabledReason } from './capability-registry.js'
|
|
18
23
|
|
|
@@ -73,5 +78,17 @@ export async function assertRuntimePreconditions(ctx, config = {}) {
|
|
|
73
78
|
`gates.requireTests is true but ${disabledReason('tower-tests')}; re-enable the tower-tests component or set gates.requireTests:false`,
|
|
74
79
|
)
|
|
75
80
|
}
|
|
81
|
+
// #131: `spawn.requireLease` is acquired through `tower_resource`, which only
|
|
82
|
+
// exists while the tower-leases row is mounted. Without this check the config
|
|
83
|
+
// looks valid and every spawn dies at `lease-busy` pointing at a tool that is
|
|
84
|
+
// not registered. Both remedies are named because either side may be the
|
|
85
|
+
// accidental one.
|
|
86
|
+
const requiredLease = String(config.spawn?.requireLease ?? '').trim()
|
|
87
|
+
if (requiredLease && !componentEnabled('tower-leases')) {
|
|
88
|
+
throw new TowerToolError(
|
|
89
|
+
'config-invalid',
|
|
90
|
+
`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`,
|
|
91
|
+
)
|
|
92
|
+
}
|
|
76
93
|
return presets
|
|
77
94
|
}
|