dsh-tower 2.0.4 → 2.1.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 (184) hide show
  1. package/CHANGELOG.md +152 -0
  2. package/COMPATIBILITY.md +39 -0
  3. package/CONTRIBUTING.md +157 -2
  4. package/README.en.md +46 -3
  5. package/README.md +44 -3
  6. package/SECURITY.md +27 -0
  7. package/adapters/default.js +12 -0
  8. package/adapters/store-fs.js +6 -0
  9. package/adapters/vcs-git.js +4 -0
  10. package/app/archive.js +190 -0
  11. package/app/batch.js +79 -0
  12. package/app/board.js +27 -0
  13. package/app/checkCompletion.js +89 -0
  14. package/app/enrichedMissions.js +26 -0
  15. package/app/findings.js +78 -0
  16. package/app/gate.js +126 -0
  17. package/app/init.js +291 -0
  18. package/app/merge.js +573 -0
  19. package/app/messages.js +66 -0
  20. package/app/mission.js +37 -0
  21. package/app/missionContext.js +14 -0
  22. package/app/plan.js +50 -0
  23. package/app/provision.js +193 -0
  24. package/app/read.js +113 -0
  25. package/app/rebase.js +302 -0
  26. package/app/recording.js +72 -0
  27. package/app/review.js +147 -0
  28. package/app/roster.js +123 -0
  29. package/app/spawn.js +125 -0
  30. package/app/teardown.js +205 -0
  31. package/app/tower.js +552 -0
  32. package/cordis.patch.yml +61 -53
  33. package/docs/README.md +3 -1
  34. package/docs/agents/domain.md +38 -0
  35. package/docs/agents/issue-tracker.md +46 -0
  36. package/docs/agents/triage-labels.md +15 -0
  37. package/docs/architecture/overview.md +114 -13
  38. package/docs/board.md +72 -0
  39. package/docs/guide/hot-reload.md +1 -1
  40. package/docs/guide/install-upgrade-migration.md +25 -1
  41. package/docs/guide/release.md +125 -0
  42. package/docs/operations/security.md +47 -2
  43. package/docs/reference/blockers.md +1 -1
  44. package/docs/reference/config.generated.md +70 -0
  45. package/docs/reference/config.md +8 -1
  46. package/docs/reference/doctor.md +58 -8
  47. package/docs/reference/errors.generated.md +41 -0
  48. package/docs/reference/errors.md +7 -3
  49. package/docs/reference/tools.generated.md +33 -0
  50. package/docs/reference/tools.md +7 -6
  51. package/docs/zh/README.md +3 -2
  52. package/docs/zh/guide/hot-reload.md +1 -1
  53. package/docs/zh/guide/install-upgrade-migration.md +18 -1
  54. package/docs/zh/operations/security.md +30 -1
  55. package/docs/zh/reference/blockers.md +1 -1
  56. package/docs/zh/reference/config.md +8 -1
  57. package/docs/zh/reference/doctor.md +35 -2
  58. package/docs/zh/reference/errors.md +6 -2
  59. package/docs/zh/reference/tools.md +3 -3
  60. package/domain/collab/batch.js +112 -0
  61. package/domain/collab/messages.js +127 -0
  62. package/domain/collab/roster.js +158 -0
  63. package/domain/collab/spawn.js +188 -0
  64. package/domain/config/schema.js +427 -0
  65. package/domain/contracts/outputs.js +305 -0
  66. package/domain/contracts/tool-inputs/collaboration.js +58 -0
  67. package/domain/contracts/tool-inputs/findings.js +29 -0
  68. package/domain/contracts/tool-inputs/index.js +94 -0
  69. package/domain/contracts/tool-inputs/integration.js +37 -0
  70. package/domain/contracts/tool-inputs/missions.js +95 -0
  71. package/domain/contracts/tool-inputs/ops.js +75 -0
  72. package/domain/contracts/tool-inputs/shared.js +25 -0
  73. package/domain/errors.js +98 -0
  74. package/domain/gate.js +125 -0
  75. package/domain/integration/gate.js +190 -0
  76. package/domain/integration/init.js +261 -0
  77. package/domain/integration/liveness.js +31 -0
  78. package/domain/integration/merge.js +377 -0
  79. package/domain/integration/provision.js +124 -0
  80. package/domain/integration/rebase.js +236 -0
  81. package/domain/integration/review.js +52 -0
  82. package/domain/integration/teardown.js +118 -0
  83. package/domain/mission.js +68 -0
  84. package/domain/missions/completion.js +167 -0
  85. package/domain/missions/findings.js +201 -0
  86. package/domain/missions/lifecycle.js +186 -0
  87. package/domain/missions/plan.js +149 -0
  88. package/domain/missions/review.js +179 -0
  89. package/domain/planning.js +121 -0
  90. package/domain/read/enrichedMissions.js +58 -0
  91. package/domain/read/missionContext.js +53 -0
  92. package/domain/read/views.js +141 -0
  93. package/domain/report.js +67 -0
  94. package/domain/roster.js +40 -0
  95. package/domain/scope.js +177 -0
  96. package/domain/state/archive.js +257 -0
  97. package/domain/state/migrations.js +145 -0
  98. package/domain/state/schema.js +239 -0
  99. package/domain/state/semantics.js +117 -0
  100. package/domain/state.js +183 -0
  101. package/domain/summary.js +116 -0
  102. package/domain/vocabulary.js +179 -0
  103. package/index.js +63 -20
  104. package/lib/archive.js +279 -0
  105. package/lib/briefing.js +176 -0
  106. package/lib/caseSensitivity.js +66 -0
  107. package/lib/compatibility.js +108 -0
  108. package/lib/config.js +33 -375
  109. package/lib/doctor.js +275 -4
  110. package/lib/git.js +60 -16
  111. package/lib/hmr.js +4 -3
  112. package/lib/leases.js +17 -7
  113. package/lib/legacy.js +39 -14
  114. package/lib/modeState.js +39 -0
  115. package/lib/modeStore.js +11 -43
  116. package/lib/persistence.js +356 -0
  117. package/lib/presets.js +35 -0
  118. package/lib/spawn-prechecks.js +168 -0
  119. package/lib/spawn-support.js +79 -0
  120. package/lib/stateFile.js +99 -0
  121. package/lib/store.js +67 -135
  122. package/lib/tests.js +17 -12
  123. package/lib/tool-registry.js +544 -0
  124. package/lib/workspaceLock.js +2 -2
  125. package/lib/worktreeOwnership.js +22 -10
  126. package/locale/en.json +23 -1
  127. package/locale/zh.json +22 -0
  128. package/package.json +32 -4
  129. package/ports/clock.js +6 -0
  130. package/ports/ids.js +7 -0
  131. package/ports/index.js +2 -0
  132. package/ports/store.js +11 -0
  133. package/ports/vcs.js +11 -0
  134. package/scripts/architecture.mjs +94 -0
  135. package/scripts/check-docs.mjs +9 -3
  136. package/scripts/check-pack.mjs +49 -0
  137. package/scripts/domain-metrics.mjs +48 -0
  138. package/scripts/generate-config-reference.mjs +46 -0
  139. package/scripts/generate-error-reference.mjs +29 -0
  140. package/scripts/generate-tools-reference.mjs +56 -0
  141. package/scripts/issue-audit.mjs +259 -0
  142. package/scripts/lib/cli.mjs +76 -0
  143. package/scripts/lib/tmp.mjs +20 -0
  144. package/scripts/mount-smoke/fixtures.mjs +162 -0
  145. package/scripts/mount-smoke/host.json +4 -0
  146. package/scripts/mount-smoke/probe.mjs +125 -0
  147. package/scripts/mount-smoke/surface.mjs +141 -0
  148. package/scripts/mount-smoke.mjs +270 -0
  149. package/scripts/pack-manifest.txt +212 -0
  150. package/scripts/release-check.mjs +303 -0
  151. package/tools/adopt.js +72 -0
  152. package/tools/batch.js +78 -0
  153. package/tools/complete.js +53 -0
  154. package/tools/diff.js +39 -0
  155. package/tools/doctor.js +50 -0
  156. package/tools/finding.js +40 -0
  157. package/tools/findings.js +39 -0
  158. package/tools/import-legacy.js +42 -0
  159. package/tools/inbox.js +39 -0
  160. package/tools/index.js +39 -0
  161. package/tools/init.js +63 -0
  162. package/tools/interrupt.js +42 -0
  163. package/tools/legacy-map.js +44 -0
  164. package/tools/merge.js +39 -0
  165. package/tools/mission.js +41 -0
  166. package/tools/plan.js +39 -0
  167. package/tools/preflight.js +45 -0
  168. package/tools/rebase.js +39 -0
  169. package/tools/recover.js +45 -0
  170. package/tools/reload.js +52 -0
  171. package/tools/resource.js +54 -0
  172. package/tools/review.js +45 -0
  173. package/tools/roster.js +60 -0
  174. package/tools/send.js +147 -0
  175. package/tools/spawn.js +196 -0
  176. package/tools/status.js +102 -0
  177. package/tools/teardown.js +42 -0
  178. package/tools/test.js +61 -0
  179. package/types/cordis.d.ts +6 -0
  180. package/types/index.d.ts +373 -0
  181. package/lib/errors.js +0 -123
  182. package/lib/tools.js +0 -2460
  183. package/lib/tower.js +0 -2912
  184. /package/{lib → domain}/batch.js +0 -0
