dsh-tower 2.3.0 → 2.4.1

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 (83) hide show
  1. package/CHANGELOG.md +44 -0
  2. package/COMPATIBILITY.md +2 -0
  3. package/CONTRIBUTING.md +20 -3
  4. package/PRINCIPLES.md +18 -0
  5. package/README.en.md +29 -16
  6. package/README.md +22 -15
  7. package/cordis.patch.yml +56 -28
  8. package/docs/README.md +2 -1
  9. package/docs/adr/0004-surface-budget.md +20 -0
  10. package/docs/adr/0005-component-surface.md +130 -0
  11. package/docs/adr/README.md +1 -0
  12. package/docs/agents/workflow.md +1 -1
  13. package/docs/architecture/overview.md +69 -7
  14. package/docs/guide/hot-reload.md +4 -4
  15. package/docs/guide/install-upgrade-migration.md +13 -0
  16. package/docs/operations/security.md +1 -1
  17. package/docs/reference/component-surface.md +135 -0
  18. package/docs/reference/config.generated.md +2 -10
  19. package/docs/reference/config.md +11 -9
  20. package/docs/reference/doctor.md +14 -9
  21. package/docs/reference/surface-budget.md +52 -23
  22. package/docs/reference/tools.generated.md +4 -2
  23. package/docs/reference/tools.md +6 -3
  24. package/docs/zh/README.md +1 -1
  25. package/docs/zh/guide/hot-reload.md +3 -3
  26. package/docs/zh/guide/install-upgrade-migration.md +10 -0
  27. package/docs/zh/operations/security.md +1 -1
  28. package/docs/zh/reference/config.md +9 -9
  29. package/docs/zh/reference/tools.md +6 -4
  30. package/domain/components.js +641 -0
  31. package/domain/config/schema.js +37 -27
  32. package/domain/contracts/outputs.js +10 -0
  33. package/domain/errors.js +11 -1
  34. package/domain/mission.js +12 -1
  35. package/domain/surface.js +154 -55
  36. package/host/capability-batch.js +15 -0
  37. package/host/capability-hmr.js +30 -0
  38. package/host/capability-import.js +11 -0
  39. package/host/capability-leases.js +11 -0
  40. package/host/capability-registry.js +149 -0
  41. package/host/capability-tests.js +16 -0
  42. package/host/doctor.js +41 -9
  43. package/host/hmr.js +2 -4
  44. package/host/presets.js +51 -9
  45. package/host/tool-registry.js +150 -34
  46. package/index.js +32 -20
  47. package/locale/batch/en.json +6 -0
  48. package/locale/batch/zh.json +6 -0
  49. package/locale/en.json +31 -11
  50. package/locale/hmr/en.json +6 -0
  51. package/locale/hmr/zh.json +6 -0
  52. package/locale/import/en.json +6 -0
  53. package/locale/import/zh.json +6 -0
  54. package/locale/leases/en.json +6 -0
  55. package/locale/leases/zh.json +6 -0
  56. package/locale/skills/en.json +6 -0
  57. package/locale/skills/zh.json +6 -0
  58. package/locale/tests/en.json +6 -0
  59. package/locale/tests/zh.json +6 -0
  60. package/locale/zh.json +30 -10
  61. package/package.json +25 -4
  62. package/scripts/check-components.mjs +235 -0
  63. package/scripts/check-pack.mjs +4 -0
  64. package/scripts/component-surface.json +395 -0
  65. package/scripts/generate-capability-locale.mjs +148 -0
  66. package/scripts/generate-component-surface.mjs +59 -0
  67. package/scripts/generate-tools-reference.mjs +7 -25
  68. package/scripts/generate-types.mjs +127 -0
  69. package/scripts/lib/tool-surface.mjs +85 -0
  70. package/scripts/mount-smoke/fixtures.mjs +3 -3
  71. package/scripts/mount-smoke/surface.mjs +96 -27
  72. package/scripts/mount-smoke.mjs +3 -1
  73. package/scripts/pack-manifest.txt +30 -0
  74. package/scripts/surface-baseline.json +35 -4
  75. package/scripts/surface-baseline.mjs +32 -34
  76. package/scripts/types-coverage.mjs +184 -0
  77. package/scripts/types-strict-manifest.txt +10 -0
  78. package/scripts/types-strict.mjs +141 -0
  79. package/skills.js +8 -1
  80. package/tools/index.js +2 -1
  81. package/tools/reload.js +34 -35
  82. package/tools/status.js +8 -1
  83. package/types/index.d.ts +28 -12
