dsh-tower 2.1.0 → 2.4.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 (203) hide show
  1. package/CHANGELOG.md +137 -0
  2. package/COMPATIBILITY.md +6 -1
  3. package/CONTRIBUTING.md +78 -5
  4. package/PRINCIPLES.md +163 -0
  5. package/README.en.md +35 -19
  6. package/README.md +28 -18
  7. package/SECURITY.md +6 -0
  8. package/{lib → adapters}/archive.js +1 -1
  9. package/{lib → adapters}/caseSensitivity.js +1 -1
  10. package/adapters/default.js +51 -10
  11. package/{lib → adapters}/git.js +1 -1
  12. package/{lib → adapters}/leases.js +1 -1
  13. package/{lib → adapters}/legacy.js +1 -1
  14. package/adapters/persistence.js +543 -0
  15. package/adapters/store-fs.js +28 -1
  16. package/adapters/store.js +204 -0
  17. package/{lib → adapters}/tests.js +9 -5
  18. package/adapters/vcs-git.js +15 -1
  19. package/{lib → adapters}/workspaceLock.js +1 -1
  20. package/{lib → adapters}/worktreeOwnership.js +1 -1
  21. package/app/archive.js +4 -6
  22. package/app/batch.js +9 -10
  23. package/app/board.js +2 -4
  24. package/app/checkCompletion.js +9 -10
  25. package/app/enrichedMissions.js +2 -4
  26. package/app/findings.js +6 -7
  27. package/app/gate.js +1 -2
  28. package/app/init.js +24 -29
  29. package/app/merge.js +58 -51
  30. package/app/messages.js +7 -8
  31. package/app/mission.js +5 -5
  32. package/app/plan.js +8 -5
  33. package/app/provision.js +12 -14
  34. package/app/read.js +6 -3
  35. package/app/rebase.js +14 -16
  36. package/app/recording.js +7 -8
  37. package/app/review.js +15 -8
  38. package/app/roster.js +14 -15
  39. package/{lib → app}/spawn-prechecks.js +4 -3
  40. package/app/spawn.js +12 -13
  41. package/app/teardown.js +8 -9
  42. package/app/tower.js +89 -35
  43. package/cordis.patch.yml +56 -28
  44. package/docs/README.md +7 -2
  45. package/docs/adr/0001-ports-adapters-as-data.md +49 -0
  46. package/docs/adr/0002-single-state-json.md +50 -0
  47. package/docs/adr/0003-allow-agent-control-default-true.md +48 -0
  48. package/docs/adr/0004-surface-budget.md +70 -0
  49. package/docs/adr/0005-component-surface.md +130 -0
  50. package/docs/adr/README.md +52 -0
  51. package/docs/agents/workflow.md +51 -0
  52. package/docs/architecture/overview.md +192 -43
  53. package/docs/guide/hot-reload.md +5 -5
  54. package/docs/guide/install-upgrade-migration.md +13 -0
  55. package/docs/guide/release.md +1 -1
  56. package/docs/operations/incident.md +82 -0
  57. package/docs/operations/runbook.md +12 -3
  58. package/docs/operations/security.md +1 -1
  59. package/docs/reference/blockers.md +2 -2
  60. package/docs/reference/component-surface.md +135 -0
  61. package/docs/reference/config.generated.md +2 -9
  62. package/docs/reference/config.md +11 -9
  63. package/docs/reference/doctor.md +53 -2
  64. package/docs/reference/errors.generated.md +41 -37
  65. package/docs/reference/errors.md +53 -41
  66. package/docs/reference/events.md +38 -6
  67. package/docs/reference/surface-budget.md +157 -0
  68. package/docs/reference/tools.generated.md +8 -5
  69. package/docs/reference/tools.md +10 -7
  70. package/docs/zh/README.md +1 -1
  71. package/docs/zh/guide/hot-reload.md +4 -4
  72. package/docs/zh/guide/install-upgrade-migration.md +10 -0
  73. package/docs/zh/operations/security.md +1 -1
  74. package/docs/zh/reference/blockers.md +2 -2
  75. package/docs/zh/reference/config.md +9 -9
  76. package/docs/zh/reference/doctor.md +29 -1
  77. package/docs/zh/reference/tools.md +10 -8
  78. package/domain/batch.js +2 -2
  79. package/domain/components.js +641 -0
  80. package/domain/config/schema.js +62 -28
  81. package/domain/contracts/outputs.js +16 -2
  82. package/domain/contracts/tool-inputs/ops.js +1 -0
  83. package/domain/errors.js +308 -43
  84. package/domain/events.js +207 -0
  85. package/domain/integration/gate.js +17 -2
  86. package/domain/integration/merge.js +35 -0
  87. package/domain/lockBudget.js +30 -0
  88. package/domain/mission.js +12 -1
  89. package/domain/missions/completion.js +14 -0
  90. package/domain/missions/lifecycle.js +9 -3
  91. package/domain/missions/plan.js +14 -0
  92. package/domain/missions/review.js +90 -7
  93. package/domain/read/views.js +3 -0
  94. package/domain/state/archive.js +1 -1
  95. package/domain/state.js +1 -1
  96. package/domain/surface.js +347 -0
  97. package/host/capability-batch.js +15 -0
  98. package/host/capability-hmr.js +30 -0
  99. package/host/capability-import.js +11 -0
  100. package/host/capability-leases.js +11 -0
  101. package/host/capability-registry.js +149 -0
  102. package/host/capability-tests.js +16 -0
  103. package/{lib → host}/doctor.js +157 -18
  104. package/{lib → host}/hmr.js +2 -4
  105. package/{lib → host}/modeStore.js +1 -1
  106. package/host/presets.js +77 -0
  107. package/{lib → host}/tool-registry.js +235 -44
  108. package/host/tower.js +32 -0
  109. package/index.js +40 -25
  110. package/locale/en.json +31 -11
  111. package/locale/zh.json +30 -10
  112. package/package.json +24 -4
  113. package/ports/archive.js +18 -0
  114. package/ports/caseSensitivity.js +15 -0
  115. package/ports/clock.js +10 -2
  116. package/ports/config.js +19 -0
  117. package/ports/contract.js +38 -0
  118. package/ports/events.js +24 -0
  119. package/ports/ids.js +10 -2
  120. package/ports/index.js +54 -2
  121. package/ports/ownership.js +17 -0
  122. package/ports/state.js +16 -0
  123. package/ports/store.js +52 -3
  124. package/ports/vcs.js +25 -4
  125. package/scripts/architecture-waivers.json +19 -0
  126. package/scripts/architecture.mjs +634 -23
  127. package/scripts/check-components.mjs +235 -0
  128. package/scripts/check-docs.mjs +8 -35
  129. package/scripts/check-pack.mjs +68 -39
  130. package/scripts/check-philosophy.mjs +266 -0
  131. package/scripts/component-surface.json +395 -0
  132. package/scripts/domain-metrics.mjs +13 -4
  133. package/scripts/generate-component-surface.mjs +59 -0
  134. package/scripts/generate-config-reference.mjs +14 -1
  135. package/scripts/generate-error-reference.mjs +68 -14
  136. package/scripts/generate-events-reference.mjs +83 -0
  137. package/scripts/generate-tools-reference.mjs +18 -25
  138. package/scripts/generate-types.mjs +127 -0
  139. package/scripts/issue-audit.mjs +2 -2
  140. package/scripts/lib/surface.mjs +87 -0
  141. package/scripts/lib/tool-surface.mjs +85 -0
  142. package/scripts/mount-smoke/fixtures.mjs +3 -3
  143. package/scripts/mount-smoke/surface.mjs +96 -27
  144. package/scripts/mount-smoke.mjs +3 -1
  145. package/scripts/pack-manifest.txt +75 -22
  146. package/scripts/regression-audit.mjs +164 -0
  147. package/scripts/release-check.mjs +1 -1
  148. package/scripts/surface-baseline.json +52 -0
  149. package/scripts/surface-baseline.mjs +118 -0
  150. package/scripts/types-coverage.mjs +184 -0
  151. package/scripts/types-strict-manifest.txt +10 -0
  152. package/scripts/types-strict.mjs +141 -0
  153. package/skills.js +8 -1
  154. package/tools/adopt.js +2 -3
  155. package/tools/batch.js +2 -3
  156. package/tools/complete.js +2 -3
  157. package/tools/diff.js +3 -3
  158. package/tools/doctor.js +7 -10
  159. package/tools/finding.js +2 -3
  160. package/tools/findings.js +3 -3
  161. package/tools/import-legacy.js +3 -5
  162. package/tools/inbox.js +3 -3
  163. package/tools/index.js +9 -8
  164. package/tools/init.js +3 -6
  165. package/tools/interrupt.js +2 -3
  166. package/tools/legacy-map.js +3 -5
  167. package/tools/merge.js +3 -3
  168. package/tools/mission.js +2 -3
  169. package/tools/plan.js +6 -5
  170. package/tools/preflight.js +2 -3
  171. package/tools/rebase.js +3 -3
  172. package/tools/recover.js +38 -13
  173. package/tools/reload.js +36 -40
  174. package/tools/resource.js +3 -5
  175. package/tools/review.js +3 -4
  176. package/tools/roster.js +2 -3
  177. package/tools/send.js +2 -2
  178. package/tools/spawn.js +8 -5
  179. package/tools/status.js +11 -4
  180. package/tools/teardown.js +2 -3
  181. package/tools/test.js +2 -2
  182. package/types/index.d.ts +29 -13
  183. package/ui-bundle/README.md +37 -3
  184. package/ui-bundle/client.cards.js +356 -0
  185. package/ui-bundle/client.denied.js +142 -0
  186. package/ui-bundle/client.dock.js +333 -0
  187. package/ui-bundle/client.helpers.js +271 -0
  188. package/ui-bundle/client.js +79 -1747
  189. package/ui-bundle/client.locales.js +247 -0
  190. package/ui-bundle/client.mode.js +206 -0
  191. package/ui-bundle/client.overlay.js +64 -0
  192. package/ui-bundle/client.styles.js +151 -0
  193. package/ui-bundle/client.toolview.js +82 -0
  194. package/ui-bundle/package.json +1 -0
  195. package/lib/persistence.js +0 -356
  196. package/lib/presets.js +0 -35
  197. package/lib/store.js +0 -133
  198. /package/{lib → adapters}/stateFile.js +0 -0
  199. /package/{lib → app}/briefing.js +0 -0
  200. /package/{lib → app}/spawn-support.js +0 -0
  201. /package/{lib → host}/compatibility.js +0 -0
  202. /package/{lib → host}/config.js +0 -0
  203. /package/{lib → host}/modeState.js +0 -0
