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
@@ -2,19 +2,34 @@
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'
20
+ import { componentReport, componentSummary } from './capability-registry.js'
16
21
 
17
22
  const execFileAsync = promisify(execFile)
23
+ const PACKAGE_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
24
+
25
+ /** Read the committed concept-budget baseline read-only; null when absent. */
26
+ async function readSurfaceBaseline() {
27
+ try {
28
+ return parseBaseline(await fsp.readFile(path.join(PACKAGE_ROOT, SURFACE_BASELINE_FILE), 'utf8'))
29
+ } catch {
30
+ return null
31
+ }
32
+ }
18
33
 
19
34
  async function exists(target) {
20
35
  try {
@@ -38,6 +53,12 @@ function check(id, status, detail, action) {
38
53
  return { id, status, detail, ...(action ? { action } : {}) }
39
54
  }
40
55
 
56
+ /** Render a lock age the same way the state-lock checker does. */
57
+ function durationText(ms) {
58
+ if (!Number.isFinite(ms)) return 'an unknown duration'
59
+ return `${Math.max(0, Math.round(ms / 1000))} s`
60
+ }
61
+
41
62
  /** Read the on-disk state version without loading, migrating, or locking. */
42
63
  async function inspectStateSchema(paths) {
43
64
  try {
@@ -116,9 +137,10 @@ function stateSchemaReport(info) {
116
137
  }
117
138
 
118
139
  /** Run every read-only diagnostic; never writes state or takes the state lock. */
119
- export async function runDoctor({ tower, ctx, agent, hostVersion, compatibilityFile, loaderRows }) {
140
+ export async function runDoctor({ tower, ctx, agent, hostVersion, compatibilityFile, loaderRows, surface }) {
120
141
  const checks = []
121
142
  let reportsReport = null
143
+ let stateLockReport = null
122
144
  const version = await gitVersion()
123
145
  checks.push(version ? check('git', 'pass', version) : check('git', 'fail', 'git is not available', 'install Git and retry'))
124
146
 
@@ -167,6 +189,19 @@ export async function runDoctor({ tower, ctx, agent, hostVersion, compatibilityF
167
189
  }
168
190
 
169
191
  checks.push(check('repository', 'pass', `${tower.root} (common ${tower.commonDir})`))
192
+ // The repo-scoped git mutex can legally be held for a whole merge (15 min by
193
+ // default). Report the holder so a stalled tower has a visible cause (#93).
194
+ const gitLock = gitLockView(await readGitLock(tower.paths))
195
+ checks.push(
196
+ gitLock.held
197
+ ? check(
198
+ 'git-lock',
199
+ 'warn',
200
+ `held by pid ${gitLock.pid ?? 'unknown'}${gitLock.phase ? ` (${gitLock.phase})` : ''} for ${durationText(gitLock.heldMs)}; the repo-scoped git mutex is busy`,
201
+ 'wait for the git mutation to finish; tower_status reports the same holder',
202
+ )
203
+ : check('git-lock', 'pass', 'free'),
204
+ )
170
205
  const schemaInfo = await inspectStateSchema(tower.paths)
171
206
  let state = null
172
207
  try {
@@ -226,17 +261,59 @@ export async function runDoctor({ tower, ctx, agent, hostVersion, compatibilityF
226
261
  'optional: add <git-common-dir>/dsh-tower/config.json',
227
262
  ),
228
263
  )
264
+ // #84: report the concept-budget surface instead of implying it. The counts
265
+ // are the live schema/tool surface; the date is the committed baseline, so a
266
+ // reader sees both the number and the decision it was frozen at.
267
+ const configCounts = configSurface()
268
+ const baseline = await readSurfaceBaseline()
269
+ const toolCount = Number.isInteger(surface?.tools) ? surface.tools : null
270
+ const surfaceCounts = {
271
+ configKeys: configCounts.count,
272
+ sections: configCounts.sections.length,
273
+ tools: toolCount,
274
+ }
275
+ const surfaceReport = {
276
+ ...surfaceCounts,
277
+ baselineDate: baseline?.date ?? null,
278
+ line: surfaceLine(surfaceCounts),
279
+ }
280
+ checks.push(
281
+ toolCount === null
282
+ ? check('surface', 'warn', `${surfaceReport.line} (tool count unavailable; pass the registered surface)`)
283
+ : check(
284
+ 'surface',
285
+ 'pass',
286
+ `${surfaceReport.line}${baseline?.date ? ` (baseline ${baseline.date})` : ''}`,
287
+ baseline?.date ? undefined : `commit ${SURFACE_BASELINE_FILE} so growth is compared against a decision`,
288
+ ),
289
+ )
229
290
  const presets = ctx.get?.('agentPresets')
291
+ const reviewerRequired = config.review?.requireReview === true
230
292
  if (presets && typeof presets.list === 'function') {
231
293
  try {
232
294
  const available = (await presets.list()).map((entry) => String(entry.id))
233
- const missing = [config.spawn?.workerPreset ?? 'tower-worker', config.spawn?.reviewerPreset ?? 'tower-reviewer']
234
- .filter((id) => !available.includes(id))
235
- checks.push(
236
- missing.length === 0
237
- ? check('presets', 'pass', `worker=${config.spawn?.workerPreset ?? 'tower-worker'} reviewer=${config.spawn?.reviewerPreset ?? 'tower-reviewer'}`)
238
- : check('presets', 'fail', `missing preset(s): ${missing.join(', ')}`, 'enable the Tower preset bundle and retry'),
239
- )
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
+ }
240
317
  } catch (error) {
241
318
  checks.push(check('presets', 'warn', `preset registry unavailable: ${String(error?.message ?? error)}`))
242
319
  }
@@ -244,6 +321,16 @@ export async function runDoctor({ tower, ctx, agent, hostVersion, compatibilityF
244
321
  checks.push(check('presets', 'warn', 'agentPresets service is unavailable'))
245
322
  }
246
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
+
247
334
  const subagents = ctx.get?.('subagents')
248
335
  checks.push(subagents ? check('subagents', 'pass', 'service mounted') : check('subagents', 'fail', 'subagents service is unavailable', 'enable the subagent bundle'))
249
336
  const hmr = ctx.get?.('hmr')
@@ -251,12 +338,45 @@ export async function runDoctor({ tower, ctx, agent, hostVersion, compatibilityF
251
338
 
252
339
  if (state) {
253
340
  const lock = await readStateLock(tower.paths)
341
+ const timing = lockTiming(lock.record)
342
+ stateLockReport = {
343
+ held: Boolean(lock.record),
344
+ pid: lock.record?.pid ?? null,
345
+ startedAt: lock.record?.startedAt ?? null,
346
+ at: lock.record?.at ?? null,
347
+ alive: lock.alive,
348
+ ageMs: timing.ageMs,
349
+ heartbeatAgeMs: timing.heartbeatAgeMs,
350
+ staleAfterMs: timing.staleAfterMs,
351
+ stale: timing.stale,
352
+ }
353
+ // The PID alone cannot distinguish a live writer from a recycled PID
354
+ // (#92); report the heartbeat age so a stuck tower is diagnosable without
355
+ // reading the file.
356
+ const heartbeatText =
357
+ timing.heartbeatAgeMs === null
358
+ ? 'no heartbeat timestamp'
359
+ : `last heartbeat ${timing.heartbeatAgeMs}ms ago (stale after ${timing.staleAfterMs}ms)`
254
360
  checks.push(
255
361
  !lock.record
256
362
  ? check('state-lock', 'pass', 'no state lock file')
257
363
  : 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`),
364
+ ? check(
365
+ 'state-lock',
366
+ 'warn',
367
+ `stale state lock (pid ${lock.record.pid ?? 'unknown'} is gone; ${heartbeatText}); the next mutation reclaims it atomically`,
368
+ )
369
+ : timing.stale
370
+ ? check(
371
+ 'state-lock',
372
+ 'warn',
373
+ `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`,
374
+ )
375
+ : check(
376
+ 'state-lock',
377
+ 'warn',
378
+ `state lock held by pid ${lock.record.pid ?? 'unknown'}; ${heartbeatText}; retry after it finishes`,
379
+ ),
260
380
  )
261
381
  const lockDir = path.join(tower.commonDir, 'dsh-tower', 'tower.lock')
262
382
  try {
@@ -306,11 +426,15 @@ export async function runDoctor({ tower, ctx, agent, hostVersion, compatibilityF
306
426
  )
307
427
 
308
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')
309
431
  checks.push(
310
432
  check(
311
433
  'tests',
312
- config.tests?.enabled === false ? 'warn' : 'pass',
313
- 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' : ''}`,
314
438
  ),
315
439
  )
316
440
  checks.push(check('legacy', 'pass', `${state.legacySnapshots?.length ?? 0} legacy snapshot(s)`))
@@ -342,7 +466,7 @@ export async function runDoctor({ tower, ctx, agent, hostVersion, compatibilityF
342
466
  }
343
467
 
344
468
  try {
345
- const { inspectLeases } = await import('./leases.js')
469
+ const { inspectLeases } = await import('../adapters/leases.js')
346
470
  const leases = await inspectLeases(tower.commonDir)
347
471
  checks.push(check('leases', leases.leases?.length ? 'warn' : 'pass', `${leases.leases?.length ?? 0} active lease(s)`))
348
472
  } catch (error) {
@@ -359,7 +483,18 @@ export async function runDoctor({ tower, ctx, agent, hostVersion, compatibilityF
359
483
 
360
484
  try {
361
485
  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)
486
+ // These percentiles are about the state lock. Git-mutex holds are logged
487
+ // too (kind: 'git' / the pre-#93 phase: 'git' form) and can legitimately
488
+ // span a merge, so they must not be counted as slow state mutations.
489
+ const samples = text
490
+ .split('\n')
491
+ .filter(Boolean)
492
+ .slice(-200)
493
+ .map((line) => JSON.parse(line))
494
+ .filter((hold) => hold.kind !== 'git' && hold.phase !== 'git')
495
+ .map((hold) => hold.ms)
496
+ .filter((ms) => Number.isFinite(ms))
497
+ .sort((a, b) => a - b)
363
498
  if (samples.length === 0) {
364
499
  checks.push(check('lock-holds', 'pass', 'no lock-hold samples yet'))
365
500
  } else {
@@ -410,9 +545,13 @@ export async function runDoctor({ tower, ctx, agent, hostVersion, compatibilityF
410
545
  return {
411
546
  ok: failed === 0,
412
547
  checks,
548
+ stateLock: stateLockReport,
549
+ gitLock,
413
550
  stateSchema: schemaReport.stateSchema,
414
551
  // reports (from #89) and archive (from #73) are both kept
415
552
  reports: reportsReport,
553
+ surface: surfaceReport,
554
+ components,
416
555
  archive: {
417
556
  missions: state ? state.missions.length : null,
418
557
  archivedMissions: archiveInventory.missions,
@@ -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). */
@@ -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
 
@@ -0,0 +1,77 @@
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.
16
+ import { TowerToolError } from '../domain/errors.js'
17
+ import { componentEnabled, disabledReason } from './capability-registry.js'
18
+
19
+ export function presetIds(config = {}) {
20
+ return {
21
+ worker: String(config.spawn?.workerPreset ?? 'tower-worker'),
22
+ reviewer: String(config.spawn?.reviewerPreset ?? 'tower-reviewer'),
23
+ }
24
+ }
25
+
26
+ function rowId(preset) {
27
+ return preset === 'tower-worker' ? 'preset-tower-worker' : preset === 'tower-reviewer' ? 'preset-tower-reviewer' : preset
28
+ }
29
+
30
+ /**
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
33
+ * registered. If the service is unavailable, doctor reports that separately and
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.
38
+ */
39
+ export async function assertPresetsAvailable(ctx, config = {}) {
40
+ const service = ctx?.get?.('agentPresets')
41
+ if (!service || typeof service.list !== 'function') return { checked: false, missing: [] }
42
+ const presets = presetIds(config)
43
+ const required = [presets.worker]
44
+ if (config.review?.requireReview === true) required.push(presets.reviewer)
45
+ const available = new Set((await service.list()).map((entry) => String(entry.id)))
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')) {
71
+ throw new TowerToolError(
72
+ 'config-invalid',
73
+ `gates.requireTests is true but ${disabledReason('tower-tests')}; re-enable the tower-tests component or set gates.requireTests:false`,
74
+ )
75
+ }
76
+ return presets
77
+ }