dsh-tower 2.4.1 → 2.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. package/CHANGELOG.md +51 -0
  2. package/COMPATIBILITY.md +2 -0
  3. package/CONTRIBUTING.md +11 -5
  4. package/GLOSSARY.md +67 -0
  5. package/README.md +6 -0
  6. package/app/archive.js +16 -1
  7. package/app/board.js +16 -1
  8. package/app/gate.js +14 -0
  9. package/app/spawn-prechecks.js +18 -3
  10. package/cordis.patch.yml +27 -8
  11. package/docs/adr/0004-surface-budget.md +10 -4
  12. package/docs/adr/0005-component-surface.md +166 -0
  13. package/docs/architecture/overview.md +47 -20
  14. package/docs/guide/install-upgrade-migration.md +21 -0
  15. package/docs/guide/quickstart.md +71 -0
  16. package/docs/guide/release.md +60 -19
  17. package/docs/reference/component-surface.md +29 -17
  18. package/docs/reference/config.generated.md +4 -7
  19. package/docs/reference/config.md +7 -5
  20. package/docs/reference/doctor.md +11 -5
  21. package/docs/reference/surface-budget.md +29 -11
  22. package/docs/reference/tools.generated.md +1 -1
  23. package/docs/reference/tools.md +2 -2
  24. package/docs/zh/guide/install-upgrade-migration.md +1 -1
  25. package/docs/zh/reference/config.md +7 -5
  26. package/docs/zh/reference/doctor.md +7 -2
  27. package/docs/zh/reference/tools.md +3 -2
  28. package/domain/component-rules.js +216 -0
  29. package/domain/components.js +169 -49
  30. package/domain/config/schema.js +46 -23
  31. package/domain/gate.js +17 -2
  32. package/domain/scope.js +176 -5
  33. package/domain/surface.js +21 -7
  34. package/host/capability-archive.js +18 -0
  35. package/host/capability-board.js +18 -0
  36. package/host/capability-registry.js +4 -0
  37. package/host/doctor.js +50 -5
  38. package/host/preset-reviewer.js +27 -0
  39. package/host/preset-worker.js +32 -0
  40. package/host/presets.js +17 -0
  41. package/host/tool-registry.js +21 -4
  42. package/index.js +17 -6
  43. package/locale/archive/en.json +6 -0
  44. package/locale/archive/zh.json +6 -0
  45. package/locale/batch/en.json +1 -1
  46. package/locale/batch/zh.json +1 -1
  47. package/locale/board/en.json +6 -0
  48. package/locale/board/zh.json +6 -0
  49. package/locale/en.json +41 -21
  50. package/locale/hmr/en.json +2 -2
  51. package/locale/hmr/zh.json +1 -1
  52. package/locale/import/en.json +1 -1
  53. package/locale/import/zh.json +1 -1
  54. package/locale/leases/en.json +2 -2
  55. package/locale/leases/zh.json +2 -2
  56. package/locale/preset-reviewer/en.json +6 -0
  57. package/locale/preset-reviewer/zh.json +6 -0
  58. package/locale/preset-worker/en.json +6 -0
  59. package/locale/preset-worker/zh.json +6 -0
  60. package/locale/skills/en.json +1 -1
  61. package/locale/skills/zh.json +1 -1
  62. package/locale/tests/en.json +2 -2
  63. package/locale/tests/zh.json +2 -2
  64. package/locale/zh.json +40 -20
  65. package/package.json +15 -2
  66. package/scripts/check-components.mjs +642 -42
  67. package/scripts/check-pack.mjs +7 -3
  68. package/scripts/component-surface.json +87 -29
  69. package/scripts/generate-capability-locale.mjs +100 -21
  70. package/scripts/mount-smoke/probe.mjs +33 -3
  71. package/scripts/mount-smoke/surface.mjs +144 -1
  72. package/scripts/mount-smoke.mjs +24 -4
  73. package/scripts/pack-manifest.txt +15 -0
  74. package/scripts/release-notes.mjs +256 -0
  75. package/scripts/surface-baseline.json +32 -3
  76. package/scripts/surface-baseline.mjs +15 -5
  77. package/skills.js +4 -1
  78. package/tools/complete.js +3 -1
  79. package/tools/spawn.js +5 -5
  80. package/tools/test.js +7 -7
  81. package/ui-bundle/locale/en.json +1 -1
  82. package/ui-bundle/locale/zh.json +1 -1
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
- const x = scopePrefix(pa)
170
- const y = scopePrefix(pb)
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
- export const SURFACE_TWO_WAY_KEYS = ['components']
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; the other surfaces keep the
244
- * "a reduction is cheap" rule from ADR 0004.
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
- if (decision.date !== undefined && !ISO_DATE.test(String(decision.date))) {
278
- record('broken-chain', `${where}.date is not YYYY-MM-DD`)
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
- const testsDisabled =
430
- config.tests?.enabled === false || components.disabled.some((entry) => entry.id === 'tower-tests')
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
- ? `test discovery disabled${config.tests?.enabled === false ? ' (tests.enabled:false)' : ' (disabled by component tower-tests)'}`
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
- const policyText = `afterDays=${archivePolicy.afterDays ?? 30} maxMissions=${archivePolicy.maxMissions ?? 100}${archivePolicy.enabled === false ? ' disabled' : ''}`
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: archivePolicy.enabled !== false,
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
  }
@@ -378,7 +378,11 @@ async function ensureRepository(repoRootInput, autoGitInit) {
378
378
  }
379
379
  }
380
380
 
381
- const OPERATIONAL_TOOLS = new Map()
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
- if (testsConfig.enabled === false || testsConfig.allowPackageDiscovery === false) {
477
- return { configured: false }
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
- registerSpawnTool(ctx, { openTower, define, actorFor, requireTowerActor, enqueueSpawn, observeActivity, spawnBackoffState, recordSpawnThrottle, recordSpawnSuccess, runMissionDiscovery, textOf, inspectLeases })
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
- ctx.systemPrompt.section({
402
- name: 'tower:mode',
403
- order: 15000,
404
- text: (context) => (context && modeOn(agentKey(context.agent)) ? reminder : ''),
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
- if (active) await assertRuntimePreconditions(ctx, config)
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
+ }
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "任务线归档",
4
+ "description": "把已结束的任务线及其关联记录移入磁盘归档,让当前状态文件不再承载它们的完整历史。关闭后:已结束的任务线继续留在当前状态文件中,文件会持续增大;合并与其他能力继续工作,且不会删除任何内容。"
5
+ }
6
+ }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "meta": {
3
3
  "title": "Evidence batches",
4
- "description": "Group missions into finite evidence batches and close them against required checks and receipts. When off: tower_batch is not registered; missions, gates and merges are unaffected."
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
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "meta": {
3
3
  "title": "证据批次",
4
- "description": "把 mission 归入有限证据批次,并按必需检查与回执关闭批次。关闭后:不再注册 tower_batch;mission、门禁与合并不受影响。"
4
+ "description": "把一组任务线收进一个批次,只有当所有必需检查与证据回执齐全时才允许关闭该批次(或结案)。关闭后:批次工具及其检查不可用;任务线、合并与其他能力继续工作。"
5
5
  }
6
6
  }
@@ -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
+ }
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "任务线看板",
4
+ "description": "在状态文件旁写入纯文本看板文件(MISSIONS.md),让人无需调用工具就能阅读任务线列表。关闭后:不再写入或刷新看板文件,已有看板会过期;任务线、合并与其他能力继续工作。"
5
+ }
6
+ }