dsh-tower 2.1.0-beta.1 → 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 (188) hide show
  1. package/CHANGELOG.md +154 -0
  2. package/COMPATIBILITY.md +6 -1
  3. package/CONTRIBUTING.md +71 -5
  4. package/PRINCIPLES.md +145 -0
  5. package/README.en.md +14 -5
  6. package/README.md +14 -5
  7. package/SECURITY.md +6 -0
  8. package/adapters/archive.js +279 -0
  9. package/adapters/caseSensitivity.js +66 -0
  10. package/adapters/default.js +51 -8
  11. package/{lib → adapters}/git.js +1 -1
  12. package/{lib → adapters}/leases.js +15 -6
  13. package/{lib → adapters}/legacy.js +20 -6
  14. package/adapters/persistence.js +543 -0
  15. package/adapters/stateFile.js +99 -0
  16. package/adapters/store-fs.js +28 -1
  17. package/adapters/store.js +204 -0
  18. package/{lib → adapters}/tests.js +15 -7
  19. package/adapters/vcs-git.js +15 -1
  20. package/{lib → adapters}/workspaceLock.js +1 -1
  21. package/{lib → adapters}/worktreeOwnership.js +18 -7
  22. package/app/archive.js +188 -0
  23. package/app/batch.js +9 -10
  24. package/app/board.js +4 -4
  25. package/app/checkCompletion.js +23 -11
  26. package/app/enrichedMissions.js +2 -4
  27. package/app/findings.js +6 -7
  28. package/app/gate.js +32 -15
  29. package/app/init.js +59 -22
  30. package/app/merge.js +64 -51
  31. package/app/messages.js +7 -8
  32. package/app/mission.js +7 -5
  33. package/app/plan.js +20 -9
  34. package/app/provision.js +12 -14
  35. package/app/read.js +65 -6
  36. package/app/rebase.js +14 -16
  37. package/app/recording.js +7 -8
  38. package/app/review.js +17 -8
  39. package/app/roster.js +14 -15
  40. package/{lib → app}/spawn-prechecks.js +4 -3
  41. package/app/spawn.js +12 -13
  42. package/app/teardown.js +28 -8
  43. package/app/tower.js +121 -39
  44. package/docs/README.md +6 -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 +50 -0
  49. package/docs/adr/README.md +51 -0
  50. package/docs/agents/domain.md +38 -0
  51. package/docs/agents/issue-tracker.md +46 -0
  52. package/docs/agents/triage-labels.md +15 -0
  53. package/docs/agents/workflow.md +51 -0
  54. package/docs/architecture/overview.md +170 -37
  55. package/docs/guide/hot-reload.md +1 -1
  56. package/docs/guide/install-upgrade-migration.md +18 -1
  57. package/docs/guide/release.md +1 -1
  58. package/docs/operations/incident.md +82 -0
  59. package/docs/operations/runbook.md +12 -3
  60. package/docs/reference/blockers.md +3 -3
  61. package/docs/reference/config.generated.md +5 -0
  62. package/docs/reference/config.md +4 -0
  63. package/docs/reference/doctor.md +104 -8
  64. package/docs/reference/errors.generated.md +41 -35
  65. package/docs/reference/errors.md +54 -40
  66. package/docs/reference/events.md +38 -6
  67. package/docs/reference/surface-budget.md +128 -0
  68. package/docs/reference/tools.generated.md +9 -8
  69. package/docs/reference/tools.md +9 -8
  70. package/docs/zh/guide/hot-reload.md +1 -1
  71. package/docs/zh/guide/install-upgrade-migration.md +13 -1
  72. package/docs/zh/reference/blockers.md +3 -3
  73. package/docs/zh/reference/config.md +4 -0
  74. package/docs/zh/reference/doctor.md +63 -2
  75. package/docs/zh/reference/errors.md +4 -2
  76. package/docs/zh/reference/tools.md +6 -6
  77. package/domain/batch.js +2 -2
  78. package/domain/config/schema.js +51 -4
  79. package/domain/contracts/outputs.js +33 -3
  80. package/domain/contracts/tool-inputs/missions.js +2 -1
  81. package/domain/contracts/tool-inputs/ops.js +1 -0
  82. package/domain/errors.js +297 -40
  83. package/domain/events.js +207 -0
  84. package/domain/integration/gate.js +41 -12
  85. package/domain/integration/init.js +40 -4
  86. package/domain/integration/merge.js +35 -0
  87. package/domain/lockBudget.js +30 -0
  88. package/domain/mission.js +1 -1
  89. package/domain/missions/completion.js +72 -5
  90. package/domain/missions/lifecycle.js +11 -4
  91. package/domain/missions/plan.js +14 -0
  92. package/domain/missions/review.js +90 -7
  93. package/domain/planning.js +6 -3
  94. package/domain/read/views.js +17 -1
  95. package/domain/report.js +9 -1
  96. package/domain/scope.js +83 -11
  97. package/domain/state/archive.js +257 -0
  98. package/domain/state/migrations.js +145 -0
  99. package/domain/state/schema.js +6 -0
  100. package/domain/state.js +154 -4
  101. package/domain/summary.js +2 -0
  102. package/domain/surface.js +248 -0
  103. package/host/doctor.js +555 -0
  104. package/{lib → host}/modeStore.js +1 -1
  105. package/{lib → host}/tool-registry.js +106 -31
  106. package/host/tower.js +32 -0
  107. package/index.js +12 -9
  108. package/package.json +10 -3
  109. package/ports/archive.js +18 -0
  110. package/ports/caseSensitivity.js +15 -0
  111. package/ports/clock.js +10 -2
  112. package/ports/config.js +19 -0
  113. package/ports/contract.js +38 -0
  114. package/ports/events.js +24 -0
  115. package/ports/ids.js +10 -2
  116. package/ports/index.js +54 -2
  117. package/ports/ownership.js +17 -0
  118. package/ports/state.js +16 -0
  119. package/ports/store.js +52 -3
  120. package/ports/vcs.js +25 -4
  121. package/scripts/architecture-waivers.json +19 -0
  122. package/scripts/architecture.mjs +634 -23
  123. package/scripts/check-docs.mjs +8 -35
  124. package/scripts/check-pack.mjs +68 -39
  125. package/scripts/check-philosophy.mjs +266 -0
  126. package/scripts/domain-metrics.mjs +13 -4
  127. package/scripts/generate-config-reference.mjs +14 -1
  128. package/scripts/generate-error-reference.mjs +68 -14
  129. package/scripts/generate-events-reference.mjs +83 -0
  130. package/scripts/generate-tools-reference.mjs +11 -0
  131. package/scripts/issue-audit.mjs +2 -2
  132. package/scripts/lib/surface.mjs +87 -0
  133. package/scripts/pack-manifest.txt +64 -19
  134. package/scripts/regression-audit.mjs +164 -0
  135. package/scripts/release-check.mjs +1 -1
  136. package/scripts/surface-baseline.json +21 -0
  137. package/scripts/surface-baseline.mjs +120 -0
  138. package/tools/adopt.js +2 -3
  139. package/tools/batch.js +2 -3
  140. package/tools/complete.js +3 -4
  141. package/tools/diff.js +3 -3
  142. package/tools/doctor.js +7 -10
  143. package/tools/finding.js +2 -3
  144. package/tools/findings.js +3 -3
  145. package/tools/import-legacy.js +3 -5
  146. package/tools/inbox.js +3 -3
  147. package/tools/index.js +7 -7
  148. package/tools/init.js +4 -7
  149. package/tools/interrupt.js +2 -3
  150. package/tools/legacy-map.js +3 -5
  151. package/tools/merge.js +3 -3
  152. package/tools/mission.js +2 -3
  153. package/tools/plan.js +7 -6
  154. package/tools/preflight.js +3 -3
  155. package/tools/rebase.js +3 -3
  156. package/tools/recover.js +38 -13
  157. package/tools/reload.js +3 -6
  158. package/tools/resource.js +3 -5
  159. package/tools/review.js +3 -4
  160. package/tools/roster.js +2 -3
  161. package/tools/send.js +2 -2
  162. package/tools/spawn.js +8 -5
  163. package/tools/status.js +16 -8
  164. package/tools/teardown.js +3 -4
  165. package/tools/test.js +2 -2
  166. package/types/index.d.ts +35 -1
  167. package/ui-bundle/README.md +37 -3
  168. package/ui-bundle/client.cards.js +356 -0
  169. package/ui-bundle/client.denied.js +142 -0
  170. package/ui-bundle/client.dock.js +333 -0
  171. package/ui-bundle/client.helpers.js +271 -0
  172. package/ui-bundle/client.js +79 -1747
  173. package/ui-bundle/client.locales.js +247 -0
  174. package/ui-bundle/client.mode.js +206 -0
  175. package/ui-bundle/client.overlay.js +64 -0
  176. package/ui-bundle/client.styles.js +151 -0
  177. package/ui-bundle/client.toolview.js +82 -0
  178. package/ui-bundle/package.json +1 -0
  179. package/lib/doctor.js +0 -270
  180. package/lib/persistence.js +0 -349
  181. package/lib/store.js +0 -118
  182. /package/{lib → app}/briefing.js +0 -0
  183. /package/{lib → app}/spawn-support.js +0 -0
  184. /package/{lib → host}/compatibility.js +0 -0
  185. /package/{lib → host}/config.js +0 -0
  186. /package/{lib → host}/hmr.js +0 -0
  187. /package/{lib → host}/modeState.js +0 -0
  188. /package/{lib → host}/presets.js +0 -0