@@ -0,0 +1,149 @@
1
+ // Runtime view of the optional component rows (#121).
2
+ //
3
+ // The component rows in `cordis.patch.yml` are separate loader entries. A
4
+ // capability row that is off simply never runs `apply()`, so the host cannot
5
+ // ask a disabled plugin to report itself. This module is the small shared
6
+ // registry the capability entry points write into and the read-only reporters
7
+ // (`tower_status`, `tower_doctor`) read back. An absent entry is a disabled
8
+ // component, reported with the exact "disabled by component <id>" wording the
9
+ // acceptance contract asks for, never a silent absence.
10
+ //
11
+ // The registry is process-local and reference-counted: a capability entry point
12
+ // registers on apply and unregisters through its `ctx.effect` disposer, so an
13
+ // HMR reload cannot leave a stale "enabled" claim behind. It is coordination,
14
+ // not state: no file is written and no tool result depends on it except the
15
+ // capability report itself.
16
+
17
+ /** The visible optional component rows, in `cordis.patch.yml` order. */
18
+ export const TOWER_COMPONENT_IDS = Object.freeze([
19
+ 'tower-skills',
20
+ 'tower-batch',
21
+ 'tower-import',
22
+ 'tower-leases',
23
+ 'tower-tests',
24
+ 'tower-hmr',
25
+ 'preset-tower-reviewer',
26
+ ])
27
+
28
+ /** Tool ids each code component owns; the preset row owns no tool. */
29
+ export const COMPONENT_TOOLS = Object.freeze({
30
+ 'tower-skills': Object.freeze([]),
31
+ 'tower-batch': Object.freeze(['tower_batch']),
32
+ 'tower-import': Object.freeze(['tower_import_legacy', 'tower_legacy_map']),
33
+ 'tower-leases': Object.freeze(['tower_resource']),
34
+ 'tower-tests': Object.freeze(['tower_test']),
35
+ 'tower-hmr': Object.freeze(['tower_reload']),
36
+ 'preset-tower-reviewer': Object.freeze([]),
37
+ })
38
+
39
+ /** id -> number of live registrations (redelivery/HMR can overlap briefly). */
40
+ const active = new Map()
41
+
42
+ /**
43
+ * Mark one component as mounted. IDEMPOTENT per call site: the reference count
44
+ * lets a brief double-mount during HMR stay "enabled" until the last disposer
45
+ * runs.
46
+ * @param {string} id
47
+ */
48
+ export function registerComponent(id) {
49
+ if (!TOWER_COMPONENT_IDS.includes(id)) throw new TypeError(`unknown Tower component: ${id}`)
50
+ active.set(id, (active.get(id) ?? 0) + 1)
51
+ }
52
+
53
+ /** Drop one registration; a component is disabled when its count reaches 0. */
54
+ export function unregisterComponent(id) {
55
+ const count = active.get(id) ?? 0
56
+ if (count <= 1) active.delete(id)
57
+ else active.set(id, count - 1)
58
+ }
59
+
60
+ /** True when a code component entry point is currently mounted. */
61
+ export function componentEnabled(id) {
62
+ return (active.get(id) ?? 0) > 0
63
+ }
64
+
65
+ /** Register/unregister a component around one plugin's lifetime. */
66
+ export function holdComponent(ctx, id) {
67
+ registerComponent(id)
68
+ if (typeof ctx?.effect === 'function') {
69
+ ctx.effect(() => () => unregisterComponent(id))
70
+ }
71
+ return () => unregisterComponent(id)
72
+ }
73
+
74
+ /** Test hook: process-local registry state must be reset per fixture. */
75
+ export function __testResetComponentRegistry() {
76
+ active.clear()
77
+ sharedHmrBridge = null
78
+ }
79
+
80
+ /** The exact wording the acceptance contract uses for a missing capability. */
81
+ export function disabledReason(id) {
82
+ return `disabled by component ${id}`
83
+ }
84
+
85
+ /** Reviewer-preset availability from the optional agent-presets service. */
86
+ async function reviewerPresetAvailable(ctx) {
87
+ const service = ctx?.get?.('agentPresets')
88
+ if (!service || typeof service.list !== 'function') return null
89
+ try {
90
+ const presets = await service.list()
91
+ return Array.isArray(presets) && presets.some((entry) => String(entry?.id) === 'tower-reviewer')
92
+ } catch {
93
+ return null
94
+ }
95
+ }
96
+
97
+ /**
98
+ * Report every optional component as enabled or disabled. The reviewer preset
99
+ * is not a code entry point: its row mounts an agent preset, so availability is
100
+ * read from `agentPresets` and reported as unknown (omitted) when that service
101
+ * is absent, exactly as the doctor's preset check does.
102
+ *
103
+ * @param {any} ctx
104
+ * @returns {Promise<{ enabled: string[], disabled: Array<{ id: string, reason: string }> }>}
105
+ */
106
+ export async function componentReport(ctx) {
107
+ const enabled = []
108
+ const disabled = []
109
+ for (const id of TOWER_COMPONENT_IDS) {
110
+ if (id === 'preset-tower-reviewer') {
111
+ const available = await reviewerPresetAvailable(ctx)
112
+ if (available === false) disabled.push({ id, reason: disabledReason(id) })
113
+ else if (available === true) enabled.push(id)
114
+ continue
115
+ }
116
+ if (componentEnabled(id)) enabled.push(id)
117
+ else disabled.push({ id, reason: disabledReason(id) })
118
+ }
119
+ return { enabled, disabled }
120
+ }
121
+
122
+ // A capability plugin's `ctx.provide()` is visible to its descendants, not to
123
+ // the parent host context that owns `/tower` and `tower_mode`. The hot-reload
124
+ // bridge is a process singleton, so it is shared here rather than through a
125
+ // service lookup the host could not see.
126
+ let sharedHmrBridge = null
127
+
128
+ /** Publish the mounted tower-hmr bridge for the host entry to read. */
129
+ export function setHmrBridge(bridge) {
130
+ sharedHmrBridge = bridge
131
+ }
132
+
133
+ /** Clear the publication when the component unloads; never clear another bridge. */
134
+ export function clearHmrBridge(bridge) {
135
+ if (sharedHmrBridge === bridge) sharedHmrBridge = null
136
+ }
137
+
138
+ /** The mounted tower-hmr bridge, or null when the component row is off. */
139
+ export function currentHmrBridge() {
140
+ return sharedHmrBridge
141
+ }
142
+
143
+ /** One-line form used by `tower_status` and the doctor's `components` check. */
144
+ export function componentSummary(report) {
145
+ if (!report || report.disabled.length === 0) {
146
+ return `${report?.enabled?.length ?? 0} optional component(s) enabled`
147
+ }
148
+ return report.disabled.map((entry) => entry.reason).join('; ')
149
+ }
@@ -0,0 +1,16 @@
1
+ // tower-tests component entry (#121): declared test discovery only.
2
+ //
3
+ // With this row off there is no `tower_test` tool and no receipt to satisfy
4
+ // `gates.requireTests`; planning/mode entry refuse that combination with an
5
+ // explanatory error (host/presets.js), and the merge gate still reports
6
+ // `tests-missing` because no receipt can exist.
7
+ import { holdComponent } from './capability-registry.js'
8
+ import { registerTestsCapability } from './tool-registry.js'
9
+
10
+ export const name = 'dsh-tower/tests'
11
+ export const inject = ['tools', 'tower']
12
+
13
+ export function apply(ctx) {
14
+ holdComponent(ctx, 'tower-tests')
15
+ registerTestsCapability(ctx)
16
+ }
package/host/doctor.js CHANGED
@@ -17,6 +17,7 @@ import { parseBaseline, surfaceLine, SURFACE_BASELINE_FILE } from '../domain/sur
17
17
  import { assessHostVersion, detectHostVersion, readCompatibility } from './compatibility.js'
