dsh-tower 2.1.0 → 2.3.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 (170) hide show
  1. package/CHANGELOG.md +103 -0
  2. package/COMPATIBILITY.md +5 -1
  3. package/CONTRIBUTING.md +64 -5
  4. package/PRINCIPLES.md +145 -0
  5. package/README.en.md +7 -4
  6. package/README.md +7 -4
  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/docs/README.md +6 -2
  44. package/docs/adr/0001-ports-adapters-as-data.md +49 -0
  45. package/docs/adr/0002-single-state-json.md +50 -0
  46. package/docs/adr/0003-allow-agent-control-default-true.md +48 -0
  47. package/docs/adr/0004-surface-budget.md +50 -0
  48. package/docs/adr/README.md +51 -0
  49. package/docs/agents/workflow.md +51 -0
  50. package/docs/architecture/overview.md +167 -36
  51. package/docs/guide/hot-reload.md +1 -1
  52. package/docs/guide/release.md +1 -1
  53. package/docs/operations/incident.md +82 -0
  54. package/docs/operations/runbook.md +12 -3
  55. package/docs/reference/blockers.md +2 -2
  56. package/docs/reference/config.generated.md +1 -0
  57. package/docs/reference/doctor.md +48 -2
  58. package/docs/reference/errors.generated.md +41 -37
  59. package/docs/reference/errors.md +53 -41
  60. package/docs/reference/events.md +38 -6
  61. package/docs/reference/surface-budget.md +128 -0
  62. package/docs/reference/tools.generated.md +5 -4
  63. package/docs/reference/tools.md +4 -4
  64. package/docs/zh/guide/hot-reload.md +1 -1
  65. package/docs/zh/reference/blockers.md +2 -2
  66. package/docs/zh/reference/doctor.md +29 -1
  67. package/docs/zh/reference/tools.md +4 -4
  68. package/domain/batch.js +2 -2
  69. package/domain/config/schema.js +25 -1
  70. package/domain/contracts/outputs.js +6 -2
  71. package/domain/contracts/tool-inputs/ops.js +1 -0
  72. package/domain/errors.js +297 -42
  73. package/domain/events.js +207 -0
  74. package/domain/integration/gate.js +17 -2
  75. package/domain/integration/merge.js +35 -0
  76. package/domain/lockBudget.js +30 -0
  77. package/domain/missions/completion.js +14 -0
  78. package/domain/missions/lifecycle.js +9 -3
  79. package/domain/missions/plan.js +14 -0
  80. package/domain/missions/review.js +90 -7
  81. package/domain/read/views.js +3 -0
  82. package/domain/state/archive.js +1 -1
  83. package/domain/state.js +1 -1
  84. package/domain/surface.js +248 -0
  85. package/{lib → host}/doctor.js +116 -9
  86. package/{lib → host}/modeStore.js +1 -1
  87. package/{lib → host}/tool-registry.js +106 -31
  88. package/host/tower.js +32 -0
  89. package/index.js +12 -9
  90. package/package.json +10 -3
  91. package/ports/archive.js +18 -0
  92. package/ports/caseSensitivity.js +15 -0
  93. package/ports/clock.js +10 -2
  94. package/ports/config.js +19 -0
  95. package/ports/contract.js +38 -0
  96. package/ports/events.js +24 -0
  97. package/ports/ids.js +10 -2
  98. package/ports/index.js +54 -2
  99. package/ports/ownership.js +17 -0
  100. package/ports/state.js +16 -0
  101. package/ports/store.js +52 -3
  102. package/ports/vcs.js +25 -4
  103. package/scripts/architecture-waivers.json +19 -0
  104. package/scripts/architecture.mjs +634 -23
  105. package/scripts/check-docs.mjs +8 -35
  106. package/scripts/check-pack.mjs +68 -39
  107. package/scripts/check-philosophy.mjs +266 -0
  108. package/scripts/domain-metrics.mjs +13 -4
  109. package/scripts/generate-config-reference.mjs +14 -1
  110. package/scripts/generate-error-reference.mjs +68 -14
  111. package/scripts/generate-events-reference.mjs +83 -0
  112. package/scripts/generate-tools-reference.mjs +11 -0
  113. package/scripts/issue-audit.mjs +2 -2
  114. package/scripts/lib/surface.mjs +87 -0
  115. package/scripts/pack-manifest.txt +58 -22
  116. package/scripts/regression-audit.mjs +164 -0
  117. package/scripts/release-check.mjs +1 -1
  118. package/scripts/surface-baseline.json +21 -0
  119. package/scripts/surface-baseline.mjs +120 -0
  120. package/tools/adopt.js +2 -3
  121. package/tools/batch.js +2 -3
  122. package/tools/complete.js +2 -3
  123. package/tools/diff.js +3 -3
  124. package/tools/doctor.js +7 -10
  125. package/tools/finding.js +2 -3
  126. package/tools/findings.js +3 -3
  127. package/tools/import-legacy.js +3 -5
  128. package/tools/inbox.js +3 -3
  129. package/tools/index.js +7 -7
  130. package/tools/init.js +3 -6
  131. package/tools/interrupt.js +2 -3
  132. package/tools/legacy-map.js +3 -5
  133. package/tools/merge.js +3 -3
  134. package/tools/mission.js +2 -3
  135. package/tools/plan.js +6 -5
  136. package/tools/preflight.js +2 -3
  137. package/tools/rebase.js +3 -3
  138. package/tools/recover.js +38 -13
  139. package/tools/reload.js +3 -6
  140. package/tools/resource.js +3 -5
  141. package/tools/review.js +3 -4
  142. package/tools/roster.js +2 -3
  143. package/tools/send.js +2 -2
  144. package/tools/spawn.js +8 -5
  145. package/tools/status.js +3 -3
  146. package/tools/teardown.js +2 -3
  147. package/tools/test.js +2 -2
  148. package/types/index.d.ts +1 -1
  149. package/ui-bundle/README.md +37 -3
  150. package/ui-bundle/client.cards.js +356 -0
  151. package/ui-bundle/client.denied.js +142 -0
  152. package/ui-bundle/client.dock.js +333 -0
  153. package/ui-bundle/client.helpers.js +271 -0
  154. package/ui-bundle/client.js +79 -1747
  155. package/ui-bundle/client.locales.js +247 -0
  156. package/ui-bundle/client.mode.js +206 -0
  157. package/ui-bundle/client.overlay.js +64 -0
  158. package/ui-bundle/client.styles.js +151 -0
  159. package/ui-bundle/client.toolview.js +82 -0
  160. package/ui-bundle/package.json +1 -0
  161. package/lib/persistence.js +0 -356
  162. package/lib/store.js +0 -133
  163. /package/{lib → adapters}/stateFile.js +0 -0
  164. /package/{lib → app}/briefing.js +0 -0
  165. /package/{lib → app}/spawn-support.js +0 -0
  166. /package/{lib → host}/compatibility.js +0 -0
  167. /package/{lib → host}/config.js +0 -0
  168. /package/{lib → host}/hmr.js +0 -0
  169. /package/{lib → host}/modeState.js +0 -0
  170. /package/{lib → host}/presets.js +0 -0