@@ -14,8 +14,15 @@ import {
14
14
  isTowerRole,
15
15
  isUnknownRole,
16
16
  } from '../domain/vocabulary.js'
17
- import { git, resolveRepo, hasAnyCommit } from './git.js'
17
+ import { git, resolveRepo, hasAnyCommit } from '../adapters/git.js'
18
18
  import { runtimeConfig } from './config.js'
19
+ import { runDoctor } from './doctor.js'
20
+ import { createHmrBridge } from './hmr.js'
21
+ import { assertRuntimePreconditions } from './presets.js'
22
+ import { readStateLock } from '../adapters/store.js'
23
+ import { lockTiming } from '../adapters/persistence.js'
24
+ import { acquireLease, releaseLease, inspectLeases } from '../adapters/leases.js'
25
+ import { importLegacySnapshot, validateLegacyMap } from '../adapters/legacy.js'
19
26
  import { registerDoctorTool } from '../tools/doctor.js'
20
27
  import { registerRecoverTool } from '../tools/recover.js'
21
28
  import { registerTestTool } from '../tools/test.js'
@@ -43,17 +50,97 @@ import { registerSpawnTool } from '../tools/spawn.js'
43
50
  import { registerCompleteTool } from '../tools/complete.js'
44
51
  import { registerFindingTool } from '../tools/finding.js'