package/lib/doctor.js CHANGED
@@ -1,10 +1,17 @@
1
1
  // Read-only environment contract and health checks for a Tower repository.
2
- import { execFile } from 'node:child_process'
2
+ import { execFile } from '../adapters/vcs-git.js'
3
3
  import { promisify } from 'node:util'
4
4
  import path from 'node:path'
5
- import fsp from 'node:fs/promises'
5
+ import { fsp } from '../adapters/store-fs.js'
6
6
  import { git } from './git.js'
7
7
  import { readStateLock } from './store.js'
8
+ import { inspectArchive } from './archive.js'
9
+ import { inspectStateVersion } from '../domain/state.js'
10
+ import { SCHEMA_VERSION } from '../domain/mission.js'
11
+ import { DEFAULT_MAX_REPORT_BYTES, completionReportBytes } from '../domain/missions/completion.js'
12
+ import { comparisonMode } from '../domain/scope.js'
13
+ import { assessHostVersion, detectHostVersion, readCompatibility } from './compatibility.js'
14
+ import { profileConfig } from './config.js'
8
15
  import { inspectWorkspaceLock } from './workspaceLock.js'
9
16
 
10
17
  const execFileAsync = promisify(execFile)
@@ -31,22 +38,186 @@ function check(id, status, detail, action) {
31
38
  return { id, status, detail, ...(action ? { action } : {}) }
32
39
  }