@@ -2,19 +2,33 @@
2
2
  import { execFile } from '../adapters/vcs-git.js'
3
3
  import { promisify } from 'node:util'
4
4
  import path from 'node:path'
5
+ import { fileURLToPath } from 'node:url'
5
6
  import { fsp } from '../adapters/store-fs.js'
6
- import { git } from './git.js'
7
- import { readStateLock } from './store.js'
8
- import { inspectArchive } from './archive.js'
7
+ import { git } from '../adapters/git.js'
8
+ import { readStateLock, readGitLock, gitLockView } from '../adapters/store.js'
9
+ import { lockTiming } from '../adapters/persistence.js'
10
+ import { inspectArchive } from '../adapters/archive.js'
9
11
  import { inspectStateVersion } from '../domain/state.js'
10
12
  import { SCHEMA_VERSION } from '../domain/mission.js'
11
13
  import { DEFAULT_MAX_REPORT_BYTES, completionReportBytes } from '../domain/missions/completion.js'
12
14
  import { comparisonMode } from '../domain/scope.js'
15
+ import { configSurface } from '../domain/config/schema.js'
16
+ import { parseBaseline, surfaceLine, SURFACE_BASELINE_FILE } from '../domain/surface.js'
13
17
  import { assessHostVersion, detectHostVersion, readCompatibility } from './compatibility.js'
14
18
  import { profileConfig } from './config.js'
15
- import { inspectWorkspaceLock } from './workspaceLock.js'
19
+ import { inspectWorkspaceLock } from '../adapters/workspaceLock.js'
16
20
 
17
21
  const execFileAsync = promisify(execFile)
22
+ const PACKAGE_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
23
+
24
+ /** Read the committed concept-budget baseline read-only; null when absent. */
25
+ async function readSurfaceBaseline() {
26
+ try {
27
+ return parseBaseline(await fsp.readFile(path.join(PACKAGE_ROOT, SURFACE_BASELINE_FILE), 'utf8'))
28
+ } catch {
29
+ return null
30
+ }
31
+ }
18
32
 
