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/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
|
},
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// Tower reviewer preset declaration (#121): the read-only preset's own label.
|
|
2
|
+
//
|
|
3
|
+
// This is the reviewer half of the pair documented in `host/preset-worker.js`.
|
|
4
|
+
// The row used to mount `@deepseek-ai/dsh-agent-preset`, so DSH resolved that
|
|
5
|
+
// shared module's locale and both preset rows rendered the same title; the
|
|
6
|
+
// conditional reviewer switch was therefore indistinguishable from the
|
|
7
|
+
// mandatory worker dependency. A Tower-owned module gives each row its own
|
|
8
|
+
// `<specifier>/locale/<lang>.json` resource without importing the agent-preset
|
|
9
|
+
// package (which is not resolvable from this package anyway).
|
|
10
|
+
//
|
|
11
|
+
// The module forwards the unchanged row config to the injected `agentPresets`
|
|
12
|
+
// service and returns its disposer, so `register`/unregister lifecycle, the
|
|
13
|
+
// definition's `plugins` mount and the activation diagnostic all stay registry
|
|
14
|
+
// owned. It imports nothing and renders nothing.
|
|
15
|
+
export const name = 'dsh-tower/preset-reviewer'
|
|
16
|
+
|
|
17
|
+
export const inject = ['agentPresets']
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Register the reviewer preset with the profile's preset registry.
|
|
21
|
+
* @param {{ agentPresets: { register: (config: any) => Promise<() => Promise<void> | void> } }} ctx
|
|
22
|
+
* @param {any} config the `cordis.patch.yml` row config (id/name/description/order/plugins)
|
|
23
|
+
* @returns {Promise<() => Promise<void> | void>} the registry's unregister disposer
|
|
24
|
+
*/
|
|
25
|
+
export async function apply(ctx, config) {
|
|
26
|
+
return ctx.agentPresets.register(config)
|
|
27
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// Tower worker preset declaration (#121): one row, one Plugins-page label.
|
|
2
|
+
//
|
|
3
|
+
// DSH labels a row from `readPluginMeta(row.moduleName)`, which resolves
|
|
4
|
+
// `<specifier>/locale/en.json` and every sibling `<lang>.json` there. Both
|
|
5
|
+
// preset rows used to mount `@deepseek-ai/dsh-agent-preset`, so both shared that
|
|
6
|
+
// module's single label and the page could not tell the worker from the
|
|
7
|
+
// reviewer. That package is also not resolvable from this package, so Tower
|
|
8
|
+
// cannot add per-row locale files to it.
|
|
9
|
+
//
|
|
10
|
+
// This module is the tiny Tower-owned twin: it keeps the row config in
|
|
11
|
+
// `cordis.patch.yml` and forwards it to the injected `agentPresets` service. The
|
|
12
|
+
// service owns preset semantics — it mounts the definition's `plugins` list
|
|
13
|
+
// itself and returns the disposer that unmounts it — so this module stays
|
|
14
|
+
// renderer-free and imports nothing. Its specifier carries the worker label
|
|
15
|
+
// (`dsh-tower/preset-worker/locale/<lang>.json`).
|
|
16
|
+
//
|
|
17
|
+
// Shape: a plain object plugin (named exports) whose async `apply` returns the
|
|
18
|
+
// registry's disposer. Cordis collects the resolved function as the plugin's
|
|
19
|
+
// disposer, so disabling the row (or an HMR unload) unregisters the preset.
|
|
20
|
+
export const name = 'dsh-tower/preset-worker'
|
|
21
|
+
|
|
22
|
+
export const inject = ['agentPresets']
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Register the worker preset with the profile's preset registry.
|
|
26
|
+
* @param {{ agentPresets: { register: (config: any) => Promise<() => Promise<void> | void> } }} ctx
|
|
27
|
+
* @param {any} config the `cordis.patch.yml` row config (id/name/description/order/plugins)
|
|
28
|
+
* @returns {Promise<() => Promise<void> | void>} the registry's unregister disposer
|
|
29
|
+
*/
|
|
30
|
+
export async function apply(ctx, config) {
|
|
31
|
+
return ctx.agentPresets.register(config)
|
|
32
|
+
}
|
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
|
}
|
package/host/tool-registry.js
CHANGED
|
@@ -378,7 +378,11 @@ async function ensureRepository(repoRootInput, autoGitInit) {
|
|
|
378
378
|
}
|
|
379
379
|
}
|
|
380
380
|
|
|
381
|
-
|
|
381
|
+
// #138: exported so `scripts/check-components.mjs` can import the route table
|
|
382
|
+
// instead of scraping this file. The table is empty since #122; an empty Map is
|
|
383
|
+
// the determined state, while a missing export or a failed import is a gate
|
|
384
|
+
// failure.
|
|
385
|
+
export const OPERATIONAL_TOOLS = new Map()
|
|
382
386
|
|
|
383
387
|
function plainSchema(value) {
|
|
384
388
|
return JSON.parse(JSON.stringify(value))
|
|
@@ -473,8 +477,11 @@ async function runMissionDiscovery(tower, mission) {
|
|
|
473
477
|
return { configured: false, disabledBy: 'tower-tests', reason: disabledReason('tower-tests') }
|
|
474
478
|
}
|
|
475
479
|
const testsConfig = tower.config.tests ?? {}
|
|
476
|
-
|
|
477
|
-
|
|
480
|
+
// #136: there is no second switch. The `tower-tests` row decided above; the
|
|
481
|
+
// only remaining reason to skip discovery is an explicit opt-out of repository
|
|
482
|
+
// package discovery.
|
|
483
|
+
if (testsConfig.allowPackageDiscovery === false) {
|
|
484
|
+
return { configured: false, error: 'test discovery is disabled by tests.allowPackageDiscovery:false' }
|
|
478
485
|
}
|
|
479
486
|
const worktreePath = mission?.worktree?.path
|
|
480
487
|
if (!worktreePath) return { configured: false, error: 'mission has no worktree' }
|
|
@@ -565,6 +572,14 @@ export function registerCoreTowerTools(ctx, options = {}) {
|
|
|
565
572
|
const baseDeps = options.deps ? assertPorts(options.deps) : (runtimeState.deps ?? createDefaultDeps())
|
|
566
573
|
runtimeState.deps = {
|
|
567
574
|
...baseDeps,
|
|
575
|
+
// #140: the merge gate lives in the pure domain, which cannot read the
|
|
576
|
+
// component registry. Publish the two probes it needs on the port bag the
|
|
577
|
+
// store already receives, so `evaluateGate` can name a disabled row as the
|
|
578
|
+
// cause instead of guessing from config.
|
|
579
|
+
components: {
|
|
580
|
+
enabled: (id) => componentEnabled(id),
|
|
581
|
+
reason: (id) => disabledReason(id),
|
|
582
|
+
},
|
|
568
583
|
events: {
|
|
569
584
|
emit(name, payload) {
|
|
570
585
|
try {
|
|
@@ -615,7 +630,9 @@ export function registerCoreTowerTools(ctx, options = {}) {
|
|
|
615
630
|
|
|
616
631
|
registerStatusTool(ctx, { openTower, define, workspaceHook: runtimeState.workspaceHook, spawnBackoffView, observeActivity, componentReport })
|
|
617
632
|
|
|
618
|
-
|
|
633
|
+
// #131: the lease precondition needs the row state, and the app layer may not
|
|
634
|
+
// import the registry, so the probe travels with the other spawn deps.
|
|
635
|
+
registerSpawnTool(ctx, { openTower, define, actorFor, requireTowerActor, enqueueSpawn, observeActivity, spawnBackoffState, recordSpawnThrottle, recordSpawnSuccess, runMissionDiscovery, textOf, inspectLeases, leaseComponent: () => ({ enabled: componentEnabled('tower-leases'), reason: disabledReason('tower-leases') }) })
|
|
619
636
|
|
|
620
637
|
registerRosterTool(ctx, { openTower, define })
|
|
621
638
|
|
package/index.js
CHANGED
|
@@ -398,11 +398,15 @@ export function apply(ctx, rawConfig) {
|
|
|
398
398
|
: () => {},
|
|
399
399
|
)
|
|
400
400
|
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
401
|
+
// #139: same leak as the skills provider — the section belongs to the plugin
|
|
402
|
+
// lifetime, so registration goes through `ctx.effect`.
|
|
403
|
+
ctx.effect(() =>
|
|
404
|
+
ctx.systemPrompt.section({
|
|
405
|
+
name: 'tower:mode',
|
|
406
|
+
order: 15000,
|
|
407
|
+
text: (context) => (context && modeOn(agentKey(context.agent)) ? reminder : ''),
|
|
408
|
+
}),
|
|
409
|
+
)
|
|
406
410
|
|
|
407
411
|
// Workspace-lock heartbeat; only sessions holding a lock do I/O.
|
|
408
412
|
ctx.effect(() => {
|
|
@@ -583,7 +587,14 @@ export function apply(ctx, rawConfig) {
|
|
|
583
587
|
}
|
|
584
588
|
}
|
|
585
589
|
const { active, message } = parsed
|
|
586
|
-
|
|
590
|
+
// #142: preconditions guard the *transition* into Tower mode, not every
|
|
591
|
+
// `/tower` instruction. Re-validating on an already-active session meant a
|
|
592
|
+
// row disabled later in the session (tower-hmr ships off) blocked
|
|
593
|
+
// `/tower off` and ordinary forwarded text — the two commands a human
|
|
594
|
+
// needs precisely when something is broken. Spawn/plan/merge keep their own
|
|
595
|
+
// guards, so the capability checks still run before any real work.
|
|
596
|
+
const transition = active && !modeOn(key)
|
|
597
|
+
if (transition) await assertRuntimePreconditions(ctx, config)
|
|
587
598
|
const outcome = active
|
|
588
599
|
? enterMode(ctx, key, agent, 'user', message || null)
|
|
589
600
|
: await exitMode(key, 'user', message || null)
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
{
|
|
2
|
+
"meta": {
|
|
3
|
+
"title": "Mission archive",
|
|
4
|
+
"description": "Move finished missions and their linked records into the on-disk archive, so the live state file stops carrying their full history. When off: Finished missions stay in the live state file, so it keeps growing; merges and every other capability keep working, and nothing is deleted."
|
|
5
|
+
}
|
|
6
|
+
}
|
package/locale/batch/en.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"meta": {
|
|
3
3
|
"title": "Evidence batches",
|
|
4
|
-
"description": "
|
|
4
|
+
"description": "Collect a finite set of missions into a batch and close it only when every required check and evidence receipt is present. When off: Batch tools and their checks are unavailable; missions, merges and every other capability keep working."
|
|
5
5
|
}
|
|
6
6
|
}
|
package/locale/batch/zh.json
CHANGED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
{
|
|
2
|
+
"meta": {
|
|
3
|
+
"title": "Mission board",
|
|
4
|
+
"description": "Write a plain-text board file (MISSIONS.md) beside the state so a person can read the mission list without calling a tool. When off: The board file is not written or refreshed, so an existing board goes stale; missions, merges and every other capability keep working."
|
|
5
|
+
}
|
|
6
|
+
}
|