33
40
 
41
+ /** Read the on-disk state version without loading, migrating, or locking. */
42
+ async function inspectStateSchema(paths) {
43
+ try {
44
+ const text = await fsp.readFile(paths.state, 'utf8')
45
+ return inspectStateVersion(JSON.parse(text), paths.state)
46
+ } catch (error) {
47
+ return {
48
+ status: 'invalid',
49
+ currentVersion: null,
50
+ supportedVersion: SCHEMA_VERSION,
51
+ migrations: [],
52
+ message: `tower state could not be inspected: ${String(error?.message ?? error)}`,
53
+ }
54
+ }
55
+ }
56
+
57
+ /**
58
+ * Report the on-disk schema version and the migration that a write would apply.
59
+ * Doctor never migrates: a read-only call must leave the file byte-identical.
60
+ */
61
+ function stateSchemaReport(info) {
62
+ const stateSchema = {
63
+ currentVersion: info.currentVersion,
64
+ supportedVersion: info.supportedVersion,
65
+ migrations: info.migrations ?? [],
66
+ status: info.status,
67
+ }
68
+ if (info.status === 'current') {
69
+ return {
70
+ stateSchema,
71
+ check: check('state-schema', 'pass', `schemaVersion ${info.currentVersion} is current`),
72
+ }
73
+ }
74
+ if (info.status === 'migratable') {
75
+ return {
76
+ stateSchema,
77
+ check: check(
78
+ 'state-schema',
79
+ 'warn',
80
+ `schemaVersion ${info.currentVersion} is older than ${info.supportedVersion}; the next locked write migrates it and keeps state.json.v${info.currentVersion}.bak`,
81
+ 'a write tool applies the migration; a read-only tool only migrates in memory',
82
+ ),
83
+ }
84
+ }
85
+ if (info.status === 'newer') {
86
+ return {
87
+ stateSchema,
88
+ check: check(
89
+ 'state-schema',
90
+ 'fail',
91
+ `schemaVersion ${info.currentVersion} is newer than this build supports (${info.supportedVersion}); state was written by a newer dsh-tower`,
92
+ 'upgrade dsh-tower; this build refuses to downgrade state.json',
93
+ ),
94
+ }
95
+ }
96
+ if (info.status === 'unmigratable') {
97
+ return {
98
+ stateSchema,
99
+ check: check(
100
+ 'state-schema',
101
+ 'fail',
102
+ `schemaVersion ${info.currentVersion} has no migration to ${info.supportedVersion}`,
103
+ 'restore a state.json written by a supported dsh-tower; this build will not guess at an unknown schema',
104
+ ),
105
+ }
106
+ }
107
+ return {
108
+ stateSchema,
109
+ check: check(
110
+ 'state-schema',
111
+ 'fail',
112
+ info.message ?? 'state schemaVersion is unreadable',
113
+ 'restore a state.json this build can read, or run tower_init for a new repository',
114
+ ),
115
+ }
116
+ }
117
+
34
118
  /** Run every read-only diagnostic; never writes state or takes the state lock. */