45
52
  import { registerFindingsTool } from '../tools/findings.js'
46
- import { readDiscoveryConfig, normalizeDiscovery, runDiscovery } from './tests.js'
53
+ import { readDiscoveryConfig, normalizeDiscovery, runDiscovery } from '../adapters/tests.js'
47
54
  import { summarize as summarizeBatch } from '../domain/batch.js'
55
+ import { assertPorts } from '../ports/index.js'
56
+ import { COMPONENT_TOOLS, componentEnabled, componentReport, holdComponent, disabledReason } from './capability-registry.js'
48
57
  import path from 'node:path'
49
58
  import { fsp } from '../adapters/store-fs.js'
59
+ import { createDefaultDeps } from '../adapters/default.js'
50
60
  import crypto from 'node:crypto'
51
61
 
52
62
  // Mutable composition state is grouped in one const object: the registration
53
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> }} */
54
75
  const runtimeState = {
55
76
  workspaceHook: null,
56
77
  hmrBridge: null,
78
+ // #69: the host entry builds the adapter set once and passes it in. The lazy
79
+ // fallback is for tests/embedders that call registerTowerTools() directly;
80
+ // it is still built once per process.
81
+ deps: null,
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.
91
+ definedTools: new Set(),
92
+ }
93
+
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
+ */
116
+ export function definedToolNames() {
117
+ return [...runtimeState.definedTools].sort()
118
+ }
119
+
120
+ /** The number of tools this build ships, used by the concept budget (#84). */
121
+ export function definedToolCount() {
122
+ return runtimeState.definedTools.size
123
+ }
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
+
130
+ /** The injected port set for the app layer (built once per process). */
131
+ function towerDeps() {
132
+ if (!runtimeState.deps) runtimeState.deps = createDefaultDeps()
133
+ return runtimeState.deps
134
+ }
135
+
136
+ /** Compose the app Tower with the injected ports (never a bare adapter import). */
137
+ function openTower(repoRoot) {
138
+ return Tower.open(repoRoot, towerDeps())
139
+ }
140
+
141
+ /** Build a Tower with an explicit config for the read-only doctor path. */
142
+ function createTower(repo, config) {
143
+ return new Tower(repo, config, towerDeps())
57
144
  }