package/host/doctor.js ADDED
@@ -0,0 +1,555 @@
1
+ // Read-only environment contract and health checks for a Tower repository.
2
+ import { execFile } from '../adapters/vcs-git.js'
3
+ import { promisify } from 'node:util'
4
+ import path from 'node:path'
5
+ import { fileURLToPath } from 'node:url'
6
+ import { fsp } from '../adapters/store-fs.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'
11
+ import { inspectStateVersion } from '../domain/state.js'
12
+ import { SCHEMA_VERSION } from '../domain/mission.js'
13
+ import { DEFAULT_MAX_REPORT_BYTES, completionReportBytes } from '../domain/missions/completion.js'
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'
17
+ import { assessHostVersion, detectHostVersion, readCompatibility } from './compatibility.js'
18
+ import { profileConfig } from './config.js'
19
+ import { inspectWorkspaceLock } from '../adapters/workspaceLock.js'
20
+
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
+ }
32
+
33
+ async function exists(target) {
34
+ try {
35
+ await fsp.stat(target)
36
+ return true
37
+ } catch {
38
+ return false
39
+ }
40
+ }
41
+
42
+ async function gitVersion() {
43
+ try {
44
+ const { stdout } = await execFileAsync('git', ['--version'], { windowsHide: true })
45
+ return String(stdout).trim()
46
+ } catch {
47
+ return null
48
+ }
49
+ }
50
+
51
+ function check(id, status, detail, action) {
52
+ return { id, status, detail, ...(action ? { action } : {}) }
53
+ }
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
+
61
+ /** Read the on-disk state version without loading, migrating, or locking. */
62
+ async function inspectStateSchema(paths) {
63
+ try {
64
+ const text = await fsp.readFile(paths.state, 'utf8')
65
+ return inspectStateVersion(JSON.parse(text), paths.state)
66
+ } catch (error) {
67
+ return {
68
+ status: 'invalid',
69
+ currentVersion: null,
70
+ supportedVersion: SCHEMA_VERSION,
71
+ migrations: [],
72
+ message: `tower state could not be inspected: ${String(error?.message ?? error)}`,
73
+ }
74
+ }
75
+ }
76
+
77
+ /**
78
+ * Report the on-disk schema version and the migration that a write would apply.
79
+ * Doctor never migrates: a read-only call must leave the file byte-identical.
80
+ */
81
+ function stateSchemaReport(info) {
82
+ const stateSchema = {
83
+ currentVersion: info.currentVersion,
84
+ supportedVersion: info.supportedVersion,
85
+ migrations: info.migrations ?? [],
86
+ status: info.status,
87
+ }
88
+ if (info.status === 'current') {
89
+ return {
90
+ stateSchema,
91
+ check: check('state-schema', 'pass', `schemaVersion ${info.currentVersion} is current`),
92
+ }
93
+ }
94
+ if (info.status === 'migratable') {
95
+ return {
96
+ stateSchema,
97
+ check: check(
98
+ 'state-schema',
99
+ 'warn',
100
+ `schemaVersion ${info.currentVersion} is older than ${info.supportedVersion}; the next locked write migrates it and keeps state.json.v${info.currentVersion}.bak`,
101
+ 'a write tool applies the migration; a read-only tool only migrates in memory',
102
+ ),
103
+ }
104
+ }
105
+ if (info.status === 'newer') {
106
+ return {
107
+ stateSchema,
108
+ check: check(
109
+ 'state-schema',
110
+ 'fail',
111
+ `schemaVersion ${info.currentVersion} is newer than this build supports (${info.supportedVersion}); state was written by a newer dsh-tower`,
112
+ 'upgrade dsh-tower; this build refuses to downgrade state.json',
113
+ ),
114
+ }
115
+ }
116
+ if (info.status === 'unmigratable') {
117
+ return {
118
+ stateSchema,
119
+ check: check(
120
+ 'state-schema',
121
+ 'fail',
122
+ `schemaVersion ${info.currentVersion} has no migration to ${info.supportedVersion}`,
123
+ 'restore a state.json written by a supported dsh-tower; this build will not guess at an unknown schema',
124
+ ),
125
+ }
126
+ }
127
+ return {
128
+ stateSchema,
129
+ check: check(
130
+ 'state-schema',
131
+ 'fail',
132
+ info.message ?? 'state schemaVersion is unreadable',
133
+ 'restore a state.json this build can read, or run tower_init for a new repository',
134
+ ),
135
+ }
136
+ }
137
+
138
+ /** Run every read-only diagnostic; never writes state or takes the state lock. */
139
+ export async function runDoctor({ tower, ctx, agent, hostVersion, compatibilityFile, loaderRows, surface }) {
140
+ const checks = []
141
+ let reportsReport = null
142
+ let stateLockReport = null
143
+ const version = await gitVersion()
144
+ checks.push(version ? check('git', 'pass', version) : check('git', 'fail', 'git is not available', 'install Git and retry'))
145
+
146
+ const detectedHost = hostVersion ?? detectHostVersion(ctx)
147
+ const compatibilityRows = readCompatibility(compatibilityFile)
148
+ const hostAssessment = assessHostVersion(detectedHost, compatibilityRows)
149
+ checks.push(
150
+ check(
151
+ 'host-version',
152
+ hostAssessment.verdict === 'verified' ? 'pass' : 'warn',
153
+ detectedHost
154
+ ? `${detectedHost}: ${hostAssessment.detail}`
155
+ : `unknown host version; ${hostAssessment.detail}. Include host version + COMPATIBILITY.md row in bug reports.`,
156
+ 'see COMPATIBILITY.md for verified host builds',
157
+ ),
158
+ )
159
+
160
+ const rows = Array.isArray(loaderRows)
161
+ ? loaderRows
162
+ : (() => {
163
+ try {
164
+ const loader = ctx?.get?.('loader') ?? ctx?.loader
165
+ const source = loader?.entries ?? loader?.rows ?? loader?.store?.entries
166
+ if (Array.isArray(source)) return source
167
+ if (source && typeof source === 'object') return Object.values(source)
168
+ } catch {
169
+ // loader inventory is optional
170
+ }
171
+ return null
172
+ })()
173
+ if (rows === null) {
174
+ checks.push(check('loader-conflicts', 'warn', 'loader inventory unavailable; cannot detect co-installed Tower packages'))
175
+ } else {
176
+ const towerRows = rows.filter((row) => row && row.name === 'dsh-tower')
177
+ const foreign = towerRows.filter((row) => row.id !== 'tower')
178
+ checks.push(
179
+ towerRows.length > 1 || foreign.length > 0
180
+ ? check(
181
+ 'loader-conflicts',
182
+ 'fail',
183
+ `another loader row resolves to "dsh-tower" (${towerRows.map((row) => row.id ?? '?').join(', ')}); uninstall the conflicting dsh-plugins-tower row`,
184
+ 'see README "Known conflicts"',
185
+ )
186
+ : check('loader-conflicts', 'pass', 'no conflicting dsh-tower loader rows'),
187
+ )
188
+ }
189
+
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
+ )
204
+ const schemaInfo = await inspectStateSchema(tower.paths)
205
+ let state = null
206
+ try {
207
+ state = await tower.load()
208
+ checks.push(check('state', 'pass', `${tower.paths.state} revision ${state.revision}`))
209
+ checks.push(
210
+ state.towerAgentId
211
+ ? check('tower-owner', 'pass', `bound to ${String(state.towerAgentId)}`)
212
+ : check('tower-owner', 'warn', 'state has no towerAgentId; write tools will fail closed', 'run tower_init with the restored session to re-claim ownership'),
213
+ )
214
+ } catch (error) {
215
+ checks.push(check('state', 'fail', String(error?.message ?? error), 'run tower_init or restore the state file'))
216
+ }
217
+ const schemaReport = stateSchemaReport(schemaInfo)
218
+ checks.push(schemaReport.check)
219
+
220
+ // Scope matching is only as good as the detected filesystem mode (#90).
221
+ // Doctor never probes: a read-only call must not create files. A legacy state
222
+ // that has not been re-initialized therefore reports the safe exact default.
223
+ const caseInsensitive =
224
+ state && (state.caseInsensitive === true || state.caseInsensitive === false) ? state.caseInsensitive : null
225
+ const comparison = comparisonMode(caseInsensitive)
226
+ checks.push(
227
+ caseInsensitive === null
228
+ ? check(
229
+ 'case-mode',
230
+ 'warn',
231
+ `caseInsensitive: not detected; scope matching uses ${comparison} comparison`,
232
+ 'run tower_init on a writable checkout to probe and record the filesystem mode',
233
+ )
234
+ : check(
235
+ 'case-mode',
236
+ 'pass',
237
+ `caseInsensitive: ${caseInsensitive}; scope matching uses ${comparison} comparison`,
238
+ ),
239
+ )
240
+
241
+ const config = tower.config ?? {}
242
+ const profile = profileConfig()
243
+ const staleDefaults = []
244
+ if (profile.review && profile.review.requireReview === undefined) {
245
+ staleDefaults.push('review.requireReview now defaults to false (2.0.3)')
246
+ }
247
+ if (profile.mode && profile.mode.allowAgentControl === undefined) {
248
+ staleDefaults.push('mode.allowAgentControl now defaults to true (2.0.4)')
249
+ }
250
+ checks.push(
251
+ staleDefaults.length > 0
252
+ ? check('default-reversals', 'warn', staleDefaults.join('; '), 'pin the key explicitly in the profile if the old posture is required')
253
+ : check('default-reversals', 'pass', 'profile does not rely on a reversed default'),
254
+ )
255
+ checks.push(
256
+ check(
257
+ 'config',
258
+ config.configPath && config.repoConfigPresent ? 'pass' : 'warn',
259
+ config.repoConfigPresent ? `repo config ${config.configPath}` : `repo config not present (${config.configPath ?? 'unknown'})`,
260
+ 'optional: add <git-common-dir>/dsh-tower/config.json',
261
+ ),
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
+ )
289
+ const presets = ctx.get?.('agentPresets')
290
+ if (presets && typeof presets.list === 'function') {
291
+ try {
292
+ const available = (await presets.list()).map((entry) => String(entry.id))
293
+ const missing = [config.spawn?.workerPreset ?? 'tower-worker', config.spawn?.reviewerPreset ?? 'tower-reviewer']
294
+ .filter((id) => !available.includes(id))
295
+ checks.push(
296
+ missing.length === 0
297
+ ? check('presets', 'pass', `worker=${config.spawn?.workerPreset ?? 'tower-worker'} reviewer=${config.spawn?.reviewerPreset ?? 'tower-reviewer'}`)
298
+ : check('presets', 'fail', `missing preset(s): ${missing.join(', ')}`, 'enable the Tower preset bundle and retry'),
299
+ )
300
+ } catch (error) {
301
+ checks.push(check('presets', 'warn', `preset registry unavailable: ${String(error?.message ?? error)}`))
302
+ }
303
+ } else {
304
+ checks.push(check('presets', 'warn', 'agentPresets service is unavailable'))
305
+ }
306
+
307
+ const subagents = ctx.get?.('subagents')
308
+ checks.push(subagents ? check('subagents', 'pass', 'service mounted') : check('subagents', 'fail', 'subagents service is unavailable', 'enable the subagent bundle'))
309
+ const hmr = ctx.get?.('hmr')
310
+ checks.push(hmr ? check('hmr', 'pass', 'service mounted') : check('hmr', 'warn', 'HMR is not mounted; /tower reload is unavailable'))
311
+
312
+ if (state) {
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)`
333
+ checks.push(
334
+ !lock.record
335
+ ? check('state-lock', 'pass', 'no state lock file')
336
+ : lock.alive === false
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
+ ),
353
+ )
354
+ const lockDir = path.join(tower.commonDir, 'dsh-tower', 'tower.lock')
355
+ try {
356
+ const lock = await inspectWorkspaceLock(tower.commonDir, {
357
+ staleMs: Number(config.mode?.workspaceStaleMs) || 300000,
358
+ })
359
+ checks.push(lock.held ? check('workspace-lock', lock.stale ? 'warn' : 'pass', `held by ${lock.holder?.sessionId ?? 'unknown'}`) : check('workspace-lock', 'pass', 'free'))
360
+ } catch {
361
+ checks.push(check('workspace-lock', 'warn', `lock file ${lockDir} could not be inspected`))
362
+ }
363
+
364
+ const live = (state.roster ?? []).filter((entry) => !['retired', 'failed', 'interrupted', 'missing', 'stale', 'inactive'].includes(entry.state))
365
+ checks.push(
366
+ live.length > 0
367
+ ? check('roster', 'pass', `${live.length} live roster entr${live.length === 1 ? 'y' : 'ies'}`)
368
+ : check('roster', 'pass', 'no live workers'),
369
+ )
370
+
371
+ let missingWorktrees = 0
372
+ let dirtyWorktrees = 0
373
+ let removedWorktrees = 0
374
+ for (const mission of state.missions ?? []) {
375
+ if (!mission.worktree) continue
376
+ if (mission.worktreeRemovedAt) {
377
+ removedWorktrees += 1
378
+ continue
379
+ }
380
+ try {
381
+ await tower.assertWorktree(mission.worktree)
382
+ if (await exists(mission.worktree.path)) {
383
+ const dirty = (await git(mission.worktree.path, ['status', '--porcelain'])).trim()
384
+ if (dirty) dirtyWorktrees += 1
385
+ } else {
386
+ missingWorktrees += 1
387
+ }
388
+ } catch {
389
+ missingWorktrees += 1
390
+ }
391
+ }
392
+ const removedNote = removedWorktrees > 0 ? ` (${removedWorktrees} removed by teardown)` : ''
393
+ checks.push(
394
+ missingWorktrees > 0
395
+ ? check('worktrees', 'fail', `${missingWorktrees} mission worktree(s) are missing or invalid${removedNote}`, 'inspect tower_status, restore the worktree/branch, then re-run tower_doctor')
396
+ : dirtyWorktrees > 0
397
+ ? check('worktrees', 'warn', `${dirtyWorktrees} worktree(s) have uncommitted changes${removedNote}`)
398
+ : check('worktrees', 'pass', `all registered worktrees are valid${removedNote}`),
399
+ )
400
+
401
+ const testConfigured = state.missions?.some((mission) => mission.testRuns !== undefined)
402
+ checks.push(
403
+ check(
404
+ 'tests',
405
+ config.tests?.enabled === false ? 'warn' : 'pass',
406
+ config.tests?.enabled === false ? 'test discovery disabled' : `admission=${config.tests?.admission ?? 'complete'} timeoutMs=${config.tests?.timeoutMs ?? 60000}${testConfigured ? '; receipts present' : ''}`,
407
+ ),
408
+ )
409
+ checks.push(check('legacy', 'pass', `${state.legacySnapshots?.length ?? 0} legacy snapshot(s)`))
410
+ checks.push(check('batches', 'pass', `${state.batches?.length ?? 0} batch(es)`))
411
+
412
+ // Completion reports are the largest model-supplied strings in state.json;
413
+ // report the biggest one so growth is visible before it becomes a problem.
414
+ const reportSizes = (state.missions ?? [])
415
+ .map((mission) => ({ missionId: mission.id, bytes: completionReportBytes(mission.completionReport) }))
416
+ .filter((entry) => entry.bytes !== null)
417
+ const largestReport = reportSizes.reduce(
418
+ (best, entry) => (best === null || entry.bytes > best.bytes ? entry : best),
419
+ null,
420
+ )
421
+ const reportCap = Number(tower.config?.gates?.maxReportBytes) || DEFAULT_MAX_REPORT_BYTES
422
+ reportsReport = { count: reportSizes.length, largest: largestReport, capBytes: reportCap }
423
+ checks.push(
424
+ largestReport
425
+ ? check(
426
+ 'reports',
427
+ largestReport.bytes > reportCap ? 'warn' : 'pass',
428
+ `${reportSizes.length} completion report(s); largest ${largestReport.bytes} B on ${largestReport.missionId} (cap ${reportCap} B)`,
429
+ largestReport.bytes > reportCap
430
+ ? 'existing reports predate the cap; reads are unchanged and no rewrite happens'
431
+ : undefined,
432
+ )
433
+ : check('reports', 'pass', `no completion reports (cap ${reportCap} B)`),
434
+ )
435
+ }
436
+
437
+ try {
438
+ const { inspectLeases } = await import('../adapters/leases.js')
439
+ const leases = await inspectLeases(tower.commonDir)
440
+ checks.push(check('leases', leases.leases?.length ? 'warn' : 'pass', `${leases.leases?.length ?? 0} active lease(s)`))
441
+ } catch (error) {
442
+ const corrupt = error?.code === 'state-corrupt'
443
+ checks.push(
444
+ check(
445
+ 'leases',
446
+ corrupt ? 'fail' : 'warn',
447
+ corrupt ? `lease ledger is corrupt: ${String(error?.message ?? error)}` : 'lease module unavailable',
448
+ corrupt ? 'inspect <git-common-dir>/dsh-tower/leases.json and repair or remove it' : undefined,
449
+ ),
450
+ )
451
+ }
452
+
453
+ try {
454
+ const text = await fsp.readFile(path.join(tower.paths.dir, 'lock-holds.jsonl'), 'utf8')
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)
467
+ if (samples.length === 0) {
468
+ checks.push(check('lock-holds', 'pass', 'no lock-hold samples yet'))
469
+ } else {
470
+ const p = (q) => samples[Math.min(samples.length - 1, Math.floor(q * samples.length))]
471
+ const p50 = p(0.5)
472
+ const p95 = p(0.95)
473
+ checks.push(
474
+ check(
475
+ 'lock-holds',
476
+ p95 > 1000 ? 'warn' : 'pass',
477
+ `${samples.length} sample(s): p50 ${p50}ms, p95 ${p95}ms`,
478
+ p95 > 1000 ? 'a mutation is holding the state lock across slow work; inspect the activity/lock-holds log' : undefined,
479
+ ),
480
+ )
481
+ }
482
+ } catch {
483
+ checks.push(check('lock-holds', 'pass', 'no lock-hold samples yet'))
484
+ }
485
+
486
+ // Archive inventory (#73): size, count and the configured trigger policy.
487
+ // A missing directory is a healthy empty archive; a corrupt file is a warn.
488
+ const archiveInventory = await inspectArchive(tower.paths)
489
+ const stateBytes = await fsp.stat(tower.paths.state).then((stat) => stat.size).catch(() => null)
490
+ const archivePolicy = config.archive ?? {}
491
+ const policyText = `afterDays=${archivePolicy.afterDays ?? 30} maxMissions=${archivePolicy.maxMissions ?? 100}${archivePolicy.enabled === false ? ' disabled' : ''}`
492
+ if (!archiveInventory.exists) {
493
+ checks.push(check('archive', 'pass', `no archived missions; ${policyText}`))
494
+ } else if (archiveInventory.unreadable.length > 0) {
495
+ checks.push(
496
+ check(
497
+ 'archive',
498
+ 'warn',
499
+ `${archiveInventory.missions} archived mission(s), ${archiveInventory.bytes} bytes; unreadable: ${archiveInventory.unreadable[0]}`,
500
+ 'inspect <git-common-dir>/dsh-tower/archive/ and repair or move the corrupt file; archiving is paused until it is readable',
501
+ ),
502
+ )
503
+ } else {
504
+ checks.push(
505
+ check(
506
+ 'archive',
507
+ 'pass',
508
+ `${archiveInventory.missions} archived mission(s) in ${archiveInventory.years.length} year file(s), ${archiveInventory.bytes} bytes; ${policyText}`,
509
+ ),
510
+ )
511
+ }
512
+
513
+ const failed = checks.filter((item) => item.status === 'fail').length
514
+ return {
515
+ ok: failed === 0,
516
+ checks,
517
+ stateLock: stateLockReport,
518
+ gitLock,
519
+ stateSchema: schemaReport.stateSchema,
520
+ // reports (from #89) and archive (from #73) are both kept
521
+ reports: reportsReport,
522
+ surface: surfaceReport,
523
+ archive: {
524
+ missions: state ? state.missions.length : null,
525
+ archivedMissions: archiveInventory.missions,
526
+ stateBytes,
527
+ archiveBytes: archiveInventory.bytes,
528
+ dir: archiveInventory.dir,
529
+ years: archiveInventory.years,
530
+ files: archiveInventory.files.length,
531
+ unreadable: archiveInventory.unreadable,
532
+ policy: {
533
+ enabled: archivePolicy.enabled !== false,
534
+ afterDays: archivePolicy.afterDays ?? 30,
535
+ maxMissions: archivePolicy.maxMissions ?? 100,
536
+ },
537
+ },
538
+ environmentContract: {
539
+ platform: process.platform,
540
+ gitVersion: version,
541
+ repoRoot: tower.root,
542
+ commonDir: tower.commonDir,
543
+ stateDir: tower.paths.dir,
544
+ caseInsensitive,
545
+ comparisonMode: comparison,
546
+ gitTimeoutMs: Number(tower.config.git?.timeoutMs) || 15000,
547
+ mergeTimeoutMs: Number(tower.config.merge?.timeoutMs) || 900000,
548
+ testsAdmission: tower.config.tests?.admission ?? 'off',
549
+ hostVersion: detectedHost,
550
+ hostCompatibility: hostAssessment.verdict,
551
+ hostCompatibilityDetail: hostAssessment.detail,
552
+ },
553
+ mode: agent ? 'ready' : 'unknown',
554
+ }
555
+ }
@@ -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