35
- export async function runDoctor({ tower, ctx, agent }) {
119
+ export async function runDoctor({ tower, ctx, agent, hostVersion, compatibilityFile, loaderRows }) {
36
120
  const checks = []
121
+ let reportsReport = null
37
122
  const version = await gitVersion()
38
123
  checks.push(version ? check('git', 'pass', version) : check('git', 'fail', 'git is not available', 'install Git and retry'))
39
124
 
125
+ const detectedHost = hostVersion ?? detectHostVersion(ctx)
126
+ const compatibilityRows = readCompatibility(compatibilityFile)
127
+ const hostAssessment = assessHostVersion(detectedHost, compatibilityRows)
128
+ checks.push(
129
+ check(
130
+ 'host-version',
131
+ hostAssessment.verdict === 'verified' ? 'pass' : 'warn',
132
+ detectedHost
133
+ ? `${detectedHost}: ${hostAssessment.detail}`
134
+ : `unknown host version; ${hostAssessment.detail}. Include host version + COMPATIBILITY.md row in bug reports.`,
135
+ 'see COMPATIBILITY.md for verified host builds',
136
+ ),
137
+ )
138
+
139
+ const rows = Array.isArray(loaderRows)
140
+ ? loaderRows
141
+ : (() => {
142
+ try {
143
+ const loader = ctx?.get?.('loader') ?? ctx?.loader
144
+ const source = loader?.entries ?? loader?.rows ?? loader?.store?.entries
145
+ if (Array.isArray(source)) return source
146
+ if (source && typeof source === 'object') return Object.values(source)
147
+ } catch {
148
+ // loader inventory is optional
149
+ }
150
+ return null
151
+ })()
152
+ if (rows === null) {
153
+ checks.push(check('loader-conflicts', 'warn', 'loader inventory unavailable; cannot detect co-installed Tower packages'))
154
+ } else {
155
+ const towerRows = rows.filter((row) => row && row.name === 'dsh-tower')
156
+ const foreign = towerRows.filter((row) => row.id !== 'tower')
157
+ checks.push(
158
+ towerRows.length > 1 || foreign.length > 0
159
+ ? check(
160
+ 'loader-conflicts',
161
+ 'fail',
162
+ `another loader row resolves to "dsh-tower" (${towerRows.map((row) => row.id ?? '?').join(', ')}); uninstall the conflicting dsh-plugins-tower row`,
163
+ 'see README "Known conflicts"',
164
+ )
165
+ : check('loader-conflicts', 'pass', 'no conflicting dsh-tower loader rows'),
166
+ )
167
+ }
168
+
40
169
  checks.push(check('repository', 'pass', `${tower.root} (common ${tower.commonDir})`))
170
+ const schemaInfo = await inspectStateSchema(tower.paths)
41
171
  let state = null
42
172
  try {
43
173
  state = await tower.load()
44
174
  checks.push(check('state', 'pass', `${tower.paths.state} revision ${state.revision}`))
175
+ checks.push(
176
+ state.towerAgentId
177
+ ? check('tower-owner', 'pass', `bound to ${String(state.towerAgentId)}`)
178
+ : check('tower-owner', 'warn', 'state has no towerAgentId; write tools will fail closed', 'run tower_init with the restored session to re-claim ownership'),
179
+ )
45
180
  } catch (error) {
46
181
  checks.push(check('state', 'fail', String(error?.message ?? error), 'run tower_init or restore the state file'))
47
182
  }
183
+ const schemaReport = stateSchemaReport(schemaInfo)
184
+ checks.push(schemaReport.check)
185
+
186
+ // Scope matching is only as good as the detected filesystem mode (#90).
187
+ // Doctor never probes: a read-only call must not create files. A legacy state
188
+ // that has not been re-initialized therefore reports the safe exact default.
189
+ const caseInsensitive =
190
+ state && (state.caseInsensitive === true || state.caseInsensitive === false) ? state.caseInsensitive : null
191
+ const comparison = comparisonMode(caseInsensitive)
192
+ checks.push(
193
+ caseInsensitive === null
194
+ ? check(
195
+ 'case-mode',
196
+ 'warn',
197
+ `caseInsensitive: not detected; scope matching uses ${comparison} comparison`,
198
+ 'run tower_init on a writable checkout to probe and record the filesystem mode',
199
+ )
200
+ : check(
201
+ 'case-mode',
202
+ 'pass',
203
+ `caseInsensitive: ${caseInsensitive}; scope matching uses ${comparison} comparison`,
204
+ ),
205
+ )
48
206
 
49
207
  const config = tower.config ?? {}
208
+ const profile = profileConfig()
209
+ const staleDefaults = []
210
+ if (profile.review && profile.review.requireReview === undefined) {
211
+ staleDefaults.push('review.requireReview now defaults to false (2.0.3)')
212
+ }
213
+ if (profile.mode && profile.mode.allowAgentControl === undefined) {
214
+ staleDefaults.push('mode.allowAgentControl now defaults to true (2.0.4)')
215
+ }
216
+ checks.push(
217
+ staleDefaults.length > 0
218
+ ? check('default-reversals', 'warn', staleDefaults.join('; '), 'pin the key explicitly in the profile if the old posture is required')
219
+ : check('default-reversals', 'pass', 'profile does not rely on a reversed default'),
220
+ )
50
221
  checks.push(
51
222
  check(
52
223
  'config',
@@ -98,7 +269,11 @@ export async function runDoctor({ tower, ctx, agent }) {
98
269
  }
99
270
 
100
271
  const live = (state.roster ?? []).filter((entry) => !['retired', 'failed', 'interrupted', 'missing', 'stale', 'inactive'].includes(entry.state))
101
- checks.push(live.length > 0 ? check('roster', 'warn', `${live.length} live roster entr${live.length === 1 ? 'y' : 'ies'}`) : check('roster', 'pass', 'no live workers'))
272
+ checks.push(
273
+ live.length > 0
274
+ ? check('roster', 'pass', `${live.length} live roster entr${live.length === 1 ? 'y' : 'ies'}`)
275
+ : check('roster', 'pass', 'no live workers'),
276
+ )
102
277
 
103
278
  let missingWorktrees = 0
104
279
  let dirtyWorktrees = 0
@@ -140,6 +315,30 @@ export async function runDoctor({ tower, ctx, agent }) {
140
315
  )
141
316
  checks.push(check('legacy', 'pass', `${state.legacySnapshots?.length ?? 0} legacy snapshot(s)`))
142
317
  checks.push(check('batches', 'pass', `${state.batches?.length ?? 0} batch(es)`))
318
+
319
+ // Completion reports are the largest model-supplied strings in state.json;
320
+ // report the biggest one so growth is visible before it becomes a problem.
321
+ const reportSizes = (state.missions ?? [])
322
+ .map((mission) => ({ missionId: mission.id, bytes: completionReportBytes(mission.completionReport) }))
323
+ .filter((entry) => entry.bytes !== null)
324
+ const largestReport = reportSizes.reduce(
325
+ (best, entry) => (best === null || entry.bytes > best.bytes ? entry : best),
326
+ null,
327
+ )
328
+ const reportCap = Number(tower.config?.gates?.maxReportBytes) || DEFAULT_MAX_REPORT_BYTES
329
+ reportsReport = { count: reportSizes.length, largest: largestReport, capBytes: reportCap }
330
+ checks.push(
331
+ largestReport
332
+ ? check(
333
+ 'reports',
334
+ largestReport.bytes > reportCap ? 'warn' : 'pass',
335
+ `${reportSizes.length} completion report(s); largest ${largestReport.bytes} B on ${largestReport.missionId} (cap ${reportCap} B)`,
336
+ largestReport.bytes > reportCap
337
+ ? 'existing reports predate the cap; reads are unchanged and no rewrite happens'
338
+ : undefined,
339
+ )
340
+ : check('reports', 'pass', `no completion reports (cap ${reportCap} B)`),
341
+ )
143
342
  }
144
343
 
145
344
  try {
@@ -158,19 +357,91 @@ export async function runDoctor({ tower, ctx, agent }) {
158
357
  )
159
358
  }
160
359
 
360
+ try {
361
+ 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)
363
+ if (samples.length === 0) {
364
+ checks.push(check('lock-holds', 'pass', 'no lock-hold samples yet'))
365
+ } else {
366
+ const p = (q) => samples[Math.min(samples.length - 1, Math.floor(q * samples.length))]
367
+ const p50 = p(0.5)
368
+ const p95 = p(0.95)
369
+ checks.push(
370
+ check(
371
+ 'lock-holds',
372
+ p95 > 1000 ? 'warn' : 'pass',
373
+ `${samples.length} sample(s): p50 ${p50}ms, p95 ${p95}ms`,
374
+ p95 > 1000 ? 'a mutation is holding the state lock across slow work; inspect the activity/lock-holds log' : undefined,
375
+ ),
376
+ )
377
+ }
378
+ } catch {
379
+ checks.push(check('lock-holds', 'pass', 'no lock-hold samples yet'))
380
+ }
381
+
382
+ // Archive inventory (#73): size, count and the configured trigger policy.
383
+ // A missing directory is a healthy empty archive; a corrupt file is a warn.
384
+ const archiveInventory = await inspectArchive(tower.paths)
385
+ const stateBytes = await fsp.stat(tower.paths.state).then((stat) => stat.size).catch(() => null)
386
+ const archivePolicy = config.archive ?? {}
387
+ const policyText = `afterDays=${archivePolicy.afterDays ?? 30} maxMissions=${archivePolicy.maxMissions ?? 100}${archivePolicy.enabled === false ? ' disabled' : ''}`
388
+ if (!archiveInventory.exists) {
389
+ checks.push(check('archive', 'pass', `no archived missions; ${policyText}`))
390
+ } else if (archiveInventory.unreadable.length > 0) {
391
+ checks.push(
392
+ check(
393
+ 'archive',
394
+ 'warn',
395
+ `${archiveInventory.missions} archived mission(s), ${archiveInventory.bytes} bytes; unreadable: ${archiveInventory.unreadable[0]}`,
396
+ 'inspect <git-common-dir>/dsh-tower/archive/ and repair or move the corrupt file; archiving is paused until it is readable',
397
+ ),
398
+ )
399
+ } else {
400
+ checks.push(
401
+ check(
402
+ 'archive',
403
+ 'pass',
404
+ `${archiveInventory.missions} archived mission(s) in ${archiveInventory.years.length} year file(s), ${archiveInventory.bytes} bytes; ${policyText}`,
405
+ ),
406
+ )
407
+ }
408
+
161
409
  const failed = checks.filter((item) => item.status === 'fail').length
162
410
  return {
163
411
  ok: failed === 0,
164
412
  checks,
413
+ stateSchema: schemaReport.stateSchema,
414
+ // reports (from #89) and archive (from #73) are both kept
415
+ reports: reportsReport,
416
+ archive: {
417
+ missions: state ? state.missions.length : null,
418
+ archivedMissions: archiveInventory.missions,
419
+ stateBytes,
420
+ archiveBytes: archiveInventory.bytes,
421
+ dir: archiveInventory.dir,
422
+ years: archiveInventory.years,
423
+ files: archiveInventory.files.length,
424
+ unreadable: archiveInventory.unreadable,
425
+ policy: {
426
+ enabled: archivePolicy.enabled !== false,
427
+ afterDays: archivePolicy.afterDays ?? 30,
428
+ maxMissions: archivePolicy.maxMissions ?? 100,
429
+ },
430
+ },
165
431
  environmentContract: {
166
432
  platform: process.platform,
167
433
  gitVersion: version,
168
434
  repoRoot: tower.root,
169
435
  commonDir: tower.commonDir,
170
436
  stateDir: tower.paths.dir,
437
+ caseInsensitive,
438
+ comparisonMode: comparison,
171
439
  gitTimeoutMs: Number(tower.config.git?.timeoutMs) || 15000,
172
440
  mergeTimeoutMs: Number(tower.config.merge?.timeoutMs) || 900000,
173
441
  testsAdmission: tower.config.tests?.admission ?? 'off',
442
+ hostVersion: detectedHost,
443
+ hostCompatibility: hostAssessment.verdict,
444
+ hostCompatibilityDetail: hostAssessment.detail,
174
445
  },
175
446
  mode: agent ? 'ready' : 'unknown',
176
447
  }
package/lib/git.js CHANGED
@@ -1,17 +1,26 @@
1
+ import { TowerToolError } from '../domain/errors.js'
1
2
  // Minimal git port: array arguments only, bounded output, no shell.
2
- import { execFile } from 'node:child_process'
3
+ import { execFile } from '../adapters/vcs-git.js'
3
4
  import path from 'node:path'
4
5
 
5
6
  const GIT_TIMEOUT_MS = 15000
6
7
  const MAX_BUFFER = 16 * 1024 * 1024
7
8
  let configuredTimeoutMs = GIT_TIMEOUT_MS
8
9
 
10
+ /** @typedef {{ timeoutMs?: number, maxBuffer?: number }} GitOptions */
11
+ /** @typedef {{ path: string, branch?: string, head?: string }} WorktreeEntry */
12
+
9
13
  /** Override the default Git timeout from resolved Tower config. */
10
14
  export function setGitTimeout(ms) {
11
15
  const value = Number(ms)
12
16
  if (Number.isFinite(value) && value >= 1000) configuredTimeoutMs = value
13
17
  }
14
18
 
19
+ /** Process default retained for manual adapter use; Tower instances pass their own. */
20
+ export function configuredGitTimeout() {
21
+ return configuredTimeoutMs
22
+ }
23
+
15
24
  const BLOCKED_GIT_ENV = new Set([
16
25
  'GIT_DIR',
17
26
  'GIT_WORK_TREE',
@@ -29,8 +38,13 @@ const BLOCKED_GIT_ENV = new Set([
29
38
  'GIT_QUARANTINE_PATH',
30
39
  ])
31
40
 
32
- /** Git must not inherit a repository redirect from the DSH process environment. */
41
+ /**
42
+ * Git must not inherit a repository redirect from the DSH process environment.
43
+ * @param {NodeJS.ProcessEnv} [source]
44
+ * @returns {NodeJS.ProcessEnv}
45
+ */
33
46
  export function gitEnv(source = process.env) {
47
+ /** @type {NodeJS.ProcessEnv} */
34
48
  const env = {}
35
49
  for (const [key, value] of Object.entries(source)) {
36
50
  if (BLOCKED_GIT_ENV.has(key)) continue
@@ -39,17 +53,16 @@ export function gitEnv(source = process.env) {
39
53
  return env
40
54
  }
41
55
 
42
- export class GitError extends Error {
43
- constructor(args, detail, code, meta = {}) {
44
- super(`git ${args.join(' ')} failed: ${detail}`)
45
- this.name = 'GitError'
46
- this.code = code
47
- this.timedOut = meta.timedOut === true
48
- this.bufferOverflow = meta.bufferOverflow === true
49
- this.stdout = meta.stdout ?? ''
50
- }
51
- }
56
+ import { GitError } from '../domain/errors.js'
57
+ export { GitError }
52
58
 
59
+ /**
60
+ * Run one git command with bounded output and no shell.
61
+ * @param {string} cwd
62
+ * @param {string[]} args
63
+ * @param {GitOptions} [options]
64
+ * @returns {Promise<string>}
65
+ */
53
66
  export function git(cwd, args, options = {}) {
54
67
  return new Promise((resolve, reject) => {
55
68
  execFile(
@@ -82,6 +95,13 @@ export function git(cwd, args, options = {}) {
82
95
  })
83
96
  }
84
97
 
98
+ /**
99
+ * Run git when its failure is not an error (existence probes).
100
+ * @param {string} cwd
101
+ * @param {string[]} args
102
+ * @param {GitOptions} [options]
103
+ * @returns {Promise<string | null>}
104
+ */
85
105
  export async function tryGit(cwd, args, options) {
86
106
  try {
87
107
  return await git(cwd, args, options)
@@ -93,17 +113,29 @@ export async function tryGit(cwd, args, options) {
93
113
  /**
94
114
  * Resolve an absolute caller path to the repository root, its common git dir
95
115
  * (shared by all worktrees), and the current branch of that checkout.
116
+ * @param {string} repoRoot
117
+ * @returns {Promise<{ root: string, commonDir: string, branch: string | null }>}
96
118
  */
97
119
  export async function resolveRepo(repoRoot) {
98
120
  if (typeof repoRoot !== 'string' || repoRoot.trim().length === 0) {
99
- throw new Error('repoRoot is required')
121
+ throw new TowerToolError('repo-invalid', 'repoRoot is required')
100
122
  }
101
123
  if (!path.isAbsolute(repoRoot)) {
102
- throw new Error(`repoRoot must be an absolute path: ${repoRoot}`)
124
+ throw new TowerToolError('repo-invalid', `repoRoot must be an absolute path: ${repoRoot}`)
125
+ }
126
+ let top
127
+ try {
128
+ top = await git(repoRoot, ['rev-parse', '--show-toplevel'])
129
+ } catch (error) {
130
+ throw new TowerToolError('repo-invalid', `not a git repository: ${repoRoot}`, { cause: error })
103
131
  }
104
- const top = await git(repoRoot, ['rev-parse', '--show-toplevel'])
105
132
  const root = path.resolve(top)
106
- const commonRaw = await git(root, ['rev-parse', '--git-common-dir'])
133
+ let commonRaw
134
+ try {
135
+ commonRaw = await git(root, ['rev-parse', '--git-common-dir'])
136
+ } catch (error) {
137
+ throw new TowerToolError('repo-invalid', `not a git repository: ${root}`, { cause: error })
138
+ }
107
139
  const commonDir = path.isAbsolute(commonRaw)
108
140
  ? path.resolve(commonRaw)
109
141
  : path.resolve(root, commonRaw)
@@ -115,25 +147,30 @@ export async function resolveRepo(repoRoot) {
115
147
  }
116
148
  }
117
149
 
150
+ /** @param {string} root @returns {Promise<boolean>} */
118
151
  export async function hasAnyCommit(root) {
119
152
  const out = await tryGit(root, ['rev-list', '-n', '1', '--all'])
120
153
  return Boolean(out && out.trim())
121
154
  }
122
155
 
156
+ /** @param {string} root @param {string} branch @returns {Promise<boolean>} */
123
157
  export async function localBranchExists(root, branch) {
124
158
  const out = await tryGit(root, ['show-ref', '--verify', '--quiet', `refs/heads/${branch}`])
125
159
  return out !== null
126
160
  }
127
161
 
162
+ /** @param {string} cwd @param {string} ref @returns {Promise<string>} */
128
163
  export async function revParse(cwd, ref) {
129
164
  const out = await git(cwd, ['rev-parse', ref])
130
165
  return out.trim()
131
166
  }
132
167
 
168
+ /** @param {string} cwd @param {string} branch @returns {Promise<boolean>} */
133
169
  export async function branchExists(cwd, branch) {
134
170
  return (await localBranchExists(cwd, branch)) === true
135
171
  }
136
172
 
173
+ /** @param {string} cwd @returns {Promise<string | null>} */
137
174
  export async function currentBranch(cwd) {
138
175
  const out = await tryGit(cwd, ['symbolic-ref', '--short', '-q', 'HEAD'])
139
176
  return out && out.trim() ? out.trim() : null
@@ -144,9 +181,12 @@ export async function addWorktree(cwd, { path: worktreePath, branch, commit }) {
144
181
  return worktreePath
145
182
  }
146
183
 
184
+ /** @param {string} cwd @returns {Promise<WorktreeEntry[]>} */
147
185
  export async function listWorktrees(cwd) {
148
186
  const out = await git(cwd, ['worktree', 'list', '--porcelain'])
187
+ /** @type {WorktreeEntry[]} */
149
188
  const entries = []
189
+ /** @type {WorktreeEntry | null} */
150
190
  let current = null
151
191
  for (const line of out.split('\n')) {
152
192
  if (line.startsWith('worktree ')) {
@@ -162,22 +202,26 @@ export async function listWorktrees(cwd) {
162
202
  return entries
163
203
  }
164
204
 
205
+ /** @param {string} cwd @returns {Promise<boolean>} */
165
206
  export async function isClean(cwd) {
166
207
  const out = await git(cwd, ['status', '--porcelain'])
167
208
  return out.trim().length === 0
168
209
  }
169
210
 
211
+ /** @param {string} cwd @returns {Promise<string | null>} */
170
212
  export async function currentCommonDir(cwd) {
171
213
  const raw = await tryGit(cwd, ['rev-parse', '--git-common-dir'])
172
214
  if (raw === null) return null
173
215
  return path.isAbsolute(raw) ? path.resolve(raw) : path.resolve(cwd, raw)
174
216
  }
175
217
 
218
+ /** @param {string} cwd @param {string} fromRef @returns {Promise<number>} */
176
219
  export async function countCommits(cwd, fromRef) {
177
220
  const out = await git(cwd, ['rev-list', '--count', `${fromRef}..HEAD`])
178
221
  return Number(out.trim() || 0)
179
222
  }
180
223
 
224
+ /** @param {string} cwd @param {string} ancestor @param {string} descendant @returns {Promise<boolean>} */
181
225
  export async function isAncestor(cwd, ancestor, descendant) {
182
226
  const out = await tryGit(cwd, ['merge-base', '--is-ancestor', ancestor, descendant])
183
227
  return out !== null
package/lib/hmr.js CHANGED
@@ -18,10 +18,11 @@
18
18
  // `restartReason` means the whole process must be restarted.
19
19
  // - `runExclusive` rejects nested transactions; a tool call is never inside
20
20
  // one, so a tool-triggered reload is safe.
21
- import { TowerToolError } from './errors.js'
21
+ import { TowerToolError } from '../domain/errors.js'
22
22
 
23
23
  /** Entry ids Tower expects to be active after a reconciliation. */
24
24
  export const DEFAULT_REQUIRED_IDS = Object.freeze([
25
+ 'tower-bundle',
25
26
  'tower',
26
27
  'tower-skills',
27
28
  'preset-tower-worker',
@@ -94,7 +95,7 @@ function warn(logger, message) {
94
95
  * @param {string[]} [options.requiredIds] Entry ids whose inactivity is reported as a failure.
95
96
  * @param {number} [options.delayMs] Delay before a scheduled reload runs.
96
97
  * @param {{ warn?: Function }} [options.logger] Optional logger for failed scheduled reloads.
97
- * @returns {{ available: boolean, requiredIds: string[], delayMs: number, status: Function, schedule: Function, runNow: Function }}
98
+ * @returns {{ available: boolean, requiredIds: string[], delayMs: number, status: Function, schedule: Function, runNow: Function, dispose: Function }}
98
99
  */
99
100
  export function createHmrBridge(ctx, options = {}) {
100
101
  const requiredIds = Object.freeze(normalizeRequiredIds(options.requiredIds))
@@ -186,7 +187,7 @@ export function createHmrBridge(ctx, options = {}) {
186
187
  get available() {
187
188
  return available()
188
189
  },
189
- requiredIds,
190
+ requiredIds: [...requiredIds],
190
191
  delayMs,
191
192
  status,
192
193
  schedule,
package/lib/leases.js CHANGED
@@ -11,7 +11,7 @@
11
11
  // Fail-closed protocol: a held, expired, or token-mismatched lease is reported,
12
12
  // never silently taken over. Only the token holder may release a lease, and
13
13
  // releasing an expired lease requires an explicit `force: true`.
14
- import fsp from 'node:fs/promises'
14
+ import { fsp } from '../adapters/store-fs.js'
15
15
  import path from 'node:path'
16
16
  import crypto from 'node:crypto'
17
17
 
@@ -27,7 +27,13 @@ const LOCK_STALE_MS = 10000
27
27
  const LOCK_POLL_MS = 20
28
28
  const NAME_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/
29
29
 
30
+ // Exported with the retry loop so a test can assert the budget and the exact
31
+ // transient set instead of copying them (#86).
32
+ export const UNLINK_ATTEMPTS = 12
33
+ export const TRANSIENT_UNLINK_ERRORS = new Set(['EPERM', 'EBUSY', 'EACCES'])
34
+
30
35
  function fail(code, message) {
36
+ /** @type {Error & {code?: string}} */
31
37
  const error = new Error(message)
32
38
  error.code = code
33
39
  throw error
@@ -56,17 +62,21 @@ export function leasesLockPath(commonDir) {
56
62
  /**
57
63
  * Windows can reject unlink while an indexer, antivirus, or a concurrent
58
64
  * reader still holds the file; retry briefly instead of failing the mutation.
65
+ *
66
+ * `options.unlink` and `options.sleep` inject the syscall and the backoff so
67
+ * both outcomes are testable on any OS (#86); production callers omit them.
59
68
  */
60
- async function unlinkWithRetry(file) {
69
+ export async function unlinkWithRetry(file, options = {}) {
70
+ const unlink = options.unlink ?? fsp.unlink
71
+ const wait = options.sleep ?? sleep
61
72
  for (let attempt = 0; ; attempt += 1) {
62
73
  try {
63
- await fsp.unlink(file)
74
+ await unlink(file)
64
75
  return true
65
76
  } catch (error) {
66
77
  if (error?.code === 'ENOENT') return false
67
- const transient = error?.code === 'EPERM' || error?.code === 'EBUSY' || error?.code === 'EACCES'
68
- if (!transient || attempt >= 12) throw error
69
- await sleep(10 + attempt * 15)
78
+ if (!TRANSIENT_UNLINK_ERRORS.has(error?.code) || attempt >= UNLINK_ATTEMPTS) throw error
79
+ await wait(10 + attempt * 15)
70
80
  }
71
81
  }
72
82
  }
@@ -147,7 +157,7 @@ async function lockIsStale(lockFile) {
147
157
  } catch {
148
158
  at = null
149
159
  }
150
- const age = Number.isFinite(at) ? Date.now() - at : Date.now() - stat.mtimeMs
160
+ const age = at !== null && Number.isFinite(at) ? Date.now() - at : Date.now() - stat.mtimeMs
151
161
  return age > LOCK_STALE_MS
152
162
  }
153
163