19
33
  async function exists(target) {
20
34
  try {
@@ -38,6 +52,12 @@ function check(id, status, detail, action) {
38
52
  return { id, status, detail, ...(action ? { action } : {}) }
39
53
  }
40
54
 
55
+ /** Render a lock age the same way the state-lock checker does. */
56
+ function durationText(ms) {
57
+ if (!Number.isFinite(ms)) return 'an unknown duration'
58
+ return `${Math.max(0, Math.round(ms / 1000))} s`
59
+ }
60
+
41
61
  /** Read the on-disk state version without loading, migrating, or locking. */
42
62
  async function inspectStateSchema(paths) {
43
63
  try {
@@ -116,9 +136,10 @@ function stateSchemaReport(info) {
116
136
  }
117
137
 
118
138
  /** Run every read-only diagnostic; never writes state or takes the state lock. */
119
- export async function runDoctor({ tower, ctx, agent, hostVersion, compatibilityFile, loaderRows }) {
139
+ export async function runDoctor({ tower, ctx, agent, hostVersion, compatibilityFile, loaderRows, surface }) {
120
140
  const checks = []
121
141
  let reportsReport = null
142
+ let stateLockReport = null
122
143
  const version = await gitVersion()
123
144
  checks.push(version ? check('git', 'pass', version) : check('git', 'fail', 'git is not available', 'install Git and retry'))
124
145
 
@@ -167,6 +188,19 @@ export async function runDoctor({ tower, ctx, agent, hostVersion, compatibilityF
167
188
  }
168
189
 
169
190
  checks.push(check('repository', 'pass', `${tower.root} (common ${tower.commonDir})`))
191
+ // The repo-scoped git mutex can legally be held for a whole merge (15 min by
192
+ // default). Report the holder so a stalled tower has a visible cause (#93).
193
+ const gitLock = gitLockView(await readGitLock(tower.paths))
194
+ checks.push(
195
+ gitLock.held
196
+ ? check(
197
+ 'git-lock',
198
+ 'warn',
199
+ `held by pid ${gitLock.pid ?? 'unknown'}${gitLock.phase ? ` (${gitLock.phase})` : ''} for ${durationText(gitLock.heldMs)}; the repo-scoped git mutex is busy`,
200
+ 'wait for the git mutation to finish; tower_status reports the same holder',
201
+ )
202
+ : check('git-lock', 'pass', 'free'),
203
+ )
170
204
  const schemaInfo = await inspectStateSchema(tower.paths)
171
205
  let state = null
172
206
  try {
@@ -226,6 +260,32 @@ export async function runDoctor({ tower, ctx, agent, hostVersion, compatibilityF
226
260
  'optional: add <git-common-dir>/dsh-tower/config.json',
227
261
  ),
228
262
  )
263
+ // #84: report the concept-budget surface instead of implying it. The counts
264
+ // are the live schema/tool surface; the date is the committed baseline, so a
265
+ // reader sees both the number and the decision it was frozen at.
266
+ const configCounts = configSurface()
267
+ const baseline = await readSurfaceBaseline()
268
+ const toolCount = Number.isInteger(surface?.tools) ? surface.tools : null
269
+ const surfaceCounts = {
270
+ configKeys: configCounts.count,
271
+ sections: configCounts.sections.length,
272
+ tools: toolCount,
273
+ }
274
+ const surfaceReport = {
275
+ ...surfaceCounts,
276
+ baselineDate: baseline?.date ?? null,
277
+ line: surfaceLine(surfaceCounts),
278
+ }
279
+ checks.push(
280
+ toolCount === null
281
+ ? check('surface', 'warn', `${surfaceReport.line} (tool count unavailable; pass the registered surface)`)
282
+ : check(
283
+ 'surface',
284
+ 'pass',
285
+ `${surfaceReport.line}${baseline?.date ? ` (baseline ${baseline.date})` : ''}`,
286
+ baseline?.date ? undefined : `commit ${SURFACE_BASELINE_FILE} so growth is compared against a decision`,
287
+ ),
288
+ )
229
289
  const presets = ctx.get?.('agentPresets')
230
290
  if (presets && typeof presets.list === 'function') {
231
291
  try {
@@ -251,12 +311,45 @@ export async function runDoctor({ tower, ctx, agent, hostVersion, compatibilityF
251
311
 
252
312
  if (state) {
253
313
  const lock = await readStateLock(tower.paths)
314
+ const timing = lockTiming(lock.record)
315
+ stateLockReport = {
316
+ held: Boolean(lock.record),
317
+ pid: lock.record?.pid ?? null,
318
+ startedAt: lock.record?.startedAt ?? null,
319
+ at: lock.record?.at ?? null,
320
+ alive: lock.alive,
321
+ ageMs: timing.ageMs,
322
+ heartbeatAgeMs: timing.heartbeatAgeMs,
323
+ staleAfterMs: timing.staleAfterMs,
324
+ stale: timing.stale,
325
+ }
326
+ // The PID alone cannot distinguish a live writer from a recycled PID
327
+ // (#92); report the heartbeat age so a stuck tower is diagnosable without
328
+ // reading the file.
329
+ const heartbeatText =
330
+ timing.heartbeatAgeMs === null
331
+ ? 'no heartbeat timestamp'
332
+ : `last heartbeat ${timing.heartbeatAgeMs}ms ago (stale after ${timing.staleAfterMs}ms)`
254
333
  checks.push(
255
334
  !lock.record
256
335
  ? check('state-lock', 'pass', 'no state lock file')
257
336
  : lock.alive === false
258
- ? check('state-lock', 'warn', `stale state lock (pid ${lock.record.pid ?? 'unknown'} is gone); the next mutation reclaims it atomically`)
259
- : check('state-lock', 'warn', `state lock held by pid ${lock.record.pid ?? 'unknown'}; retry after it finishes`),
337
+ ? check(
338
+ 'state-lock',
339
+ 'warn',
340
+ `stale state lock (pid ${lock.record.pid ?? 'unknown'} is gone; ${heartbeatText}); the next mutation reclaims it atomically`,
341
+ )
342
+ : timing.stale
343
+ ? check(
344
+ 'state-lock',
345
+ 'warn',
346
+ `state lock pid ${lock.record.pid ?? 'unknown'} is alive but silent (${heartbeatText}); the next mutation reclaims it after byte verification, or tower_recover clear-stale-lock force:true moves it to a backup`,
347
+ )
348
+ : check(
349
+ 'state-lock',
350
+ 'warn',
351
+ `state lock held by pid ${lock.record.pid ?? 'unknown'}; ${heartbeatText}; retry after it finishes`,
352
+ ),
260
353
  )
261
354
  const lockDir = path.join(tower.commonDir, 'dsh-tower', 'tower.lock')
262
355
  try {
@@ -342,7 +435,7 @@ export async function runDoctor({ tower, ctx, agent, hostVersion, compatibilityF
342
435
  }
343
436
 
344
437
  try {
345
- const { inspectLeases } = await import('./leases.js')
438
+ const { inspectLeases } = await import('../adapters/leases.js')
346
439
  const leases = await inspectLeases(tower.commonDir)
347
440
  checks.push(check('leases', leases.leases?.length ? 'warn' : 'pass', `${leases.leases?.length ?? 0} active lease(s)`))
348
441
  } catch (error) {
@@ -359,7 +452,18 @@ export async function runDoctor({ tower, ctx, agent, hostVersion, compatibilityF
359
452
 
360
453
  try {
361
454
  const text = await fsp.readFile(path.join(tower.paths.dir, 'lock-holds.jsonl'), 'utf8')
362
- const samples = text.split('\n').filter(Boolean).slice(-200).map((line) => JSON.parse(line).ms).filter((ms) => Number.isFinite(ms)).sort((a, b) => a - b)
455
+ // These percentiles are about the state lock. Git-mutex holds are logged
456
+ // too (kind: 'git' / the pre-#93 phase: 'git' form) and can legitimately
457
+ // span a merge, so they must not be counted as slow state mutations.
458
+ const samples = text
459
+ .split('\n')
460
+ .filter(Boolean)
461
+ .slice(-200)
462
+ .map((line) => JSON.parse(line))
463
+ .filter((hold) => hold.kind !== 'git' && hold.phase !== 'git')
464
+ .map((hold) => hold.ms)
465
+ .filter((ms) => Number.isFinite(ms))
466
+ .sort((a, b) => a - b)
363
467
  if (samples.length === 0) {
364
468
  checks.push(check('lock-holds', 'pass', 'no lock-hold samples yet'))
365
469
  } else {
@@ -410,9 +514,12 @@ export async function runDoctor({ tower, ctx, agent, hostVersion, compatibilityF
410
514
  return {
411
515
  ok: failed === 0,
412
516
  checks,
517
+ stateLock: stateLockReport,
518
+ gitLock,
413
519
  stateSchema: schemaReport.stateSchema,
414
520
  // reports (from #89) and archive (from #73) are both kept
415
521
  reports: reportsReport,
522
+ surface: surfaceReport,
416
523
  archive: {
417
524
  missions: state ? state.missions.length : null,
418
525
  archivedMissions: archiveInventory.missions,
@@ -6,7 +6,7 @@
6
6
  import { fs } from '../adapters/store-fs.js'
7
7
  import os from 'node:os'
8
8
  import path from 'node:path'
9
- import { acquireLockSync, readJsonSync, writeJsonAtomicSync } from './persistence.js'
9
+ import { acquireLockSync, readJsonSync, writeJsonAtomicSync } from '../adapters/persistence.js'
10
10
 
11
11
  const DEFAULT_MAX_ENTRIES = 200
12
12
 
@@ -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 { assertPresetsAvailable } 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,10 +50,12 @@ 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'
48
56
  import path from 'node:path'
49
57
  import { fsp } from '../adapters/store-fs.js'
58
+ import { createDefaultDeps } from '../adapters/default.js'
50
59
  import crypto from 'node:crypto'
51
60
 
52
61
  // Mutable composition state is grouped in one const object: the registration
@@ -54,6 +63,41 @@ import crypto from 'node:crypto'
54
63
  const runtimeState = {
55
64
  workspaceHook: null,
56
65
  hmrBridge: null,
66
+ // #69: the host entry builds the adapter set once and passes it in. The lazy
67
+ // fallback is for tests/embedders that call registerTowerTools() directly;
68
+ // it is still built once per process.
69
+ 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.
74
+ definedTools: new Set(),
75
+ }
76
+
77
+ /** The full defined tool surface (names, sorted), independent of exposure flags. */
78
+ export function definedToolNames() {
79
+ return [...runtimeState.definedTools].sort()
80
+ }
81
+
82
+ /** The number of tools defined by this build, used by the concept budget (#84). */
83
+ export function definedToolCount() {
84
+ return runtimeState.definedTools.size
85
+ }
86
+
87
+ /** The injected port set for the app layer (built once per process). */
88
+ function towerDeps() {
89
+ if (!runtimeState.deps) runtimeState.deps = createDefaultDeps()
90
+ return runtimeState.deps
91
+ }
92
+
93
+ /** Compose the app Tower with the injected ports (never a bare adapter import). */
94
+ function openTower(repoRoot) {
95
+ return Tower.open(repoRoot, towerDeps())
96
+ }
97
+
98
+ /** Build a Tower with an explicit config for the read-only doctor path. */
99
+ function createTower(repo, config) {
100
+ return new Tower(repo, config, towerDeps())
57
101
  }
58
102
 
59
103
  function normalizeToolArgs(args, aliases = {}) {
@@ -324,6 +368,9 @@ function plainSchema(value) {
324
368
  */
325
369
  function define(ctx, definition) {
326
370
  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)
327
374
  const toolsConfig = runtimeConfig().tools ?? {}
328
375
  const flag = OPERATIONAL_TOOLS.get(rest.name)
329
376
  if (flag && (toolsConfig.exposeOperational === false || toolsConfig[flag] === false)) return
@@ -364,7 +411,7 @@ function define(ctx, definition) {
364
411
  }
365
412
  if (board === true && typeof normalized.repoRoot === 'string') {
366
413
  try {
367
- const opened = await Tower.open(normalized.repoRoot)
414
+ const opened = await openTower(normalized.repoRoot)
368
415
  await opened.writeBoard()
369
416
  } catch {
370
417
  // board output is best effort
@@ -451,6 +498,24 @@ async function observeActivity(ctx, tower) {
451
498
  export function registerTowerTools(ctx, options = {}) {
452
499
  runtimeState.workspaceHook = options.workspace ?? null
453
500
  runtimeState.hmrBridge = options.hmrBridge ?? null
501
+ // #69: the host entry (index.js) is the composition root. When it supplies the
502
+ // port set, adopt it — validated once — instead of constructing a second set
503
+ // here. Tests/embedders that omit deps fall back to one lazily built set.
504
+ // #75: the event port is bound here to the live Cordis emitter; emitting is
505
+ // best effort so a listener can never fail the tool that produced the fact.
506
+ const baseDeps = options.deps ? assertPorts(options.deps) : (runtimeState.deps ?? createDefaultDeps())
507
+ runtimeState.deps = {
508
+ ...baseDeps,
509
+ events: {
510
+ emit(name, payload) {
511
+ try {
512
+ ctx.emit?.(name, payload)
513
+ } catch {
514
+ // events are an optional side channel; never fail a tool for them
515
+ }
516
+ },
517
+ },
518
+ }
454
519
  // A lightweight roster watchdog: the authoritative activity comes from the
455
520
  // subagents catalog; a missing observation after spawn.staleMs is marked
456
521
  // stale (never silently treated as stopped) and blocks merge via the gate.
@@ -460,7 +525,7 @@ export function registerTowerTools(ctx, options = {}) {
460
525
  try {
461
526
  if (!ctx.get('subagents')) return
462
527
  for (const repoRoot of [...WATCHED_REPOS]) {
463
- const tower = await Tower.open(repoRoot)
528
+ const tower = await openTower(repoRoot)
464
529
  const view = await tower.rosterView()
465
530
  const live = view.roster.filter((entry) => !ROSTER_INACTIVE_STATES.includes(entry.state))
466
531
  if (live.length === 0) { WATCHED_REPOS.delete(repoRoot); continue }
@@ -481,59 +546,69 @@ export function registerTowerTools(ctx, options = {}) {
481
546
  }, watchMs)
482
547
  timer.unref?.()
483
548
  ctx.effect(() => () => clearInterval(timer))
484
- registerInitTool(ctx, { define, actorFor, requireTowerActor, workspaceHook: runtimeState.workspaceHook, ensureRepository })
549
+ registerInitTool(ctx, { openTower, define, actorFor, requireTowerActor, workspaceHook: runtimeState.workspaceHook, ensureRepository, resolveRepo, runtimeConfig })
485
550
 
486
- registerPlanTool(ctx, { define, actorFor, requireTowerActor })
551
+ registerPlanTool(ctx, { openTower, define, actorFor, requireTowerActor, assertPresetsAvailable })
487
552
 
488
- registerMissionTool(ctx, { define, actorFor, requireKnownActor })
553
+ registerMissionTool(ctx, { openTower, define, actorFor, requireKnownActor })
489
554
 
490
- registerStatusTool(ctx, { define, workspaceHook: runtimeState.workspaceHook, spawnBackoffView, observeActivity })
555
+ registerStatusTool(ctx, { openTower, define, workspaceHook: runtimeState.workspaceHook, spawnBackoffView, observeActivity })
491
556
 
492
- registerSpawnTool(ctx, { define, actorFor, requireTowerActor, enqueueSpawn, observeActivity, spawnBackoffState, spawnBackoffView, recordSpawnThrottle, recordSpawnSuccess, runMissionDiscovery, textOf })
557
+ registerSpawnTool(ctx, { openTower, define, actorFor, requireTowerActor, enqueueSpawn, observeActivity, spawnBackoffState, spawnBackoffView, recordSpawnThrottle, recordSpawnSuccess, runMissionDiscovery, textOf, inspectLeases })
493
558
 
494
- registerRosterTool(ctx, { define, workspaceHook: runtimeState.workspaceHook, observeActivity, actorFor, requireTowerActor, interruptRosterAgent })
559
+ registerRosterTool(ctx, { openTower, define, workspaceHook: runtimeState.workspaceHook, observeActivity, actorFor, requireTowerActor, interruptRosterAgent })
495
560
 
496
- registerInterruptTool(ctx, { define, actorFor, requireTowerActor, interruptRosterAgent })
561
+ registerInterruptTool(ctx, { openTower, define, actorFor, requireTowerActor, interruptRosterAgent })
497
562
 
498
- registerDiffTool(ctx, { define, actorFor, requireTowerActor })
563
+ registerDiffTool(ctx, { openTower, define, actorFor, requireTowerActor })
499
564
 
500
- registerAdoptTool(ctx, { define, actorFor, requireTowerActor })
565
+ registerAdoptTool(ctx, { openTower, define, actorFor, requireTowerActor })
501
566
 
502
- registerSendTool(ctx, { define, actorFor, checkSendRate })
567
+ registerSendTool(ctx, { openTower, define, actorFor, checkSendRate })
503
568
 
504
- registerInboxTool(ctx, { define })
569
+ registerInboxTool(ctx, { openTower, define })
505
570
 
506
- registerCompleteTool(ctx, { define, runMissionDiscovery })
571
+ registerCompleteTool(ctx, { openTower, define, runMissionDiscovery })
507
572
 
508
- registerFindingTool(ctx, { define, actorFor, requireKnownActor })
573
+ registerFindingTool(ctx, { openTower, define, actorFor, requireKnownActor })
509
574
 
510
- registerFindingsTool(ctx, { define, actorFor, requireKnownActor })
575
+ registerFindingsTool(ctx, { openTower, define, actorFor, requireKnownActor })
511
576
 
512
- registerReviewTool(ctx, { define, actorFor })
577
+ registerReviewTool(ctx, { openTower, define, actorFor })
513
578
 
514
- registerPreflightTool(ctx, { define, observeActivity })
579
+ registerPreflightTool(ctx, { openTower, define, observeActivity })
515
580
 
516
- registerMergeTool(ctx, { define, actorFor, requireTowerActor, observeActivity })
581
+ registerMergeTool(ctx, { openTower, define, actorFor, requireTowerActor, observeActivity })
517
582
 
518
- registerRebaseTool(ctx, { define, actorFor, requireTowerActor, observeActivity })
583
+ registerRebaseTool(ctx, { openTower, define, actorFor, requireTowerActor, observeActivity })
519
584
 
520
- registerTeardownTool(ctx, { define, actorFor, requireTowerActor, observeActivity })
585
+ registerTeardownTool(ctx, { openTower, define, actorFor, requireTowerActor, observeActivity })
521
586
 
522
- registerDoctorTool(ctx, define)
587
+ registerDoctorTool(ctx, {
588
+ openTower,
589
+ define,
590
+ createTower,
591
+ resolveRepo,
592
+ runtimeConfig,
593
+ 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.
596
+ surface: () => ({ tools: definedToolCount() }),
597
+ })
523
598
 
524
- registerRecoverTool(ctx, { define, actorFor, requireTowerActor })
599
+ registerRecoverTool(ctx, { openTower, define, actorFor, requireTowerActor, readStateLock, lockTiming })
525
600
 
526
- registerTestTool(ctx, { define, actorFor, requireKnownActor, runMissionDiscovery })
601
+ registerTestTool(ctx, { openTower, define, actorFor, requireKnownActor, runMissionDiscovery })
527
602
 
528
- registerBatchTool(ctx, { define, actorFor, requireTowerActor, summarizeBatch, verifyBatchReceiptFiles })
603
+ registerBatchTool(ctx, { openTower, define, actorFor, requireTowerActor, summarizeBatch, verifyBatchReceiptFiles })
529
604
 
530
- registerResourceTool(ctx, { define, actorFor, requireTowerActor })
605
+ registerResourceTool(ctx, { openTower, define, actorFor, requireTowerActor, acquireLease, releaseLease, inspectLeases })
531
606
 
532
- registerImportLegacyTool(ctx, { define, actorFor, requireTowerActor })
607
+ registerImportLegacyTool(ctx, { openTower, define, actorFor, requireTowerActor, importLegacySnapshot })
533
608
 
534
- registerLegacyMapTool(ctx, { define, actorFor, requireTowerActor })
609
+ registerLegacyMapTool(ctx, { openTower, define, actorFor, requireTowerActor, validateLegacyMap })
535
610
 
536
- registerReloadTool(ctx, { define, actorFor, requireTowerActor, sharedHmrBridge: runtimeState.hmrBridge })
611
+ registerReloadTool(ctx, { openTower, define, actorFor, requireTowerActor, sharedHmrBridge: runtimeState.hmrBridge, runtimeConfig, createHmrBridge })
537
612
  }
538
613
 
539
614
  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 { registerTowerTools } 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 { createHmrBridge } from './host/hmr.js'
30
+ import { assertPresetsAvailable } from './host/presets.js'
30
31
 
31
32
  export const name = 'dsh-tower'
32
33
  export const inject = ['tools', 'systemPrompt']
@@ -343,7 +344,9 @@ export function apply(ctx, rawConfig) {
343
344
  },
344
345
  }
345
346
 
346
- registerTowerTools(ctx, { workspace, hmrBridge })
347
+ // #69: the host entry is the composition root — the adapters are constructed
348
+ // once here and injected into the tool layer and every Tower instance.
349
+ registerTowerTools(ctx, { workspace, hmrBridge, deps: createDefaultDeps() })
347
350
 
348
351
  // On dispose (including an HMR reload) release advisory workspace locks this
349
352
  // process still owns; a reloaded plugin restores the entries from modes.json.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-tower",
3
- "version": "2.1.0",
3
+ "version": "2.3.0",
4
4
  "type": "module",
5
5
  "description": "DSH-native Tower: mission planning, worktree isolation, review-gated merges, and a human-owned Tower mode.",
6
6
  "license": "MIT",
@@ -41,7 +41,7 @@
41
41
  "reviewer.js",
42
42
  "skills.js",
43
43
  "skills/",
44
- "lib/*.js",
44
+ "host/*.js",
45
45
  "domain/**/*.js",
46
46
  "app/*.js",
47
47
  "tools/*.js",
@@ -58,6 +58,7 @@
58
58
  "LICENSE",
59
59
  "CONTRIBUTING.md",
60
60
  "SECURITY.md",
61
+ "PRINCIPLES.md",
61
62
  "docs/",
62
63
  "ui-bundle/",
63
64
  "ports/*.js"
@@ -71,22 +72,28 @@
71
72
  "test": "node --test tests/*.test.mjs",
72
73
  "test:watch": "node --test --watch tests/*.test.mjs",
73
74
  "test:coverage": "node --test --experimental-test-coverage tests/*.test.mjs",
75
+ "test:live": "node tests/live/run.cjs",
74
76
  "smoke:mount": "node scripts/mount-smoke.mjs",
75
77
  "smoke:fixtures": "node scripts/mount-smoke/fixtures.mjs",
76
78
  "release:check": "node scripts/release-check.mjs",
77
79
  "audit:issues": "node scripts/issue-audit.mjs",
80
+ "audit:regressions": "node scripts/regression-audit.mjs",
81
+ "surface:check": "node scripts/surface-baseline.mjs",
78
82
  "dev:hmr": "node scripts/enable-dev-hmr.mjs",
79
83
  "docs:check": "node scripts/check-docs.mjs",
84
+ "docs:check:philosophy": "node scripts/check-philosophy.mjs",
80
85
  "docs:generate:errors": "node scripts/generate-error-reference.mjs",
81
86
  "docs:check:generated": "node scripts/generate-error-reference.mjs --check",
82
87
  "docs:generate:tools": "node scripts/generate-tools-reference.mjs",
83
88
  "docs:check:generated:tools": "node scripts/generate-tools-reference.mjs --check",
84
89
  "docs:generate:config": "node scripts/generate-config-reference.mjs",
85
90
  "docs:check:generated:config": "node scripts/generate-config-reference.mjs --check",
91
+ "docs:generate:events": "node scripts/generate-events-reference.mjs",
92
+ "docs:check:generated:events": "node scripts/generate-events-reference.mjs --check",
86
93
  "pack:check": "node scripts/check-pack.mjs",
87
94
  "architecture:check": "node scripts/architecture.mjs",
88
95
  "types:check": "tsc --noEmit",
89
- "check": "npm test && npm run docs:check && npm run pack:check && npm run architecture:check && npm run types:check && npm run domain:check",
96
+ "check": "npm test && npm run docs:check && npm run docs:check:philosophy && npm run docs:check:generated && npm run docs:check:generated:tools && npm run docs:check:generated:config && npm run docs:check:generated:events && npm run surface:check && npm run pack:check && npm run architecture:check && npm run types:check && npm run domain:check",
90
97
  "domain:metrics": "node scripts/domain-metrics.mjs",
91
98
  "domain:check": "node scripts/domain-metrics.mjs --check"
92
99
  },
@@ -0,0 +1,18 @@
1
+ // Terminal-mission archive port (#69). The readers/writers are store-agnostic:
2
+ // they take the store port and the Tower paths, so the same archive logic runs
3
+ // on the filesystem adapter and on an in-memory store.
4
+ import { assertPort, definePort } from './contract.js'
5
+
6
+ /**
7
+ * @typedef {object} ArchivePort
8
+ * @property {(store: unknown, paths: { dir: string }) => Promise<{ ok: boolean, file: string, missions: any[], error?: string, exists: boolean }>} readIndex
9
+ * @property {(store: unknown, paths: { dir: string }, missionId: string) => Promise<any>} readEntry
10
+ * @property {(store: unknown, paths: { dir: string }, input: { entries: any[], now?: number }) => Promise<any>} writeEntries
11
+ */
12
+
13
+ export const ARCHIVE_PORT = definePort('ArchivePort', ['readIndex', 'readEntry', 'writeEntries'])
14
+
15
+ /** @param {unknown} impl */
16
+ export function assertArchivePort(impl) {
17
+ return /** @type {ArchivePort} */ (assertPort(impl, ARCHIVE_PORT, 'archive'))
18
+ }
@@ -0,0 +1,15 @@
1
+ // Case-sensitivity probe port (#69). tower_init probes the checkout filesystem
2
+ // once; a composition without a real filesystem injects its own answer.
3
+ import { assertPort, definePort } from './contract.js'
4
+
5
+ /**
6
+ * @typedef {object} CaseSensitivityPort
7
+ * @property {(root: string) => Promise<boolean | null>} detect
8
+ */
9
+
10
+ export const CASE_SENSITIVITY_PORT = definePort('CaseSensitivityPort', ['detect'])
11
+
12
+ /** @param {unknown} impl */
13
+ export function assertCaseSensitivityPort(impl) {
14
+ return /** @type {CaseSensitivityPort} */ (assertPort(impl, CASE_SENSITIVITY_PORT, 'caseSensitivity'))
15
+ }
package/ports/clock.js CHANGED
@@ -1,6 +1,14 @@
1
- // Clock port (#56).
1
+ // Clock port (#69).
2
+ import { assertPort, definePort } from './contract.js'
3
+
2
4
  /**
3
5
  * @typedef {object} ClockPort
4
6
  * @property {() => number} now
5
7
  */
6
- export {}
8
+
9
+ export const CLOCK_PORT = definePort('ClockPort', ['now'])
10
+
11
+ /** @param {unknown} impl */
12
+ export function assertClockPort(impl) {
13
+ return /** @type {ClockPort} */ (assertPort(impl, CLOCK_PORT, 'clock'))
14
+ }
@@ -0,0 +1,19 @@
1
+ // Config port (#69).
2
+ //
3
+ // App use cases read the effective Tower config through this port. They do not
4
+ // import host/config.js, so the process-level config store stays a property of
5
+ // the composition root rather than a module-global dependency of the app.
6
+ import { assertPort, definePort } from './contract.js'
7
+
8
+ /**
9
+ * @typedef {object} ConfigPort
10
+ * @property {() => import('../types/index.js').TowerConfig} runtimeConfig
11
+ * @property {(repo: { root: string, commonDir: string, branch?: string|null }) => Promise<import('../types/index.js').TowerConfig>} resolveRepoConfig
12
+ */
13
+
14
+ export const CONFIG_PORT = definePort('ConfigPort', ['runtimeConfig', 'resolveRepoConfig'])
15
+
16
+ /** @param {unknown} impl */
17
+ export function assertConfigPort(impl) {
18
+ return /** @type {ConfigPort} */ (assertPort(impl, CONFIG_PORT, 'config'))
19
+ }