dsh-tower 2.4.2 → 2.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +72 -0
- package/COMPATIBILITY.md +2 -0
- package/CONTRIBUTING.md +5 -3
- package/GLOSSARY.md +73 -0
- package/PRINCIPLES.md +15 -7
- package/README.en.md +16 -15
- package/README.md +18 -10
- package/app/archive.js +16 -1
- package/app/board.js +16 -1
- package/app/gate.js +14 -0
- package/app/review.js +7 -0
- package/app/spawn-prechecks.js +30 -5
- package/app/tower.js +5 -5
- package/client.js +321 -0
- package/cordis.patch.yml +26 -9
- package/docs/adr/0004-surface-budget.md +10 -4
- package/docs/adr/0005-component-surface.md +222 -11
- package/docs/architecture/overview.md +12 -6
- package/docs/examples/first-mission.md +3 -2
- package/docs/faq.md +9 -7
- package/docs/guide/install-upgrade-migration.md +53 -7
- package/docs/guide/quickstart.md +91 -10
- package/docs/guide/release.md +60 -19
- package/docs/guide/scenarios-and-philosophy.md +35 -28
- package/docs/operations/security.md +11 -10
- package/docs/reference/blockers.md +12 -7
- package/docs/reference/component-surface.md +40 -21
- package/docs/reference/config.generated.md +6 -8
- package/docs/reference/config.md +36 -8
- package/docs/reference/doctor.md +11 -5
- package/docs/reference/errors.generated.md +1 -0
- package/docs/reference/errors.md +2 -1
- package/docs/reference/surface-budget.md +47 -12
- package/docs/reference/testing.md +1 -1
- package/docs/reference/tools.generated.md +5 -5
- package/docs/reference/tools.md +5 -5
- package/docs/zh/faq.md +7 -5
- package/docs/zh/guide/install-upgrade-migration.md +26 -7
- package/docs/zh/guide/quickstart.md +17 -10
- package/docs/zh/guide/scenarios-and-philosophy.md +23 -15
- package/docs/zh/operations/security.md +9 -8
- package/docs/zh/reference/blockers.md +11 -7
- package/docs/zh/reference/config.md +28 -8
- package/docs/zh/reference/doctor.md +7 -2
- package/docs/zh/reference/errors.md +2 -1
- package/docs/zh/reference/testing.md +2 -2
- package/docs/zh/reference/tools.md +6 -5
- package/domain/component-rules.js +216 -0
- package/domain/components.js +181 -53
- package/domain/config/schema.js +86 -28
- package/domain/contracts/outputs.js +2 -0
- package/domain/contracts/tool-inputs/missions.js +1 -1
- package/domain/errors.js +6 -0
- package/domain/gate.js +23 -5
- package/domain/integration/review.js +203 -9
- 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/config.js +40 -3
- package/host/doctor.js +68 -12
- package/host/presets.js +26 -8
- package/host/tool-registry.js +30 -5
- 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 +17 -2
- package/scripts/check-components.mjs +698 -44
- package/scripts/check-pack.mjs +3 -0
- package/scripts/component-surface.json +101 -37
- package/scripts/generate-capability-locale.mjs +3 -0
- package/scripts/mount-smoke/surface.mjs +2 -0
- package/scripts/pack-manifest.txt +10 -0
- package/scripts/release-notes.mjs +256 -0
- package/scripts/surface-baseline.json +39 -3
- package/scripts/surface-baseline.mjs +15 -5
- package/scripts/types-coverage.mjs +7 -0
- package/skills.js +4 -1
- package/tools/complete.js +3 -1
- package/tools/preflight.js +1 -1
- package/tools/review.js +1 -1
- package/tools/spawn.js +7 -7
- package/tools/status.js +8 -1
- package/tools/test.js +7 -7
- package/types/index.d.ts +10 -0
- package/ui-bundle/locale/en.json +1 -1
- package/ui-bundle/locale/zh.json +1 -1
package/domain/scope.js
CHANGED
|
@@ -155,6 +155,180 @@ function scopePrefix(pattern) {
|
|
|
155
155
|
}
|
|
156
156
|
|
|
157
157
|
/**
|
|
158
|
+
* The historical directory-prefix heuristic. Cheap, and deliberately wider
|
|
159
|
+
* than the exact glob intersection: it serializes `a/**` with `a` and
|
|
160
|
+
* `src/*.js` with `src/a/x.js`, and pairs anything with a leading wildcard.
|
|
161
|
+
* It is only an over-approximation, so it misses inline prefixes such as
|
|
162
|
+
* `a/b*` vs `a/bc*`; `globLanguagesIntersect` below covers those.
|
|
163
|
+
* @param {string} pa folded pattern
|
|
164
|
+
* @param {string} pb folded pattern
|
|
165
|
+
* @returns {boolean}
|
|
166
|
+
*/
|
|
167
|
+
function prefixesOverlap(pa, pb) {
|
|
168
|
+
const x = scopePrefix(pa)
|
|
169
|
+
const y = scopePrefix(pb)
|
|
170
|
+
if (!x || !y) return true
|
|
171
|
+
if (x === y) return true
|
|
172
|
+
return x.startsWith(`${y}/`) || y.startsWith(`${x}/`)
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// ---------------------------------------------------------------------------
|
|
176
|
+
// Exact glob-language intersection (F16, #130).
|
|
177
|
+
//
|
|
178
|
+
// Each pattern becomes an NFA over the documented grammar: a literal matches
|
|
179
|
+
// itself, `?` matches one non-`/` character, `*` matches zero or more non-`/`
|
|
180
|
+
// characters, and `**` matches zero or more of any character. Two patterns
|
|
181
|
+
// share a concrete path exactly when the product of their NFAs can reach the
|
|
182
|
+
// accepting states, so the check is complete for that grammar.
|
|
183
|
+
|
|
184
|
+
/** @typedef {{ kind: 'literal', char: string } | { kind: 'nonSlash' } | { kind: 'any' }} CharSet */
|
|
185
|
+
/** @typedef {{ charset: CharSet, to: number }} GlobTransition */
|
|
186
|
+
/** @typedef {{ kind: 'literal', char: string } | { kind: 'star' } | { kind: 'any' } | { kind: 'question' }} GlobToken */
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Split a pattern into glob tokens exactly like `globToRegExp` walks it: a
|
|
190
|
+
* doubled `*` is one `**` token (anything, including `/`), every other `*` is
|
|
191
|
+
* one non-`/` token, `?` is one non-`/` character, and everything else is a
|
|
192
|
+
* literal (including `[`, `]`, `{` and `}` — they are not grammar, #130).
|
|
193
|
+
* @param {string} pattern
|
|
194
|
+
* @returns {GlobToken[]}
|
|
195
|
+
*/
|
|
196
|
+
function globTokens(pattern) {
|
|
197
|
+
/** @type {GlobToken[]} */
|
|
198
|
+
const tokens = []
|
|
199
|
+
for (let index = 0; index < pattern.length; index += 1) {
|
|
200
|
+
const char = pattern[index]
|
|
201
|
+
if (char === '*') {
|
|
202
|
+
if (pattern[index + 1] === '*') {
|
|
203
|
+
tokens.push({ kind: 'any' })
|
|
204
|
+
index += 1
|
|
205
|
+
} else {
|
|
206
|
+
tokens.push({ kind: 'star' })
|
|
207
|
+
}
|
|
208
|
+
} else if (char === '?') {
|
|
209
|
+
tokens.push({ kind: 'question' })
|
|
210
|
+
} else {
|
|
211
|
+
tokens.push({ kind: 'literal', char })
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
return tokens
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* The characters one token can consume from `state`, and where they lead.
|
|
219
|
+
* A `*`/`**` token loops on itself; skipping it (consuming nothing) is the
|
|
220
|
+
* epsilon transition, handled by `epsilonClosure`.
|
|
221
|
+
* @param {GlobToken | undefined} token
|
|
222
|
+
* @param {number} state
|
|
223
|
+
* @returns {GlobTransition[]}
|
|
224
|
+
*/
|
|
225
|
+
function tokenTransitions(token, state) {
|
|
226
|
+
if (token === undefined) return []
|
|
227
|
+
if (token.kind === 'literal') return [{ charset: { kind: 'literal', char: token.char }, to: state + 1 }]
|
|
228
|
+
if (token.kind === 'question') return [{ charset: { kind: 'nonSlash' }, to: state + 1 }]
|
|
229
|
+
if (token.kind === 'star') return [{ charset: { kind: 'nonSlash' }, to: state }]
|
|
230
|
+
return [{ charset: { kind: 'any' }, to: state }]
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* States reachable from `state` without consuming a character: a `*` or `**`
|
|
235
|
+
* token may be skipped, and consecutive skippable tokens chain.
|
|
236
|
+
* @param {GlobToken[]} tokens
|
|
237
|
+
* @param {number} state
|
|
238
|
+
* @returns {number[]}
|
|
239
|
+
*/
|
|
240
|
+
function epsilonClosure(tokens, state) {
|
|
241
|
+
const out = [state]
|
|
242
|
+
let cursor = state
|
|
243
|
+
while (cursor < tokens.length) {
|
|
244
|
+
const token = tokens[cursor]
|
|
245
|
+
if (token === undefined || (token.kind !== 'star' && token.kind !== 'any')) break
|
|
246
|
+
cursor += 1
|
|
247
|
+
out.push(cursor)
|
|
248
|
+
}
|
|
249
|
+
return out
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Whether one character can satisfy both transitions' character sets.
|
|
254
|
+
* `any` includes `/`, so it intersects every set.
|
|
255
|
+
* @param {CharSet} left
|
|
256
|
+
* @param {CharSet} right
|
|
257
|
+
* @returns {boolean}
|
|
258
|
+
*/
|
|
259
|
+
function charsetsIntersect(left, right) {
|
|
260
|
+
if (left.kind === 'literal' && right.kind === 'literal') return left.char === right.char
|
|
261
|
+
if (left.kind === 'literal') return left.char !== '/' || right.kind === 'any'
|
|
262
|
+
if (right.kind === 'literal') return right.char !== '/' || left.kind === 'any'
|
|
263
|
+
return true
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Whether the two patterns match at least one common path.
|
|
268
|
+
* @param {GlobToken[]} left
|
|
269
|
+
* @param {GlobToken[]} right
|
|
270
|
+
* @returns {boolean}
|
|
271
|
+
*/
|
|
272
|
+
function globLanguagesIntersect(left, right) {
|
|
273
|
+
/** @type {number[][]} */
|
|
274
|
+
const leftClosures = []
|
|
275
|
+
for (let state = 0; state <= left.length; state += 1) leftClosures.push(epsilonClosure(left, state))
|
|
276
|
+
/** @type {number[][]} */
|
|
277
|
+
const rightClosures = []
|
|
278
|
+
for (let state = 0; state <= right.length; state += 1) rightClosures.push(epsilonClosure(right, state))
|
|
279
|
+
|
|
280
|
+
/** @type {[number, number][]} */
|
|
281
|
+
const queue = [[0, 0]]
|
|
282
|
+
const seen = new Set(['0:0'])
|
|
283
|
+
while (queue.length > 0) {
|
|
284
|
+
const pair = queue.pop()
|
|
285
|
+
if (pair === undefined) break
|
|
286
|
+
const [leftState, rightState] = pair
|
|
287
|
+
const leftClosure = leftClosures[leftState] ?? [leftState]
|
|
288
|
+
const rightClosure = rightClosures[rightState] ?? [rightState]
|
|
289
|
+
if (leftClosure.includes(left.length) && rightClosure.includes(right.length)) return true
|
|
290
|
+
for (const fromLeft of leftClosure) {
|
|
291
|
+
for (const fromRight of rightClosure) {
|
|
292
|
+
for (const leftMove of tokenTransitions(left[fromLeft], fromLeft)) {
|
|
293
|
+
for (const rightMove of tokenTransitions(right[fromRight], fromRight)) {
|
|
294
|
+
if (!charsetsIntersect(leftMove.charset, rightMove.charset)) continue
|
|
295
|
+
const key = `${leftMove.to}:${rightMove.to}`
|
|
296
|
+
if (seen.has(key)) continue
|
|
297
|
+
seen.add(key)
|
|
298
|
+
queue.push([leftMove.to, rightMove.to])
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
return false
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* Whether two scopes may own the same path, i.e. whether the missions that
|
|
309
|
+
* declare them must serialize. The contract is a *sound over-approximation*:
|
|
310
|
+
*
|
|
311
|
+
* * if one concrete path is in both scopes, this returns true;
|
|
312
|
+
* * it may also return true for scopes that only look adjacent, because the
|
|
313
|
+
* directory-prefix heuristic is deliberately conservative (`a/**` vs `a`,
|
|
314
|
+
* `src/*.js` vs `src/a/x.js`, anything with a leading wildcard).
|
|
315
|
+
*
|
|
316
|
+
* `false` therefore means "provably disjoint within the documented grammar"
|
|
317
|
+
* (`*` never crosses `/`, `**` does, `?` is one non-`/` character, everything
|
|
318
|
+
* else is literal, and only `*`, `**` and `?` are special).
|
|
319
|
+
*
|
|
320
|
+
* Each pair of entries is checked twice, and either check may return true:
|
|
321
|
+
* 1. the legacy directory-prefix heuristic (cheap, conservative);
|
|
322
|
+
* 2. an exact intersection of the two glob languages, which catches the
|
|
323
|
+
* inline prefixes the heuristic misses: `a/b*` and `a/bc*` both match
|
|
324
|
+
* `a/bcd`, and `src/a*.mjs` and `src/ab*.mjs` both match `src/abx.mjs`.
|
|
325
|
+
*
|
|
326
|
+
* Known limits: `[...]` and `{...}` are not grammar (they match literally,
|
|
327
|
+
* same as `fileInScope`); a leading wildcard is treated as a wildcard tree, so
|
|
328
|
+
* a scope that starts with a glob token overlaps every scope.
|
|
329
|
+
* `caseInsensitive` only decides the comparison — neither the stored scope
|
|
330
|
+
* text nor the result is rewritten.
|
|
331
|
+
*
|
|
158
332
|
* @param {string[]} a
|
|
159
333
|
* @param {string[]} b
|
|
160
334
|
* @param {MatchOptions} [options]
|
|
@@ -166,11 +340,8 @@ export function scopesOverlap(a, b, { caseInsensitive = false } = {}) {
|
|
|
166
340
|
const pa = foldCase(rawA, caseInsensitive)
|
|
167
341
|
const pb = foldCase(rawB, caseInsensitive)
|
|
168
342
|
if (pa === pb) return true
|
|
169
|
-
|
|
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/config.js
CHANGED
|
@@ -24,6 +24,7 @@ import {
|
|
|
24
24
|
resolveTowerConfig,
|
|
25
25
|
} from '../domain/config/schema.js'
|
|
26
26
|
import { TowerToolError } from '../domain/errors.js'
|
|
27
|
+
import { materializeVolatile, resolveReviewPolicy, reviewUnavailable } from '../domain/integration/review.js'
|
|
27
28
|
|
|
28
29
|
export { Config, DEFAULT_DENY_TOOLS, resolveTowerConfig }
|
|
29
30
|
|
|
@@ -31,11 +32,16 @@ const REPO_CONFIG_MAX_BYTES = 1024 * 1024
|
|
|
31
32
|
|
|
32
33
|
// The last raw profile config `apply()` received; repository config is merged
|
|
33
34
|
// over it so a repo can tune spawn/merge/review without editing the profile.
|
|
35
|
+
//
|
|
36
|
+
// #M40: this must stay the *raw* object. A `.volatile()` field arrives as a
|
|
37
|
+
// live reference that Cordis updates in place on a settings write, and cloning
|
|
38
|
+
// (JSON) would freeze a copy and hide the new value from runtimeConfig(). Only
|
|
39
|
+
// readers that need plain data (profileConfig) materialize it.
|
|
34
40
|
let profileRaw = {}
|
|
35
41
|
|
|
36
42
|
export function configureRuntime(config) {
|
|
37
|
-
profileRaw =
|
|
38
|
-
return resolveTowerConfig(
|
|
43
|
+
profileRaw = config ?? {}
|
|
44
|
+
return resolveTowerConfig(profileRaw)
|
|
39
45
|
}
|
|
40
46
|
|
|
41
47
|
export function runtimeConfig() {
|
|
@@ -44,7 +50,7 @@ export function runtimeConfig() {
|
|
|
44
50
|
|
|
45
51
|
/** Raw profile config before defaults are materialized (for doctor warnings). */
|
|
46
52
|
export function profileConfig() {
|
|
47
|
-
return clone(profileRaw) ?? {}
|
|
53
|
+
return clone(materializeVolatile(profileRaw)) ?? {}
|
|
48
54
|
}
|
|
49
55
|
|
|
50
56
|
export function repoConfigPath(commonDir) {
|
|
@@ -92,6 +98,10 @@ export async function readRepoConfig(commonDir) {
|
|
|
92
98
|
// Validate value types without letting the schema's defaults win over the
|
|
93
99
|
// profile layer: merge into an empty raw object and discard the result.
|
|
94
100
|
Config(parsed)
|
|
101
|
+
// #M39: the schema accepts both review keys; only the resolver knows that an
|
|
102
|
+
// explicit `requireReview:true` next to a conflicting `review.mode` is a
|
|
103
|
+
// contradiction. Reject it here, before the layer can be merged.
|
|
104
|
+
resolveReviewPolicy(parsed.review)
|
|
95
105
|
} catch (error) {
|
|
96
106
|
throw new TowerToolError('config-invalid', `repo config at ${file} is invalid: ${String(error?.message ?? error)}`)
|
|
97
107
|
}
|
|
@@ -130,6 +140,33 @@ export async function resolveRepoConfig(repo) {
|
|
|
130
140
|
if (!Array.isArray(merged.mode?.denyTools) || merged.mode.denyTools.length === 0) {
|
|
131
141
|
merged.mode = { ...(merged.mode ?? {}), denyTools: [...DEFAULT_DENY_TOOLS] }
|
|
132
142
|
}
|
|
143
|
+
// #M39: the review policy is resolved per layer instead of deep-merging two
|
|
144
|
+
// resolved shapes. A resolved base always carries both `review.mode` and its
|
|
145
|
+
// mirrored alias, so a repository layer that sets only one of the two would
|
|
146
|
+
// otherwise pair a stale inherited `mode` with the new alias. The repository
|
|
147
|
+
// layer wins as a whole when it names either key; a layer that names neither
|
|
148
|
+
// inherits the profile policy untouched.
|
|
149
|
+
const repoReview = repoLayer.review
|
|
150
|
+
if (repoReview && typeof repoReview === 'object' && !Array.isArray(repoReview)) {
|
|
151
|
+
const explicit = repoReview.mode !== undefined || repoReview.requireReview !== undefined
|
|
152
|
+
if (explicit) {
|
|
153
|
+
const repoPolicy = resolveReviewPolicy(repoReview)
|
|
154
|
+
if (reviewUnavailable(base) && repoPolicy.mode !== 'off') {
|
|
155
|
+
// The tool surface is fixed when the plugin applies: a profile with
|
|
156
|
+
// review off never registered tower_review, so a repository may relax
|
|
157
|
+
// review further but cannot re-enable a tool that does not exist.
|
|
158
|
+
throw new TowerToolError(
|
|
159
|
+
'config-invalid',
|
|
160
|
+
`review.mode is off in the profile, so tower_review is not registered; repository config cannot set review.mode:${repoPolicy.mode}. Set review.mode in the profile instead.`,
|
|
161
|
+
)
|
|
162
|
+
}
|
|
163
|
+
merged.review = {
|
|
164
|
+
...(merged.review ?? {}),
|
|
165
|
+
mode: repoPolicy.mode,
|
|
166
|
+
requireReview: repoPolicy.mode === 'required',
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
133
170
|
merged.section = merged.mode?.promptSection ?? merged.section ?? ''
|
|
134
171
|
return {
|
|
135
172
|
...merged,
|
package/host/doctor.js
CHANGED
|
@@ -13,6 +13,7 @@ import { SCHEMA_VERSION } from '../domain/mission.js'
|
|
|
13
13
|
import { DEFAULT_MAX_REPORT_BYTES, completionReportBytes } from '../domain/missions/completion.js'
|
|
14
14
|
import { comparisonMode } from '../domain/scope.js'
|
|
15
15
|
import { configSurface } from '../domain/config/schema.js'
|
|
16
|
+
import { reviewPolicyView, reviewRequired } from '../domain/integration/review.js'
|
|
16
17
|
import { parseBaseline, surfaceLine, SURFACE_BASELINE_FILE } from '../domain/surface.js'
|
|
17
18
|
import { assessHostVersion, detectHostVersion, readCompatibility } from './compatibility.js'
|
|
18
19
|
import { profileConfig } from './config.js'
|
|
@@ -242,8 +243,11 @@ export async function runDoctor({ tower, ctx, agent, hostVersion, compatibilityF
|
|
|
242
243
|
const config = tower.config ?? {}
|
|
243
244
|
const profile = profileConfig()
|
|
244
245
|
const staleDefaults = []
|
|
245
|
-
|
|
246
|
-
|
|
246
|
+
// #M39: `review.mode` replaces the deprecated boolean. A profile that names
|
|
247
|
+
// neither key relies on the default (optional), so say so rather than letting
|
|
248
|
+
// a reader assume review is enforced.
|
|
249
|
+
if (profile.review && profile.review.mode === undefined && profile.review.requireReview === undefined) {
|
|
250
|
+
staleDefaults.push('review.mode defaults to optional; review.requireReview is a deprecated alias')
|
|
247
251
|
}
|
|
248
252
|
if (profile.mode && profile.mode.allowAgentControl === undefined) {
|
|
249
253
|
staleDefaults.push('mode.allowAgentControl now defaults to true (2.0.4)')
|
|
@@ -261,6 +265,11 @@ export async function runDoctor({ tower, ctx, agent, hostVersion, compatibilityF
|
|
|
261
265
|
'optional: add <git-common-dir>/dsh-tower/config.json',
|
|
262
266
|
),
|
|
263
267
|
)
|
|
268
|
+
// #M39: the resolved review mode is its own diagnostic fact. `off` is a
|
|
269
|
+
// supported, intentional posture (not a degradation), so it is a pass whose
|
|
270
|
+
// detail says exactly what is unavailable.
|
|
271
|
+
const reviewPolicy = reviewPolicyView(config)
|
|
272
|
+
checks.push(check('review', 'pass', reviewPolicy.detail))
|
|
264
273
|
// #84: report the concept-budget surface instead of implying it. The counts
|
|
265
274
|
// are the live schema/tool surface; the date is the committed baseline, so a
|
|
266
275
|
// reader sees both the number and the decision it was frozen at.
|
|
@@ -288,7 +297,7 @@ export async function runDoctor({ tower, ctx, agent, hostVersion, compatibilityF
|
|
|
288
297
|
),
|
|
289
298
|
)
|
|
290
299
|
const presets = ctx.get?.('agentPresets')
|
|
291
|
-
const reviewerRequired = config
|
|
300
|
+
const reviewerRequired = reviewRequired(config)
|
|
292
301
|
if (presets && typeof presets.list === 'function') {
|
|
293
302
|
try {
|
|
294
303
|
const available = (await presets.list()).map((entry) => String(entry.id))
|
|
@@ -296,9 +305,10 @@ export async function runDoctor({ tower, ctx, agent, hostVersion, compatibilityF
|
|
|
296
305
|
const reviewer = config.spawn?.reviewerPreset ?? 'tower-reviewer'
|
|
297
306
|
const required = reviewerRequired ? [worker, reviewer] : [worker]
|
|
298
307
|
const missing = required.filter((id) => !available.includes(id))
|
|
299
|
-
// #121: `preset-tower-reviewer` is a conditional component. Its off
|
|
300
|
-
// is supported while review
|
|
301
|
-
// a fail, because no independent review can
|
|
308
|
+
// #121/#M39: `preset-tower-reviewer` is a conditional component. Its off
|
|
309
|
+
// state is supported while review is off or optional; with
|
|
310
|
+
// `review.mode:required` it is a fail, because no independent review can
|
|
311
|
+
// be dispatched.
|
|
302
312
|
if (missing.length === 0) {
|
|
303
313
|
const reviewerNote = available.includes(reviewer)
|
|
304
314
|
? `reviewer=${reviewer}`
|
|
@@ -309,7 +319,7 @@ export async function runDoctor({ tower, ctx, agent, hostVersion, compatibilityF
|
|
|
309
319
|
check(
|
|
310
320
|
'presets',
|
|
311
321
|
'fail',
|
|
312
|
-
`missing preset(s): ${missing.join(', ')}${reviewerRequired && missing.includes(reviewer) ? ' (review.
|
|
322
|
+
`missing preset(s): ${missing.join(', ')}${reviewerRequired && missing.includes(reviewer) ? ' (review.mode is required, so preset-tower-reviewer may not be off)' : ''}`,
|
|
313
323
|
'enable the Tower preset component and retry',
|
|
314
324
|
),
|
|
315
325
|
)
|
|
@@ -331,6 +341,25 @@ export async function runDoctor({ tower, ctx, agent, hostVersion, compatibilityF
|
|
|
331
341
|
: check('components', 'pass', componentSummary(components)),
|
|
332
342
|
)
|
|
333
343
|
|
|
344
|
+
// #131: `spawn.requireLease` is acquired through `tower_resource`, which belongs
|
|
345
|
+
// to the tower-leases row. The runtime preconditions refuse this combination, so
|
|
346
|
+
// the doctor reports it first and names both remedies instead of letting it
|
|
347
|
+
// surface as a lease-busy from every spawn.
|
|
348
|
+
const requiredLease = String(config.spawn?.requireLease ?? '').trim()
|
|
349
|
+
if (requiredLease) {
|
|
350
|
+
const leasesDisabled = components.disabled.some((entry) => entry.id === 'tower-leases')
|
|
351
|
+
checks.push(
|
|
352
|
+
leasesDisabled
|
|
353
|
+
? check(
|
|
354
|
+
'spawn-lease',
|
|
355
|
+
'fail',
|
|
356
|
+
`spawn.requireLease="${requiredLease}" is set but the tower-leases row is disabled, so tower_resource cannot acquire the lease`,
|
|
357
|
+
're-enable the tower-leases row in the plugin page and restart DSH, or clear spawn.requireLease',
|
|
358
|
+
)
|
|
359
|
+
: check('spawn-lease', 'pass', `spawn.requireLease="${requiredLease}" is satisfiable (tower-leases row enabled)`),
|
|
360
|
+
)
|
|
361
|
+
}
|
|
362
|
+
|
|
334
363
|
const subagents = ctx.get?.('subagents')
|
|
335
364
|
checks.push(subagents ? check('subagents', 'pass', 'service mounted') : check('subagents', 'fail', 'subagents service is unavailable', 'enable the subagent bundle'))
|
|
336
365
|
const hmr = ctx.get?.('hmr')
|
|
@@ -426,20 +455,41 @@ export async function runDoctor({ tower, ctx, agent, hostVersion, compatibilityF
|
|
|
426
455
|
)
|
|
427
456
|
|
|
428
457
|
const testConfigured = state.missions?.some((mission) => mission.testRuns !== undefined)
|
|
429
|
-
|
|
430
|
-
|
|
458
|
+
// #136: the `tower-tests` component row is the only switch (there is no
|
|
459
|
+
// tests.enabled), so this check reads the registry and nothing else.
|
|
460
|
+
const testsDisabled = components.disabled.some((entry) => entry.id === 'tower-tests')
|
|
431
461
|
checks.push(
|
|
432
462
|
check(
|
|
433
463
|
'tests',
|
|
434
464
|
testsDisabled ? 'warn' : 'pass',
|
|
435
465
|
testsDisabled
|
|
436
|
-
?
|
|
466
|
+
? 'test discovery disabled (disabled by component tower-tests; enable the tower-tests row and restart DSH)'
|
|
437
467
|
: `admission=${config.tests?.admission ?? 'complete'} timeoutMs=${config.tests?.timeoutMs ?? 60000}${testConfigured ? '; receipts present' : ''}`,
|
|
438
468
|
),
|
|
439
469
|
)
|
|
440
470
|
checks.push(check('legacy', 'pass', `${state.legacySnapshots?.length ?? 0} legacy snapshot(s)`))
|
|
441
471
|
checks.push(check('batches', 'pass', `${state.batches?.length ?? 0} batch(es)`))
|
|
442
472
|
|
|
473
|
+
// #133: whether MISSIONS.md is written is the `tower-board` row, not a config
|
|
474
|
+
// key, so name the component when it is off instead of a phantom key.
|
|
475
|
+
const boardDisabled = components.disabled.some((entry) => entry.id === 'tower-board')
|
|
476
|
+
const boardPath =
|
|
477
|
+
config.docs?.boardLocation === 'repo'
|
|
478
|
+
? path.join(tower.root, 'MISSIONS.md')
|
|
479
|
+
: path.join(tower.paths.dir, 'MISSIONS.md')
|
|
480
|
+
checks.push(
|
|
481
|
+
check(
|
|
482
|
+
'board',
|
|
483
|
+
boardDisabled ? 'warn' : 'pass',
|
|
484
|
+
boardDisabled
|
|
485
|
+
? `MISSIONS.md is not written (disabled by component tower-board); expected at ${boardPath}`
|
|
486
|
+
: `MISSIONS.md refreshes after mission changes at ${boardPath}`,
|
|
487
|
+
boardDisabled
|
|
488
|
+
? 'enable the tower-board row and restart DSH, or accept a stale board; every state tool and gate still works'
|
|
489
|
+
: undefined,
|
|
490
|
+
),
|
|
491
|
+
)
|
|
492
|
+
|
|
443
493
|
// Completion reports are the largest model-supplied strings in state.json;
|
|
444
494
|
// report the biggest one so growth is visible before it becomes a problem.
|
|
445
495
|
const reportSizes = (state.missions ?? [])
|
|
@@ -519,7 +569,12 @@ export async function runDoctor({ tower, ctx, agent, hostVersion, compatibilityF
|
|
|
519
569
|
const archiveInventory = await inspectArchive(tower.paths)
|
|
520
570
|
const stateBytes = await fsp.stat(tower.paths.state).then((stat) => stat.size).catch(() => null)
|
|
521
571
|
const archivePolicy = config.archive ?? {}
|
|
522
|
-
|
|
572
|
+
// #135: archival is the `tower-archive` row. Name the component when it is off;
|
|
573
|
+
// the documented consequence is that terminal missions stay hot.
|
|
574
|
+
const archiveEnabled = !components.disabled.some((entry) => entry.id === 'tower-archive')
|
|
575
|
+
const policyText = `afterDays=${archivePolicy.afterDays ?? 30} maxMissions=${archivePolicy.maxMissions ?? 100}${
|
|
576
|
+
archiveEnabled ? '' : ' (disabled by component tower-archive: terminal missions stay hot and state.json keeps growing)'
|
|
577
|
+
}`
|
|
523
578
|
if (!archiveInventory.exists) {
|
|
524
579
|
checks.push(check('archive', 'pass', `no archived missions; ${policyText}`))
|
|
525
580
|
} else if (archiveInventory.unreadable.length > 0) {
|
|
@@ -551,6 +606,7 @@ export async function runDoctor({ tower, ctx, agent, hostVersion, compatibilityF
|
|
|
551
606
|
// reports (from #89) and archive (from #73) are both kept
|
|
552
607
|
reports: reportsReport,
|
|
553
608
|
surface: surfaceReport,
|
|
609
|
+
review: reviewPolicy,
|
|
554
610
|
components,
|
|
555
611
|
archive: {
|
|
556
612
|
missions: state ? state.missions.length : null,
|
|
@@ -562,7 +618,7 @@ export async function runDoctor({ tower, ctx, agent, hostVersion, compatibilityF
|
|
|
562
618
|
files: archiveInventory.files.length,
|
|
563
619
|
unreadable: archiveInventory.unreadable,
|
|
564
620
|
policy: {
|
|
565
|
-
enabled:
|
|
621
|
+
enabled: archiveEnabled,
|
|
566
622
|
afterDays: archivePolicy.afterDays ?? 30,
|
|
567
623
|
maxMissions: archivePolicy.maxMissions ?? 100,
|
|
568
624
|
},
|
package/host/presets.js
CHANGED
|
@@ -6,14 +6,20 @@
|
|
|
6
6
|
// 1. `preset-tower-worker` is a mandatory dependency: planning, mode entry and
|
|
7
7
|
// spawn must refuse when the worker preset is absent.
|
|
8
8
|
// 2. `preset-tower-reviewer` is a *conditional* component. It may be off while
|
|
9
|
-
// `review.
|
|
10
|
-
//
|
|
11
|
-
// allowed.
|
|
9
|
+
// `review.mode` is `off` or `optional` (merge is unaffected; `off` does not
|
|
10
|
+
// use the preset at all); with `review.mode:required` it must be locked with
|
|
11
|
+
// an explanatory error, never silently allowed.
|
|
12
12
|
//
|
|
13
13
|
// `tower-tests` adds a third: with the component row off there is no
|
|
14
14
|
// `tower_test` tool and no receipt, so `gates.requireTests: true` is refused
|
|
15
15
|
// here rather than left to fail at merge time with a misleading blocker.
|
|
16
|
+
//
|
|
17
|
+
// `tower-leases` adds a fourth (#131): `spawn.requireLease` is satisfied through
|
|
18
|
+
// `tower_resource`, the tool the `tower-leases` row owns. With that row off every
|
|
19
|
+
// spawn would fail at `lease-busy` advising a tool that is not registered, so the
|
|
20
|
+
// contradiction is refused up front instead.
|
|
16
21
|
import { TowerToolError } from '../domain/errors.js'
|
|
22
|
+
import { reviewRequired } from '../domain/integration/review.js'
|
|
17
23
|
import { componentEnabled, disabledReason } from './capability-registry.js'
|
|
18
24
|
|
|
19
25
|
export function presetIds(config = {}) {
|
|
@@ -33,22 +39,22 @@ function rowId(preset) {
|
|
|
33
39
|
* registered. If the service is unavailable, doctor reports that separately and
|
|
34
40
|
* this check cannot make a claim either way.
|
|
35
41
|
*
|
|
36
|
-
* The reviewer preset is required only when `review.
|
|
37
|
-
* off state is
|
|
42
|
+
* The reviewer preset is required only when `review.mode` is `required`; with
|
|
43
|
+
* `off`/`optional` the off state is supported by the component contract.
|
|
38
44
|
*/
|
|
39
45
|
export async function assertPresetsAvailable(ctx, config = {}) {
|
|
40
46
|
const service = ctx?.get?.('agentPresets')
|
|
41
47
|
if (!service || typeof service.list !== 'function') return { checked: false, missing: [] }
|
|
42
48
|
const presets = presetIds(config)
|
|
43
49
|
const required = [presets.worker]
|
|
44
|
-
if (config
|
|
50
|
+
if (reviewRequired(config)) required.push(presets.reviewer)
|
|
45
51
|
const available = new Set((await service.list()).map((entry) => String(entry.id)))
|
|
46
52
|
const missing = required.filter((id) => !available.has(id))
|
|
47
53
|
if (missing.length === 0) return { checked: true, missing: [] }
|
|
48
54
|
const rows = [...new Set(missing.map(rowId))]
|
|
49
55
|
const reviewLock =
|
|
50
|
-
config
|
|
51
|
-
? ` review.
|
|
56
|
+
reviewRequired(config) && missing.includes(presets.reviewer)
|
|
57
|
+
? ` review.mode is required, so the reviewer row may not be off; ${disabledReason(rowId(presets.reviewer))} is rejected`
|
|
52
58
|
: ''
|
|
53
59
|
throw new TowerToolError(
|
|
54
60
|
'preset-missing',
|
|
@@ -73,5 +79,17 @@ export async function assertRuntimePreconditions(ctx, config = {}) {
|
|
|
73
79
|
`gates.requireTests is true but ${disabledReason('tower-tests')}; re-enable the tower-tests component or set gates.requireTests:false`,
|
|
74
80
|
)
|
|
75
81
|
}
|
|
82
|
+
// #131: `spawn.requireLease` is acquired through `tower_resource`, which only
|
|
83
|
+
// exists while the tower-leases row is mounted. Without this check the config
|
|
84
|
+
// looks valid and every spawn dies at `lease-busy` pointing at a tool that is
|
|
85
|
+
// not registered. Both remedies are named because either side may be the
|
|
86
|
+
// accidental one.
|
|
87
|
+
const requiredLease = String(config.spawn?.requireLease ?? '').trim()
|
|
88
|
+
if (requiredLease && !componentEnabled('tower-leases')) {
|
|
89
|
+
throw new TowerToolError(
|
|
90
|
+
'config-invalid',
|
|
91
|
+
`spawn.requireLease="${requiredLease}" is set but ${disabledReason('tower-leases')}; tower_resource is not registered, so the lease can never be acquired. Re-enable the tower-leases component or clear spawn.requireLease`,
|
|
92
|
+
)
|
|
93
|
+
}
|
|
76
94
|
return presets
|
|
77
95
|
}
|