58
145
 
59
146
  function normalizeToolArgs(args, aliases = {}) {
@@ -291,15 +378,7 @@ async function ensureRepository(repoRootInput, autoGitInit) {
291
378
  }
292
379
  }
293
380
 
294
- const OPERATIONAL_TOOLS = new Map([
295
- ['tower_batch', 'exposeBatch'],
296
- ['tower_resource', 'exposeLeases'],
297
- ['tower_import_legacy', 'exposeImport'],
298
- ['tower_legacy_map', 'exposeImport'],
299
- ['tower_recover', 'exposeRecovery'],
300
- ['tower_test', 'exposeTests'],
301
- ['tower_doctor', 'exposeDoctor'],
302
- ])
381
+ const OPERATIONAL_TOOLS = new Map()
303
382
 
304
383
  function plainSchema(value) {
305
384
  return JSON.parse(JSON.stringify(value))
@@ -324,9 +403,10 @@ function plainSchema(value) {
324
403
  */
325
404
  function define(ctx, definition) {
326
405
  const { aliases, requiresMissionId, board, execute, ...rest } = definition
327
- const toolsConfig = runtimeConfig().tools ?? {}
328
- const flag = OPERATIONAL_TOOLS.get(rest.name)
329
- 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)
330
410
  ctx.effect(() =>
331
411
  ctx.tools.register({
332
412
  ...rest,
@@ -364,7 +444,7 @@ function define(ctx, definition) {
364
444
  }
365
445
  if (board === true && typeof normalized.repoRoot === 'string') {
366
446
  try {
367
- const opened = await Tower.open(normalized.repoRoot)
447
+ const opened = await openTower(normalized.repoRoot)
368
448
  await opened.writeBoard()
369
449
  } catch {
370
450
  // board output is best effort
@@ -386,6 +466,12 @@ function textOf(blocks) {
386
466
  /** Read `package.json tower.testDiscovery` and run it for one mission worktree.
387
467
  * Returns `{configured:false}` when the repository has no discovery block. */
388
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
+ }
389
475
  const testsConfig = tower.config.tests ?? {}
390
476
  if (testsConfig.enabled === false || testsConfig.allowPackageDiscovery === false) {
391
477
  return { configured: false }
@@ -448,9 +534,47 @@ async function observeActivity(ctx, tower) {
448
534
  return { activity, parentStatus }
449
535
  }
450
536
 
451
- 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 = {}) {
452
548
  runtimeState.workspaceHook = options.workspace ?? null
453
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
+ }
560
+ // #69: the host entry (index.js) is the composition root. When it supplies the
561
+ // port set, adopt it — validated once — instead of constructing a second set
562
+ // here. Tests/embedders that omit deps fall back to one lazily built set.
563
+ // #75: the event port is bound here to the live Cordis emitter; emitting is
564
+ // best effort so a listener can never fail the tool that produced the fact.
565
+ const baseDeps = options.deps ? assertPorts(options.deps) : (runtimeState.deps ?? createDefaultDeps())
566
+ runtimeState.deps = {
567
+ ...baseDeps,
568
+ events: {
569
+ emit(name, payload) {
570
+ try {
571
+ ctx.emit?.(name, payload)
572
+ } catch {
573
+ // events are an optional side channel; never fail a tool for them
574
+ }
575
+ },
576
+ },
577
+ }
454
578
  // A lightweight roster watchdog: the authoritative activity comes from the
455
579
  // subagents catalog; a missing observation after spawn.staleMs is marked
456
580
  // stale (never silently treated as stopped) and blocks merge via the gate.
@@ -460,7 +584,7 @@ export function registerTowerTools(ctx, options = {}) {
460
584
  try {
461
585
  if (!ctx.get('subagents')) return
462
586
  for (const repoRoot of [...WATCHED_REPOS]) {
463
- const tower = await Tower.open(repoRoot)
587
+ const tower = await openTower(repoRoot)
464
588
  const view = await tower.rosterView()
465
589
  const live = view.roster.filter((entry) => !ROSTER_INACTIVE_STATES.includes(entry.state))
466
590
  if (live.length === 0) { WATCHED_REPOS.delete(repoRoot); continue }
@@ -481,59 +605,126 @@ export function registerTowerTools(ctx, options = {}) {
481
605
  }, watchMs)
482
606
  timer.unref?.()
483
607
  ctx.effect(() => () => clearInterval(timer))
484
- registerInitTool(ctx, { define, actorFor, requireTowerActor, workspaceHook: runtimeState.workspaceHook, ensureRepository })
608
+ registerInitTool(ctx, { openTower, define, actorFor, requireTowerActor, workspaceHook: runtimeState.workspaceHook, ensureRepository, resolveRepo, runtimeConfig })
609
+
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 })
485
613
 
486
- registerPlanTool(ctx, { define, actorFor, requireTowerActor })
614
+ registerMissionTool(ctx, { openTower, define, actorFor, requireKnownActor })
487
615
 
488
- registerMissionTool(ctx, { define, actorFor, requireKnownActor })
616
+ registerStatusTool(ctx, { openTower, define, workspaceHook: runtimeState.workspaceHook, spawnBackoffView, observeActivity, componentReport })
489
617
 
490
- registerStatusTool(ctx, { define, workspaceHook: runtimeState.workspaceHook, spawnBackoffView, observeActivity })
618
+ registerSpawnTool(ctx, { openTower, define, actorFor, requireTowerActor, enqueueSpawn, observeActivity, spawnBackoffState, recordSpawnThrottle, recordSpawnSuccess, runMissionDiscovery, textOf, inspectLeases })
491
619
 
492
- registerSpawnTool(ctx, { define, actorFor, requireTowerActor, enqueueSpawn, observeActivity, spawnBackoffState, spawnBackoffView, recordSpawnThrottle, recordSpawnSuccess, runMissionDiscovery, textOf })
620
+ registerRosterTool(ctx, { openTower, define })
493
621
 
494
- registerRosterTool(ctx, { define, workspaceHook: runtimeState.workspaceHook, observeActivity, actorFor, requireTowerActor, interruptRosterAgent })
622
+ registerInterruptTool(ctx, { openTower, define, actorFor, requireTowerActor, interruptRosterAgent })
495
623
 
496
- registerInterruptTool(ctx, { define, actorFor, requireTowerActor, interruptRosterAgent })
624
+ registerDiffTool(ctx, { openTower, define })
497
625
 
498
- registerDiffTool(ctx, { define, actorFor, requireTowerActor })
626
+ registerAdoptTool(ctx, { openTower, define, actorFor, requireTowerActor })
499
627
 
500
- registerAdoptTool(ctx, { define, actorFor, requireTowerActor })
628
+ registerSendTool(ctx, { openTower, define, actorFor, checkSendRate })
501
629
 
502
- registerSendTool(ctx, { define, actorFor, checkSendRate })
630
+ registerInboxTool(ctx, { openTower, define })
503
631
 
504
- registerInboxTool(ctx, { define })
632
+ registerCompleteTool(ctx, { openTower, define, runMissionDiscovery })
505
633
 
506
- registerCompleteTool(ctx, { define, runMissionDiscovery })
634
+ registerFindingTool(ctx, { openTower, define, actorFor, requireKnownActor })
507
635
 
508
- registerFindingTool(ctx, { define, actorFor, requireKnownActor })
636
+ registerFindingsTool(ctx, { openTower, define, actorFor, requireKnownActor })
509
637
 
510
- registerFindingsTool(ctx, { define, actorFor, requireKnownActor })
638
+ registerReviewTool(ctx, { openTower, define, actorFor })
511
639
 
512
- registerReviewTool(ctx, { define, actorFor })
640
+ registerPreflightTool(ctx, { openTower, define, observeActivity })
513
641
 
514
- registerPreflightTool(ctx, { define, observeActivity })
642
+ registerMergeTool(ctx, { openTower, define, actorFor, requireTowerActor, observeActivity })
515
643
 
516
- registerMergeTool(ctx, { define, actorFor, requireTowerActor, observeActivity })
644
+ registerRebaseTool(ctx, { openTower, define, actorFor, requireTowerActor, observeActivity })
517
645
 
518
- registerRebaseTool(ctx, { define, actorFor, requireTowerActor, observeActivity })
646
+ registerTeardownTool(ctx, { openTower, define, actorFor, requireTowerActor, observeActivity })
519
647
 
520
- registerTeardownTool(ctx, { define, actorFor, requireTowerActor, observeActivity })
648
+ registerDoctorTool(ctx, {
649
+ openTower,
650
+ define,
651
+ createTower,
652
+ resolveRepo,
653
+ runtimeConfig,
654
+ runDoctor,
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.
657
+ surface: () => ({ tools: definedToolCount() }),
658
+ })
521
659
 
522
- registerDoctorTool(ctx, define)
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.
663
+ registerRecoverTool(ctx, { openTower, define, actorFor, requireTowerActor, readStateLock, lockTiming })
664
+ }
523
665
 
524
- registerRecoverTool(ctx, { define, actorFor, requireTowerActor })
666
+ /** Evidence batches — the `tower-batch` component. */
667
+ export function registerBatchCapability(ctx) {
668
+ registerBatchTool(ctx, { openTower, define, actorFor, requireTowerActor, summarizeBatch, verifyBatchReceiptFiles })
669
+ }
525
670
 
526
- registerTestTool(ctx, { define, actorFor, requireKnownActor, runMissionDiscovery })
671
+ /** Advisory leases — the `tower-leases` component. */
672
+ export function registerLeasesCapability(ctx) {
673
+ registerResourceTool(ctx, { openTower, define, actorFor, requireTowerActor, acquireLease, releaseLease, inspectLeases })
674
+ }
527
675
 
528
- registerBatchTool(ctx, { define, actorFor, requireTowerActor, summarizeBatch, verifyBatchReceiptFiles })
676
+ /** Legacy import and its audit map — the `tower-import` component. */
677
+ export function registerImportCapability(ctx) {
678
+ registerImportLegacyTool(ctx, { openTower, define, actorFor, requireTowerActor, importLegacySnapshot })
679
+ registerLegacyMapTool(ctx, { openTower, define, actorFor, requireTowerActor, validateLegacyMap })
680
+ }
529
681
 
530
- registerResourceTool(ctx, { define, actorFor, requireTowerActor })
682
+ /** Declared test discovery — the `tower-tests` component. */
683
+ export function registerTestsCapability(ctx) {
684
+ registerTestTool(ctx, { openTower, define, actorFor, requireKnownActor, runMissionDiscovery })
685
+ }
531
686
 
532
- registerImportLegacyTool(ctx, { define, actorFor, requireTowerActor })
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
+ }
533
704
 
534
- registerLegacyMapTool(ctx, { define, actorFor, requireTowerActor })
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
+ }
535
718
 
536
- registerReloadTool(ctx, { define, actorFor, requireTowerActor, sharedHmrBridge: runtimeState.hmrBridge })
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)
537
728
  }
538
729
 
539
730
  export const __testSpawnBackoff = {
package/host/tower.js ADDED
@@ -0,0 +1,32 @@
1
+ // Default-deps binding of the Tower app service (#69).
2
+ //
3
+ // The composition root (index.js / host/tool-registry.js) builds one deps object
4
+ // and injects it into the app layer. This module is the convenience binding for
5
+ // embedders and tests that want the process-default adapters; it is the only
6
+ // host/ module besides tool-registry.js that knows the default adapter set.
7
+ //
8
+ // Object layout supplied here is memoised in adapters/default.js, so every
9
+ // caller shares the same adapter instances.
10
+ import { Tower as InjectedTower } from '../app/tower.js'
11
+ import { defaultDeps } from '../adapters/default.js'
12
+
13
+ export class Tower extends InjectedTower {
14
+ /**
15
+ * @param {{ root: string, commonDir: string, branch?: string | null }} repo
16
+ * @param {import('../types/index.js').TowerConfig} [config]
17
+ * @param {Record<string, any>} [deps]
18
+ */
19
+ constructor(repo, config, deps = defaultDeps) {
20
+ super(repo, config, deps)
21
+ }
22
+
23
+ /**
24
+ * @param {string} repoRoot
25
+ * @param {Record<string, any>} [deps]
26
+ */
27
+ static async open(repoRoot, deps = defaultDeps) {
28
+ const repo = await deps.vcs.resolveRepo(repoRoot)
29
+ const config = await deps.config.resolveRepoConfig(repo)
30
+ return new Tower(repo, config, deps)
31
+ }
32
+ }
package/index.js CHANGED
@@ -12,21 +12,22 @@
12
12
  // plugin from disk when the HMR service is present (dev/linked installs).
13
13
  import fs from 'node:fs'
14
14
  import { randomUUID } from 'node:crypto'
15
- import { registerTowerTools } from './lib/tool-registry.js'
16
- import { loadModes, saveModes, storePath } from './lib/modeStore.js'
17
- import { ModeState } from './lib/modeState.js'
18
- import { Config as ConfigSchema, DEFAULT_DENY_TOOLS, configureRuntime, runtimeConfig } from './lib/config.js'
15
+ import { registerCoreTowerTools, recordToolRegistration } from './host/tool-registry.js'
16
+ import { createDefaultDeps } from './adapters/default.js'
17
+ import { loadModes, saveModes, storePath } from './host/modeStore.js'
18
+ import { ModeState } from './host/modeState.js'
19
+ import { Config as ConfigSchema, DEFAULT_DENY_TOOLS, configureRuntime, runtimeConfig } from './host/config.js'
19
20
  import { TowerToolError } from './domain/errors.js'
20
- import { resolveRepo } from './lib/git.js'
21
+ import { resolveRepo } from './adapters/git.js'
21
22
  import {
22
23
  acquireWorkspaceLock,
23
24
  inspectWorkspaceLock,
24
25
  releaseWorkspaceLock,
25
26
  touchWorkspaceLock,
26
27
  workspaceLockToken,
27
- } from './lib/workspaceLock.js'
28
- import { createHmrBridge } from './lib/hmr.js'
29
- import { assertPresetsAvailable } from './lib/presets.js'
28
+ } from './adapters/workspaceLock.js'
29
+ import { assertRuntimePreconditions } from './host/presets.js'
30
+ import { currentHmrBridge } from './host/capability-registry.js'
30
31
 
31
32
  export const name = 'dsh-tower'
32
33
  export const inject = ['tools', 'systemPrompt']
@@ -255,14 +256,6 @@ export function apply(ctx, rawConfig) {
255
256
  modeState.replace(loadModes(config.mode.storePath || undefined))
256
257
  if (typeof ctx.provide === 'function') ctx.provide('tower', modeState)
257
258
  denyTools = new Set(config.mode.denyTools)
258
- const hotReloadEnabled = config.dev?.hotReload !== false
259
- const hmrBridge = hotReloadEnabled
260
- ? createHmrBridge(ctx, {
261
- requiredIds: config.dev?.reloadRequiredIds,
262
- delayMs: config.dev?.reloadDelayMs,
263
- logger: ctx.logger ?? console,
264
- })
265
- : null
266
259
 
267
260
  const workspace = {
268
261
  /** Lazily acquire the advisory workspace lock on the first repo-scoped tool. */
@@ -343,12 +336,26 @@ export function apply(ctx, rawConfig) {
343
336
  },
344
337
  }
345
338
 
346
- registerTowerTools(ctx, { workspace, hmrBridge })
339
+ // #69: the host entry is the composition root — the adapters are constructed
340
+ // once here and injected into the tool layer and every Tower instance.
341
+ // #121: only the mandatory host surface is registered here. The optional
342
+ // capability rows (batch/import/leases/tests/hmr) are separate loader entries
343
+ // and register their own tools, so a disabled row removes exactly one
344
+ // capability.
345
+ registerCoreTowerTools(ctx, { workspace, deps: createDefaultDeps() })
346
+
347
+ // #118/#121: tower_mode/tower_control are registered directly here instead of
348
+ // through define(), so record them through the same recorder the registry
349
+ // uses. tower_control is recorded before the allowAgentControl gate: the
350
+ // concept budget measures the surface this build can register, not only the
351
+ // tools one profile exposes (#84). Together with the capability-tool names the
352
+ // registry records before row gating, these are the shipped 29.
353
+ recordToolRegistration('tower_mode')
354
+ recordToolRegistration('tower_control')
347
355
 
348
356
  // On dispose (including an HMR reload) release advisory workspace locks this
349
357
  // process still owns; a reloaded plugin restores the entries from modes.json.
350
358
  ctx.effect(() => () => {
351
- if (hmrBridge) hmrBridge.dispose?.()
352
359
  for (const entry of modeState.values()) {
353
360
  if (entry?.lock) {
354
361
  releaseWorkspaceLock(entry.lock.commonDir, { token: entry.lock.token }).catch(() => {})
@@ -448,7 +455,12 @@ export function apply(ctx, rawConfig) {
448
455
  deniedWhileActive: [...denyTools],
449
456
  persistedAt: storePath(config.mode.storePath || undefined),
450
457
  pluginVersion: readPluginVersion(),
451
- hotReload: hmrBridge ? hmrBridge.status() : { available: false, disabled: true },
458
+ hotReload: (() => {
459
+ // #121/#122: the tower-hmr component row owns the bridge. When the
460
+ // row is off the capability is reported, never silently absent.
461
+ const bridge = currentHmrBridge()
462
+ return bridge ? bridge.status() : { available: false, disabled: true, component: 'tower-hmr' }
463
+ })(),
452
464
  planMode: (() => {
453
465
  const state = planModeState(ctx, exec.agent)
454
466
  return { available: state.available, active: state.active, pending: state.pending }
@@ -504,7 +516,7 @@ export function apply(ctx, rawConfig) {
504
516
  }
505
517
  const reason = args.reason.trim()
506
518
  if (args.action === 'enter' && !modeOn(key)) {
507
- await assertPresetsAvailable(ctx, config)
519
+ await assertRuntimePreconditions(ctx, config)
508
520
  }
509
521
  const outcome =
510
522
  args.action === 'enter'
@@ -555,20 +567,23 @@ export function apply(ctx, rawConfig) {
555
567
  }
556
568
  const parsed = parseTowerInput(rawInput)
557
569
  if (parsed.action === 'reload') {
558
- if (!hotReloadEnabled) {
559
- return { kind: 'error', text: 'Tower reload is disabled by dev.hotReload:false; restart DSH instead.' }
570
+ // #121: /tower reload is the tower-hmr component. The row is shipped
571
+ // off, so the error names the component instead of an invisible key.
572
+ const bridge = currentHmrBridge()
573
+ if (!bridge) {
574
+ return { kind: 'error', text: 'Tower reload is disabled by component tower-hmr; enable the tower-hmr row in the plugin page and restart DSH.' }
560
575
  }
561
- if (!hmrBridge || !hmrBridge.available) {
576
+ if (!bridge.available) {
562
577
  return { kind: 'error', text: 'Tower reload is unavailable: this DSH profile has no HMR service. Restart DSH instead.' }
563
578
  }
564
- const outcome = hmrBridge.schedule()
579
+ const outcome = bridge.schedule()
565
580
  return {
566
581
  kind: 'success',
567
582
  text: `Tower reload scheduled in ${outcome.delayMs}ms (required entries: ${outcome.requiredIds.join(', ')}). If the new code does not appear, restart DSH.`,
568
583
  }
569
584
  }
570
585
  const { active, message } = parsed
571
- if (active) await assertPresetsAvailable(ctx, config)
586
+ if (active) await assertRuntimePreconditions(ctx, config)
572
587
  const outcome = active
573
588
  ? enterMode(ctx, key, agent, 'user', message || null)
574
589
  : await exitMode(key, 'user', message || null)
package/locale/en.json CHANGED
@@ -4,25 +4,45 @@
4
4
  "description": "Mission planning, isolated worktrees, worker/reviewer agents, and review-gated merges."
5
5
  },
6
6
  "components": {
7
- "tower-bundle": {
8
- "title": "Tower",
9
- "summary": "Mission planning, isolated worktrees, worker/reviewer agents, and merge gates.",
10
- "disables": "Turns off the whole Tower bundle; no orphan preset or skill is left behind."
7
+ "tower": {
8
+ "title": "Tower host",
9
+ "summary": "Mission planning, worktree isolation, the mode guard, the health contract and the recovery escape.",
10
+ "disables": "Turns off the mandatory host; this equals uninstalling Tower: no tools, presets or skills remain."
11
11
  },
12
12
  "tower-skills": {
13
13
  "title": "Tower skills",
14
14
  "summary": "Playbooks for worker/reviewer agents and recovery after a blocked merge.",
15
- "disables": "Internal section; toggled with the Tower bundle."
15
+ "disables": "The three Tower skills leave the catalog; the worker/reviewer protocol sections and every mission tool keep working."
16
16
  },
17
- "preset-tower-worker": {
18
- "title": "Tower worker",
19
- "summary": "Writes code, runs verification, and commits only the files assigned to its mission.",
20
- "disables": "Internal section; a missing preset is rejected before planning/mode entry."
17
+ "tower-batch": {
18
+ "title": "Evidence batches",
19
+ "summary": "Group missions into finite evidence batches and close them against required checks and receipts.",
20
+ "disables": "tower_batch is not registered; missions, gates and merges are unaffected."
21
+ },
22
+ "tower-import": {
23
+ "title": "Legacy import",
24
+ "summary": "Import a Tower Codex legacy state file and audit its snapshot-to-mission mapping.",
25
+ "disables": "tower_import_legacy and tower_legacy_map are not registered; new state is unaffected."
26
+ },
27
+ "tower-leases": {
28
+ "title": "Advisory leases",
29
+ "summary": "Coordinate heavy per-repository work with advisory resource leases.",
30
+ "disables": "tower_resource is not registered; nothing else depends on a lease."
31
+ },
32
+ "tower-tests": {
33
+ "title": "Test discovery",
34
+ "summary": "Run the worktree's declared test discovery and record a receipt for the mission tip.",
35
+ "disables": "tower_test is not registered and no receipt can be recorded; with gates.requireTests:true planning/mode entry is rejected with an explanation."
36
+ },
37
+ "tower-hmr": {
38
+ "title": "Dev hot reload",
39
+ "summary": "Register tower_reload and /tower reload for linked-profile development.",
40
+ "disables": "tower_reload and /tower reload are unavailable; a non-HMR profile behaves exactly as before. Shipped off."
21
41
  },
22
42
  "preset-tower-reviewer": {
23
43
  "title": "Tower reviewer",
24
- "summary": "Read-only checks for scope and acceptance, returning a clean verdict or findings.",
25
- "disables": "Internal section; a missing preset is rejected before planning/mode entry when review is required."
44
+ "summary": "Read-only independent review: inspect the mission diff, verify scope and acceptance, return a verdict.",
45
+ "disables": "The reviewer cannot be dispatched. Supported while review.requireReview is false; with the gate on planning/mode entry is locked with an explanatory error."
26
46
  }
27
47
  }
28
48
  }