18
18
  import { profileConfig } from './config.js'
19
19
  import { inspectWorkspaceLock } from '../adapters/workspaceLock.js'
20
+ import { componentReport, componentSummary } from './capability-registry.js'
20
21
 
21
22
  const execFileAsync = promisify(execFile)
22
23
  const PACKAGE_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
@@ -287,16 +288,32 @@ export async function runDoctor({ tower, ctx, agent, hostVersion, compatibilityF
287
288
  ),
288
289
  )
289
290
  const presets = ctx.get?.('agentPresets')
291
+ const reviewerRequired = config.review?.requireReview === true
290
292
  if (presets && typeof presets.list === 'function') {
291
293
  try {
292
294
  const available = (await presets.list()).map((entry) => String(entry.id))
293
- const missing = [config.spawn?.workerPreset ?? 'tower-worker', config.spawn?.reviewerPreset ?? 'tower-reviewer']
294
- .filter((id) => !available.includes(id))
295
- checks.push(
296
- missing.length === 0
297
- ? check('presets', 'pass', `worker=${config.spawn?.workerPreset ?? 'tower-worker'} reviewer=${config.spawn?.reviewerPreset ?? 'tower-reviewer'}`)
298
- : check('presets', 'fail', `missing preset(s): ${missing.join(', ')}`, 'enable the Tower preset bundle and retry'),
299
- )
295
+ const worker = config.spawn?.workerPreset ?? 'tower-worker'
296
+ const reviewer = config.spawn?.reviewerPreset ?? 'tower-reviewer'
297
+ const required = reviewerRequired ? [worker, reviewer] : [worker]
298
+ const missing = required.filter((id) => !available.includes(id))
299
+ // #121: `preset-tower-reviewer` is a conditional component. Its off state
300
+ // is supported while review.requireReview is false; with the gate on it is
301
+ // a fail, because no independent review can be dispatched.
302
+ if (missing.length === 0) {
303
+ const reviewerNote = available.includes(reviewer)
304
+ ? `reviewer=${reviewer}`
305
+ : `reviewer=${reviewer} (disabled by component preset-tower-reviewer)`
306
+ checks.push(check('presets', 'pass', `worker=${worker} ${reviewerNote}`))
307
+ } else {
308
+ checks.push(
309
+ check(
310
+ 'presets',
311
+ 'fail',
312
+ `missing preset(s): ${missing.join(', ')}${reviewerRequired && missing.includes(reviewer) ? ' (review.requireReview is true, so preset-tower-reviewer may not be off)' : ''}`,
313
+ 'enable the Tower preset component and retry',
314
+ ),
315
+ )
316
+ }
300
317
  } catch (error) {
301
318
  checks.push(check('presets', 'warn', `preset registry unavailable: ${String(error?.message ?? error)}`))
302
319
  }
@@ -304,6 +321,16 @@ export async function runDoctor({ tower, ctx, agent, hostVersion, compatibilityF
304
321
  checks.push(check('presets', 'warn', 'agentPresets service is unavailable'))
305
322
  }
306
323
 
324
+ // #121: the optional component rows are a diagnostic fact. A disabled row is
325
+ // reported with the exact "disabled by component <id>" reason rather than
326
+ // disappearing silently from the tool surface.
327
+ const components = await componentReport(ctx)
328
+ checks.push(
329
+ components.disabled.length > 0
330
+ ? check('components', 'warn', componentSummary(components), 'enable the reported row in the plugin page and restart DSH')
331
+ : check('components', 'pass', componentSummary(components)),
332
+ )
333
+
307
334
  const subagents = ctx.get?.('subagents')
308
335
  checks.push(subagents ? check('subagents', 'pass', 'service mounted') : check('subagents', 'fail', 'subagents service is unavailable', 'enable the subagent bundle'))
309
336
  const hmr = ctx.get?.('hmr')
@@ -399,11 +426,15 @@ export async function runDoctor({ tower, ctx, agent, hostVersion, compatibilityF
399
426
  )
400
427
 
401
428
  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')
402
431
  checks.push(
403
432
  check(
404
433
  'tests',
405
- config.tests?.enabled === false ? 'warn' : 'pass',
406
- config.tests?.enabled === false ? 'test discovery disabled' : `admission=${config.tests?.admission ?? 'complete'} timeoutMs=${config.tests?.timeoutMs ?? 60000}${testConfigured ? '; receipts present' : ''}`,
434
+ testsDisabled ? 'warn' : 'pass',
435
+ testsDisabled
436
+ ? `test discovery disabled${config.tests?.enabled === false ? ' (tests.enabled:false)' : ' (disabled by component tower-tests)'}`
437
+ : `admission=${config.tests?.admission ?? 'complete'} timeoutMs=${config.tests?.timeoutMs ?? 60000}${testConfigured ? '; receipts present' : ''}`,
407
438
  ),
408
439
  )
409
440
  checks.push(check('legacy', 'pass', `${state.legacySnapshots?.length ?? 0} legacy snapshot(s)`))
@@ -520,6 +551,7 @@ export async function runDoctor({ tower, ctx, agent, hostVersion, compatibilityF
520
551
  // reports (from #89) and archive (from #73) are both kept
521
552
  reports: reportsReport,
522
553
  surface: surfaceReport,
554
+ components,
523
555
  archive: {
524
556
  missions: state ? state.missions.length : null,
525
557
  archivedMissions: archiveInventory.missions,
package/host/hmr.js CHANGED
@@ -20,13 +20,11 @@
20
20
  // one, so a tool-triggered reload is safe.
21
21
  import { TowerToolError } from '../domain/errors.js'
22
22
 
23
- /** Entry ids Tower expects to be active after a reconciliation. */
23
+ /** Entry ids Tower expects to be active after a reconciliation (#121 rows). */
24
24
  export const DEFAULT_REQUIRED_IDS = Object.freeze([
25
- 'tower-bundle',
26
25
  'tower',
27
- 'tower-skills',
26
+ 'tower-host',
28
27
  'preset-tower-worker',
29
- 'preset-tower-reviewer',
30
28
  ])
31
29
 
32
30
  /** Debounce window for the dev hot-reload hook (milliseconds). */
package/host/presets.js CHANGED
@@ -1,5 +1,20 @@
1
- // Cross-field preset validation (#64): fail before work starts, not at spawn.
1
+ // Cross-field preset and component validation (#64, #121): fail before work
2
+ // starts, not at spawn.
3
+ //
4
+ // Two invariants travel with the optional rows:
5
+ //
6
+ // 1. `preset-tower-worker` is a mandatory dependency: planning, mode entry and
7
+ // spawn must refuse when the worker preset is absent.
8
+ // 2. `preset-tower-reviewer` is a *conditional* component. It may be off while
9
+ // `review.requireReview` is false (merge is unaffected); with the review
10
+ // gate on it must be locked with an explanatory error, never silently
11
+ // allowed.
12
+ //
13
+ // `tower-tests` adds a third: with the component row off there is no
14
+ // `tower_test` tool and no receipt, so `gates.requireTests: true` is refused
15
+ // here rather than left to fail at merge time with a misleading blocker.
2
16
  import { TowerToolError } from '../domain/errors.js'
17
+ import { componentEnabled, disabledReason } from './capability-registry.js'
3
18
 
4
19
  export function presetIds(config = {}) {
5
20
  return {
@@ -13,23 +28,50 @@ function rowId(preset) {
13
28
  }
14
29
 
15
30
  /**
16
- * Resolve both presets against the profile's agentPresets service. Fail closed
17
- * with preset-missing before mode entry/planning when a required preset is not
31
+ * Resolve the presets against the profile's agentPresets service. Fail closed
32
+ * with preset-missing before mode entry/planning when a *required* preset is not
18
33
  * registered. If the service is unavailable, doctor reports that separately and
19
34
  * this check cannot make a claim either way.
35
+ *
36
+ * The reviewer preset is required only when `review.requireReview` is true; the
37
+ * off state is otherwise supported by the component contract.
20
38
  */
21
39
  export async function assertPresetsAvailable(ctx, config = {}) {
22
40
  const service = ctx?.get?.('agentPresets')
23
41
  if (!service || typeof service.list !== 'function') return { checked: false, missing: [] }
24
42
  const presets = presetIds(config)
43
+ const required = [presets.worker]
44
+ if (config.review?.requireReview === true) required.push(presets.reviewer)
25
45
  const available = new Set((await service.list()).map((entry) => String(entry.id)))
26
- const missing = [presets.worker, presets.reviewer].filter((id) => !available.has(id))
27
- if (missing.length > 0) {
28
- const rows = [...new Set(missing.map(rowId))]
46
+ const missing = required.filter((id) => !available.has(id))
47
+ if (missing.length === 0) return { checked: true, missing: [] }
48
+ const rows = [...new Set(missing.map(rowId))]
49
+ const reviewLock =
50
+ config.review?.requireReview === true && missing.includes(presets.reviewer)
51
+ ? ` review.requireReview is true, so the reviewer row may not be off; ${disabledReason(rowId(presets.reviewer))} is rejected`
52
+ : ''
53
+ throw new TowerToolError(
54
+ 'preset-missing',
55
+ `required Tower agent preset(s) missing: ${missing.join(', ')}; re-enable ${rows.join(', ')} in the plugin page, then restart DSH.${reviewLock}`,
56
+ )
57
+ }
58
+
59
+ /**
60
+ * Every runtime invariant that depends on which component rows are mounted.
61
+ * Called before planning and before entering Tower mode, so a configuration
62
+ * that a component switch has invalidated is rejected with an explanation
63
+ * instead of failing later at merge time.
64
+ *
65
+ * @param {any} ctx
66
+ * @param {Record<string, any>} config
67
+ */
68
+ export async function assertRuntimePreconditions(ctx, config = {}) {
69
+ const presets = await assertPresetsAvailable(ctx, config)
70
+ if (config.gates?.requireTests === true && !componentEnabled('tower-tests')) {
29
71
  throw new TowerToolError(
30
- 'preset-missing',
31
- `required Tower agent preset(s) missing: ${missing.join(', ')}; re-enable ${rows.join(', ')} in the plugin page, then restart DSH`,
72
+ 'config-invalid',
73
+ `gates.requireTests is true but ${disabledReason('tower-tests')}; re-enable the tower-tests component or set gates.requireTests:false`,
32
74
  )
33
75
  }
34
- return { checked: true, missing: [] }
76
+ return presets
35
77
  }
@@ -18,7 +18,7 @@ import { git, resolveRepo, hasAnyCommit } from '../adapters/git.js'
18
18
  import { runtimeConfig } from './config.js'
19
19
  import { runDoctor } from './doctor.js'
20
20
  import { createHmrBridge } from './hmr.js'
21
- import { assertPresetsAvailable } from './presets.js'
21
+ import { assertRuntimePreconditions } from './presets.js'
22
22
  import { readStateLock } from '../adapters/store.js'
23
23
  import { lockTiming } from '../adapters/persistence.js'
24
24
  import { acquireLease, releaseLease, inspectLeases } from '../adapters/leases.js'
@@ -53,6 +53,7 @@ import { registerFindingsTool } from '../tools/findings.js'
53
53
  import { readDiscoveryConfig, normalizeDiscovery, runDiscovery } from '../adapters/tests.js'
54
54
  import { summarize as summarizeBatch } from '../domain/batch.js'
55
55
  import { assertPorts } from '../ports/index.js'
56
+ import { COMPONENT_TOOLS, componentEnabled, componentReport, holdComponent, disabledReason } from './capability-registry.js'
56
57
  import path from 'node:path'
57
58
  import { fsp } from '../adapters/store-fs.js'
58
59
  import { createDefaultDeps } from '../adapters/default.js'
@@ -60,6 +61,17 @@ import crypto from 'node:crypto'
60
61
 
61
62
  // Mutable composition state is grouped in one const object: the registration
62
63
  // aggregator is the composition root, not an ambient module-level binding.
64
+ /**
65
+ * The workspace-lock hook the Host entry injects (`index.js`). Optional
66
+ * members are only present when the entry supplies them, so the registry and
67
+ * the tools that receive it declare the same shape.
68
+ * @typedef {object} WorkspaceHook
69
+ * @property {(repoRoot: string, exec: any, toolName: string) => Promise<void>} ensure
70
+ * @property {(exec: any) => string | null} [basePreference]
71
+ * @property {(exec: any) => Record<string, any> | null} [modeInfo]
72
+ * @property {(entry: any, key: string) => Promise<Record<string, any>>} [status]
73
+ */
74
+ /** @type {{ workspaceHook: WorkspaceHook | null, hmrBridge: ReturnType<typeof createHmrBridge> | null, deps: Record<string, any> | null, definedTools: Set<string> }} */
63
75
  const runtimeState = {
64
76
  workspaceHook: null,
65
77
  hmrBridge: null,
@@ -67,23 +79,54 @@ const runtimeState = {
67
79
  // fallback is for tests/embedders that call registerTowerTools() directly;
68
80
  // it is still built once per process.
69
81
  deps: null,
70
- // #84: the defined tool names are recorded before the exposure switches run,
71
- // so the concept budget measures the full surface rather than one profile's
72
- // subset. The number is reported by tower_doctor and compared with the
73
- // committed scripts/surface-baseline.json.
82
+ // #84/#118/#121: registered tool names are recorded before any component row
83
+ // decides whether its capability mounts, so the concept budget measures the
84
+ // full build surface (29 tools) rather than one profile's mounted subset (28
85
+ // with the shipped `tower-hmr` row off). `recordToolRegistration` is the one
86
+ // recorder: define() calls it for every definition, the Host entry calls it
87
+ // for the directly registered tower_mode/tower_control pair, and the host
88
+ // records the declared capability tools (COMPONENT_TOOLS) because those entry
89
+ // points only run when their row is on. The number is reported by
90
+ // tower_doctor and compared with the committed scripts/surface-baseline.json.
74
91
  definedTools: new Set(),
75
92
  }
76
93
 
77
- /** The full defined tool surface (names, sorted), independent of exposure flags. */
94
+ /**
95
+ * Record one tool name in the concept-budget surface (#84, #118, #121).
96
+ *
97
+ * This is the single recorder for the *build* tool surface. `define()` calls it
98
+ * for every definition when a capability is mounted; the Host entry (`index.js`)
99
+ * calls it for the two tools it registers directly, `tower_mode` and
100
+ * `tower_control`; and `registerCoreTowerTools` records the declared capability
101
+ * tools up front so a row that ships off does not shrink the budget. One
102
+ * recorder means the count cannot silently miss a registration path again: #118
103
+ * found 27 of the 29 names because only the `define()` path was recorded.
104
+ */
105
+ export function recordToolRegistration(name) {
106
+ if (typeof name !== 'string' || name.trim().length === 0) {
107
+ throw new TypeError('recordToolRegistration requires a non-empty tool name')
108
+ }
109
+ runtimeState.definedTools.add(name.trim())
110
+ }
111
+
112
+ /**
113
+ * The full shipped tool surface (names, sorted). It covers every tool this build
114
+ * can register, including capabilities whose component row is currently off.
115
+ */
78
116
  export function definedToolNames() {
79
117
  return [...runtimeState.definedTools].sort()
80
118
  }
81
119
 
82
- /** The number of tools defined by this build, used by the concept budget (#84). */
120
+ /** The number of tools this build ships, used by the concept budget (#84). */
83
121
  export function definedToolCount() {
84
122
  return runtimeState.definedTools.size
85
123
  }
86
124
 
125
+ /** Test hook: the recorded set is process state and must be reset per fixture. */
126
+ export function __testResetToolSurface() {
127
+ runtimeState.definedTools.clear()
128
+ }
129
+
87
130
  /** The injected port set for the app layer (built once per process). */
88
131
  function towerDeps() {
89
132
  if (!runtimeState.deps) runtimeState.deps = createDefaultDeps()
@@ -335,15 +378,7 @@ async function ensureRepository(repoRootInput, autoGitInit) {
335
378
  }
336
379
  }
337
380
 
338
- const OPERATIONAL_TOOLS = new Map([
339
- ['tower_batch', 'exposeBatch'],
340
- ['tower_resource', 'exposeLeases'],
341
- ['tower_import_legacy', 'exposeImport'],
342
- ['tower_legacy_map', 'exposeImport'],
343
- ['tower_recover', 'exposeRecovery'],
344
- ['tower_test', 'exposeTests'],
345
- ['tower_doctor', 'exposeDoctor'],
346
- ])
381
+ const OPERATIONAL_TOOLS = new Map()
347
382
 
348
383
  function plainSchema(value) {
349
384
  return JSON.parse(JSON.stringify(value))
@@ -368,12 +403,10 @@ function plainSchema(value) {
368
403
  */
369
404
  function define(ctx, definition) {
370
405
  const { aliases, requiresMissionId, board, execute, ...rest } = definition
371
- // Record the definition before the exposure gate: the concept budget counts
372
- // the tools this build can register, not only the ones a profile exposes.
373
- runtimeState.definedTools.add(rest.name)
374
- const toolsConfig = runtimeConfig().tools ?? {}
375
- const flag = OPERATIONAL_TOOLS.get(rest.name)
376
- if (flag && (toolsConfig.exposeOperational === false || toolsConfig[flag] === false)) return
406
+ // Record the definition for the concept budget. The capability that owns the
407
+ // tool decides whether it is registered at all (#121): the component row is
408
+ // the switch, so `define()` no longer consults a `tools.expose*` boolean.
409
+ recordToolRegistration(rest.name)
377
410
  ctx.effect(() =>
378
411
  ctx.tools.register({
379
412
  ...rest,
@@ -433,6 +466,12 @@ function textOf(blocks) {
433
466
  /** Read `package.json tower.testDiscovery` and run it for one mission worktree.
434
467
  * Returns `{configured:false}` when the repository has no discovery block. */
435
468
  async function runMissionDiscovery(tower, mission) {
469
+ // #121: test discovery is the tower-tests component. With the row off there is
470
+ // no tower_test tool and no receipt; never execute repository code silently
471
+ // because a config key still says admission complete/spawn.
472
+ if (!componentEnabled('tower-tests')) {
473
+ return { configured: false, disabledBy: 'tower-tests', reason: disabledReason('tower-tests') }
474
+ }
436
475
  const testsConfig = tower.config.tests ?? {}
437
476
  if (testsConfig.enabled === false || testsConfig.allowPackageDiscovery === false) {
438
477
  return { configured: false }
@@ -495,9 +534,29 @@ async function observeActivity(ctx, tower) {
495
534
  return { activity, parentStatus }
496
535
  }
497
536
 
498
- export function registerTowerTools(ctx, options = {}) {
537
+ /**
538
+ * Register the mandatory Tower host surface (#121).
539
+ *
540
+ * The host owns planning, mode integration, the health contract
541
+ * (`tower_doctor`) and the recovery escape (`tower_recover`); neither is a
542
+ * component and neither can be switched off. The optional capabilities
543
+ * (batch/import/leases/tests/reload) are registered by their own entry points
544
+ * (`registerBatchCapability`, ...), so switching a component row off removes
545
+ * exactly that capability while the host keeps working.
546
+ */
547
+ export function registerCoreTowerTools(ctx, options = {}) {
499
548
  runtimeState.workspaceHook = options.workspace ?? null
500
549
  runtimeState.hmrBridge = options.hmrBridge ?? null
550
+ // #121/#122: capability tools are part of this build even while their
551
+ // component row is off. A row's entry point only runs when the row is on, so
552
+ // record the declared capability names here — before any row gates
553
+ // registration — and the concept budget reports the full 29-tool build
554
+ // surface in every profile. `componentReport` is what names the subset that
555
+ // is actually mounted; this count never shrinks because a user switched a
556
+ // component off.
557
+ for (const toolIds of Object.values(COMPONENT_TOOLS)) {
558
+ for (const toolId of toolIds) recordToolRegistration(toolId)
559
+ }
501
560
  // #69: the host entry (index.js) is the composition root. When it supplies the
502
561
  // port set, adopt it — validated once — instead of constructing a second set
503
562
  // here. Tests/embedders that omit deps fall back to one lazily built set.
@@ -548,19 +607,21 @@ export function registerTowerTools(ctx, options = {}) {
548
607
  ctx.effect(() => () => clearInterval(timer))
549
608
  registerInitTool(ctx, { openTower, define, actorFor, requireTowerActor, workspaceHook: runtimeState.workspaceHook, ensureRepository, resolveRepo, runtimeConfig })
550
609
 
551
- registerPlanTool(ctx, { openTower, define, actorFor, requireTowerActor, assertPresetsAvailable })
610
+ // #121: planning validates the mounted component rows as well as the presets,
611
+ // so a review gate without its reviewer row is refused before work starts.
612
+ registerPlanTool(ctx, { openTower, define, actorFor, requireTowerActor, assertPresetsAvailable: assertRuntimePreconditions })
552
613
 
553
614
  registerMissionTool(ctx, { openTower, define, actorFor, requireKnownActor })
554
615
 
555
- registerStatusTool(ctx, { openTower, define, workspaceHook: runtimeState.workspaceHook, spawnBackoffView, observeActivity })
616
+ registerStatusTool(ctx, { openTower, define, workspaceHook: runtimeState.workspaceHook, spawnBackoffView, observeActivity, componentReport })
556
617
 
557
- registerSpawnTool(ctx, { openTower, define, actorFor, requireTowerActor, enqueueSpawn, observeActivity, spawnBackoffState, spawnBackoffView, recordSpawnThrottle, recordSpawnSuccess, runMissionDiscovery, textOf, inspectLeases })
618
+ registerSpawnTool(ctx, { openTower, define, actorFor, requireTowerActor, enqueueSpawn, observeActivity, spawnBackoffState, recordSpawnThrottle, recordSpawnSuccess, runMissionDiscovery, textOf, inspectLeases })
558
619
 
559
- registerRosterTool(ctx, { openTower, define, workspaceHook: runtimeState.workspaceHook, observeActivity, actorFor, requireTowerActor, interruptRosterAgent })
620
+ registerRosterTool(ctx, { openTower, define })
560
621
 
561
622
  registerInterruptTool(ctx, { openTower, define, actorFor, requireTowerActor, interruptRosterAgent })
562
623
 
563
- registerDiffTool(ctx, { openTower, define, actorFor, requireTowerActor })
624
+ registerDiffTool(ctx, { openTower, define })
564
625
 
565
626
  registerAdoptTool(ctx, { openTower, define, actorFor, requireTowerActor })
566
627
 
@@ -591,24 +652,79 @@ export function registerTowerTools(ctx, options = {}) {
591
652
  resolveRepo,
592
653
  runtimeConfig,
593
654
  runDoctor,
594
- // #84: the doctor reports the live surface; the count is the full defined
595
- // tool surface, independent of which operational switches this profile uses.
655
+ // #84/#122: the doctor reports the full registered surface. It is always
656
+ // registered itself, and it is the reporter that names a disabled component.
596
657
  surface: () => ({ tools: definedToolCount() }),
597
658
  })
598
659
 
660
+ // #122: the recovery escape is registered for every configuration. The lock it
661
+ // repairs can be left behind by any crashed mutation, so an off state would be
662
+ // worse than uninstalling the plugin.
599
663
  registerRecoverTool(ctx, { openTower, define, actorFor, requireTowerActor, readStateLock, lockTiming })
664
+ }
600
665
 
601
- registerTestTool(ctx, { openTower, define, actorFor, requireKnownActor, runMissionDiscovery })
602
-
666
+ /** Evidence batches — the `tower-batch` component. */
667
+ export function registerBatchCapability(ctx) {
603
668
  registerBatchTool(ctx, { openTower, define, actorFor, requireTowerActor, summarizeBatch, verifyBatchReceiptFiles })
669
+ }
604
670
 
671
+ /** Advisory leases — the `tower-leases` component. */
672
+ export function registerLeasesCapability(ctx) {
605
673
  registerResourceTool(ctx, { openTower, define, actorFor, requireTowerActor, acquireLease, releaseLease, inspectLeases })
674
+ }
606
675
 
676
+ /** Legacy import and its audit map — the `tower-import` component. */
677
+ export function registerImportCapability(ctx) {
607
678
  registerImportLegacyTool(ctx, { openTower, define, actorFor, requireTowerActor, importLegacySnapshot })
608
-
609
679
  registerLegacyMapTool(ctx, { openTower, define, actorFor, requireTowerActor, validateLegacyMap })
680
+ }
610
681
 
611
- registerReloadTool(ctx, { openTower, define, actorFor, requireTowerActor, sharedHmrBridge: runtimeState.hmrBridge, runtimeConfig, createHmrBridge })
682
+ /** Declared test discovery — the `tower-tests` component. */
683
+ export function registerTestsCapability(ctx) {
684
+ registerTestTool(ctx, { openTower, define, actorFor, requireKnownActor, runMissionDiscovery })
685
+ }
686
+
687
+ /**
688
+ * Dev hot reload — the `tower-hmr` component (shipped off).
689
+ * The returned bridge is the one `/tower reload` and `tower_mode` report.
690
+ */
691
+ export function registerHmrCapability(ctx, options = {}) {
692
+ const dev = runtimeConfig().dev ?? {}
693
+ const bridge =
694
+ options.bridge ??
695
+ runtimeState.hmrBridge ??
696
+ createHmrBridge(ctx, {
697
+ requiredIds: dev.reloadRequiredIds,
698
+ delayMs: dev.reloadDelayMs,
699
+ logger: ctx.logger ?? console,
700
+ })
701
+ registerReloadTool(ctx, { openTower, define, actorFor, requireTowerActor, sharedHmrBridge: bridge, runtimeConfig, createHmrBridge })
702
+ return bridge
703
+ }
704
+
705
+ /**
706
+ * Register every optional capability in-process. This is the aggregate used by
707
+ * tests and embedders; the shipped profile mounts the capability rows instead,
708
+ * so one row can be off without removing the rest.
709
+ */
710
+ export function registerAllCapabilities(ctx, options = {}) {
711
+ for (const id of ['tower-batch', 'tower-import', 'tower-leases', 'tower-tests', 'tower-hmr']) holdComponent(ctx, id)
712
+ registerBatchCapability(ctx)
713
+ registerImportCapability(ctx)
714
+ registerLeasesCapability(ctx)
715
+ registerTestsCapability(ctx)
716
+ registerHmrCapability(ctx, options)
717
+ }
718
+
719
+ /**
720
+ * Backwards-compatible aggregate: the mandatory host surface plus every
721
+ * optional capability. Tests and embedders call this; the shipped composition
722
+ * calls `registerCoreTowerTools` and lets the component rows register their own
723
+ * capability.
724
+ */
725
+ export function registerTowerTools(ctx, options = {}) {
726
+ registerCoreTowerTools(ctx, options)
727
+ registerAllCapabilities(ctx, options)
612
728
  }
613
729
 
614
730
  export const __testSpawnBackoff = {