dsh-tower 2.0.4 → 2.1.0-beta.1

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 (171) hide show
  1. package/CHANGELOG.md +101 -0
  2. package/COMPATIBILITY.md +38 -0
  3. package/CONTRIBUTING.md +150 -2
  4. package/README.en.md +40 -3
  5. package/README.md +38 -3
  6. package/SECURITY.md +27 -0
  7. package/adapters/default.js +10 -0
  8. package/adapters/store-fs.js +6 -0
  9. package/adapters/vcs-git.js +4 -0
  10. package/app/batch.js +79 -0
  11. package/app/board.js +25 -0
  12. package/app/checkCompletion.js +76 -0
  13. package/app/enrichedMissions.js +26 -0
  14. package/app/findings.js +78 -0
  15. package/app/gate.js +108 -0
  16. package/app/init.js +249 -0
  17. package/app/merge.js +567 -0
  18. package/app/messages.js +66 -0
  19. package/app/mission.js +35 -0
  20. package/app/missionContext.js +14 -0
  21. package/app/plan.js +42 -0
  22. package/app/provision.js +193 -0
  23. package/app/read.js +57 -0
  24. package/app/rebase.js +302 -0
  25. package/app/recording.js +72 -0
  26. package/app/review.js +145 -0
  27. package/app/roster.js +123 -0
  28. package/app/spawn.js +125 -0
  29. package/app/teardown.js +184 -0
  30. package/app/tower.js +524 -0
  31. package/cordis.patch.yml +61 -53
  32. package/docs/README.md +3 -1
  33. package/docs/architecture/overview.md +111 -12
  34. package/docs/board.md +72 -0
  35. package/docs/guide/hot-reload.md +1 -1
  36. package/docs/guide/install-upgrade-migration.md +8 -1
  37. package/docs/guide/release.md +125 -0
  38. package/docs/operations/security.md +47 -2
  39. package/docs/reference/config.generated.md +66 -0
  40. package/docs/reference/config.md +4 -1
  41. package/docs/reference/errors.generated.md +39 -0
  42. package/docs/reference/errors.md +4 -2
  43. package/docs/reference/tools.generated.md +33 -0
  44. package/docs/reference/tools.md +1 -1
  45. package/docs/zh/README.md +3 -2
  46. package/docs/zh/guide/hot-reload.md +1 -1
  47. package/docs/zh/guide/install-upgrade-migration.md +6 -1
  48. package/docs/zh/operations/security.md +30 -1
  49. package/docs/zh/reference/config.md +4 -1
  50. package/docs/zh/reference/errors.md +3 -1
  51. package/docs/zh/reference/tools.md +1 -1
  52. package/domain/collab/batch.js +112 -0
  53. package/domain/collab/messages.js +127 -0
  54. package/domain/collab/roster.js +158 -0
  55. package/domain/collab/spawn.js +188 -0
  56. package/domain/config/schema.js +404 -0
  57. package/domain/contracts/outputs.js +279 -0
  58. package/domain/contracts/tool-inputs/collaboration.js +58 -0
  59. package/domain/contracts/tool-inputs/findings.js +29 -0
  60. package/domain/contracts/tool-inputs/index.js +94 -0
  61. package/domain/contracts/tool-inputs/integration.js +37 -0
  62. package/domain/contracts/tool-inputs/missions.js +94 -0
  63. package/domain/contracts/tool-inputs/ops.js +75 -0
  64. package/domain/contracts/tool-inputs/shared.js +25 -0
  65. package/domain/errors.js +96 -0
  66. package/domain/gate.js +125 -0
  67. package/domain/integration/gate.js +176 -0
  68. package/domain/integration/init.js +225 -0
  69. package/domain/integration/liveness.js +31 -0
  70. package/domain/integration/merge.js +377 -0
  71. package/domain/integration/provision.js +124 -0
  72. package/domain/integration/rebase.js +236 -0
  73. package/domain/integration/review.js +52 -0
  74. package/domain/integration/teardown.js +118 -0
  75. package/domain/mission.js +68 -0
  76. package/domain/missions/completion.js +114 -0
  77. package/domain/missions/findings.js +201 -0
  78. package/domain/missions/lifecycle.js +185 -0
  79. package/domain/missions/plan.js +149 -0
  80. package/domain/missions/review.js +179 -0
  81. package/domain/planning.js +118 -0
  82. package/domain/read/enrichedMissions.js +58 -0
  83. package/domain/read/missionContext.js +53 -0
  84. package/domain/read/views.js +128 -0
  85. package/domain/report.js +59 -0
  86. package/domain/roster.js +40 -0
  87. package/domain/scope.js +105 -0
  88. package/domain/state/schema.js +233 -0
  89. package/domain/state/semantics.js +117 -0
  90. package/domain/state.js +33 -0
  91. package/domain/summary.js +114 -0
  92. package/domain/vocabulary.js +179 -0
  93. package/index.js +63 -20
  94. package/lib/briefing.js +176 -0
  95. package/lib/compatibility.js +108 -0
  96. package/lib/config.js +33 -375
  97. package/lib/doctor.js +97 -4
  98. package/lib/git.js +60 -16
  99. package/lib/hmr.js +4 -3
  100. package/lib/leases.js +3 -2
  101. package/lib/legacy.js +20 -9
  102. package/lib/modeState.js +39 -0
  103. package/lib/modeStore.js +11 -43
  104. package/lib/persistence.js +349 -0
  105. package/lib/presets.js +35 -0
  106. package/lib/spawn-prechecks.js +168 -0
  107. package/lib/spawn-support.js +79 -0
  108. package/lib/store.js +54 -137
  109. package/lib/tests.js +11 -10
  110. package/lib/tool-registry.js +544 -0
  111. package/lib/workspaceLock.js +2 -2
  112. package/lib/worktreeOwnership.js +5 -4
  113. package/locale/en.json +23 -1
  114. package/locale/zh.json +22 -0
  115. package/package.json +32 -4
  116. package/ports/clock.js +6 -0
  117. package/ports/ids.js +7 -0
  118. package/ports/index.js +2 -0
  119. package/ports/store.js +11 -0
  120. package/ports/vcs.js +11 -0
  121. package/scripts/architecture.mjs +94 -0
  122. package/scripts/check-docs.mjs +9 -3
  123. package/scripts/check-pack.mjs +49 -0
  124. package/scripts/domain-metrics.mjs +48 -0
  125. package/scripts/generate-config-reference.mjs +46 -0
  126. package/scripts/generate-error-reference.mjs +29 -0
  127. package/scripts/generate-tools-reference.mjs +56 -0
  128. package/scripts/issue-audit.mjs +259 -0
  129. package/scripts/lib/cli.mjs +76 -0
  130. package/scripts/lib/tmp.mjs +20 -0
  131. package/scripts/mount-smoke/fixtures.mjs +162 -0
  132. package/scripts/mount-smoke/host.json +4 -0
  133. package/scripts/mount-smoke/probe.mjs +125 -0
  134. package/scripts/mount-smoke/surface.mjs +141 -0
  135. package/scripts/mount-smoke.mjs +270 -0
  136. package/scripts/pack-manifest.txt +203 -0
  137. package/scripts/release-check.mjs +303 -0
  138. package/tools/adopt.js +72 -0
  139. package/tools/batch.js +78 -0
  140. package/tools/complete.js +53 -0
  141. package/tools/diff.js +39 -0
  142. package/tools/doctor.js +50 -0
  143. package/tools/finding.js +40 -0
  144. package/tools/findings.js +39 -0
  145. package/tools/import-legacy.js +42 -0
  146. package/tools/inbox.js +39 -0
  147. package/tools/index.js +39 -0
  148. package/tools/init.js +63 -0
  149. package/tools/interrupt.js +42 -0
  150. package/tools/legacy-map.js +44 -0
  151. package/tools/merge.js +39 -0
  152. package/tools/mission.js +41 -0
  153. package/tools/plan.js +39 -0
  154. package/tools/preflight.js +44 -0
  155. package/tools/rebase.js +39 -0
  156. package/tools/recover.js +45 -0
  157. package/tools/reload.js +52 -0
  158. package/tools/resource.js +54 -0
  159. package/tools/review.js +45 -0
  160. package/tools/roster.js +60 -0
  161. package/tools/send.js +147 -0
  162. package/tools/spawn.js +196 -0
  163. package/tools/status.js +94 -0
  164. package/tools/teardown.js +42 -0
  165. package/tools/test.js +61 -0
  166. package/types/cordis.d.ts +6 -0
  167. package/types/index.d.ts +339 -0
  168. package/lib/errors.js +0 -123
  169. package/lib/tools.js +0 -2460
  170. package/lib/tower.js +0 -2912
  171. /package/{lib → domain}/batch.js +0 -0
@@ -0,0 +1,544 @@
1
+ // Tower tool registration and shared machinery (#56 P5).
2
+ //
3
+ // Per-tool handlers live in tools/*.js; this module owns the registration
4
+ // aggregator, actor guards, spawn throttling, repository preparation, and the
5
+ // best-effort roster watchdog. tools/index.js stays a thin public wiring module.
6
+ import { Tower } from '../app/tower.js'
7
+ import { TowerToolError, classifyError } from '../domain/errors.js'
8
+ import {
9
+ PARENT_STATUS_OK,
10
+ ROLE,
11
+ ROSTER_ACTIVITY,
12
+ ROSTER_INACTIVE_STATES,
13
+ isRunningActivity,
14
+ isTowerRole,
15
+ isUnknownRole,
16
+ } from '../domain/vocabulary.js'
17
+ import { git, resolveRepo, hasAnyCommit } from './git.js'
18
+ import { runtimeConfig } from './config.js'
19
+ import { registerDoctorTool } from '../tools/doctor.js'
20
+ import { registerRecoverTool } from '../tools/recover.js'
21
+ import { registerTestTool } from '../tools/test.js'
22
+ import { registerBatchTool } from '../tools/batch.js'
23
+ import { registerResourceTool } from '../tools/resource.js'
24
+ import { registerImportLegacyTool } from '../tools/import-legacy.js'
25
+ import { registerLegacyMapTool } from '../tools/legacy-map.js'
26
+ import { registerReloadTool } from '../tools/reload.js'
27
+ import { registerInitTool } from '../tools/init.js'
28
+ import { registerPlanTool } from '../tools/plan.js'
29
+ import { registerMissionTool } from '../tools/mission.js'
30
+ import { registerStatusTool } from '../tools/status.js'
31
+ import { registerRosterTool } from '../tools/roster.js'
32
+ import { registerInterruptTool } from '../tools/interrupt.js'
33
+ import { registerDiffTool } from '../tools/diff.js'
34
+ import { registerAdoptTool } from '../tools/adopt.js'
35
+ import { registerSendTool } from '../tools/send.js'
36
+ import { registerInboxTool } from '../tools/inbox.js'
37
+ import { registerReviewTool } from '../tools/review.js'
38
+ import { registerPreflightTool } from '../tools/preflight.js'
39
+ import { registerMergeTool } from '../tools/merge.js'
40
+ import { registerRebaseTool } from '../tools/rebase.js'
41
+ import { registerTeardownTool } from '../tools/teardown.js'
42
+ import { registerSpawnTool } from '../tools/spawn.js'
43
+ import { registerCompleteTool } from '../tools/complete.js'
44
+ import { registerFindingTool } from '../tools/finding.js'
45
+ import { registerFindingsTool } from '../tools/findings.js'
46
+ import { readDiscoveryConfig, normalizeDiscovery, runDiscovery } from './tests.js'
47
+ import { summarize as summarizeBatch } from '../domain/batch.js'
48
+ import path from 'node:path'
49
+ import { fsp } from '../adapters/store-fs.js'
50
+ import crypto from 'node:crypto'
51
+
52
+ // Mutable composition state is grouped in one const object: the registration
53
+ // aggregator is the composition root, not an ambient module-level binding.
54
+ const runtimeState = {
55
+ workspaceHook: null,
56
+ hmrBridge: null,
57
+ }
58
+
59
+ function normalizeToolArgs(args, aliases = {}) {
60
+ const out = { ...(args ?? {}) }
61
+ for (const [canonical, names] of Object.entries(aliases)) {
62
+ const current = out[canonical]
63
+ if (current !== undefined && current !== null) continue
64
+ for (const name of names) {
65
+ if (out[name] !== undefined && out[name] !== null) {
66
+ out[canonical] = out[name]
67
+ break
68
+ }
69
+ }
70
+ }
71
+ return out
72
+ }
73
+
74
+ /** Resolve the calling agent to a roster role; unknown callers are rejected. */
75
+ async function actorFor(tower, exec) {
76
+ const agentId = exec?.agent?.id !== undefined ? String(exec.agent.id) : null
77
+ const unknown = { name: ROLE.unknown, role: ROLE.unknown, agentId, ownerMissing: false }
78
+ if (!agentId) return unknown
79
+ let state
80
+ try {
81
+ state = await tower.load()
82
+ } catch (error) {
83
+ if (error?.code !== 'not-initialized') throw error
84
+ // No readable state: only tower_init may claim ownership (handled in that tool).
85
+ return unknown
86
+ }
87
+ if (!state.towerAgentId) unknown.ownerMissing = true
88
+ if (state.towerAgentId && String(state.towerAgentId) === agentId) {
89
+ return { name: ROLE.tower, role: ROLE.tower, agentId }
90
+ }
91
+ let view
92
+ try {
93
+ view = await tower.rosterView()
94
+ } catch {
95
+ return unknown
96
+ }
97
+ const self = view.roster.find((entry) => entry.agentId === agentId)
98
+ if (!self) return unknown
99
+ if (isTowerRole(self.role)) return { name: self.name, role: ROLE.tower, agentId }
100
+ return { name: self.name, role: self.role, agentId }
101
+ }
102
+
103
+ function requireKnownActor(actor, action) {
104
+ if (!actor || isUnknownRole(actor.role)) {
105
+ throw new TowerToolError('not-authorized', `${action} requires a registered roster agent or the tower${actor?.ownerMissing ? '; repository state has no towerAgentId — run tower_init to re-claim' : ''}`)
106
+ }
107
+ }
108
+
109
+ function requireTowerActor(actor, action) {
110
+ if (!isTowerRole(actor.role)) {
111
+ throw new TowerToolError('not-authorized', `only the tower can ${action}; ${actor.name} is a ${actor.role}${actor.ownerMissing ? '; repository state has no towerAgentId — run tower_init to re-claim' : ''}`)
112
+ }
113
+ }
114
+
115
+ const SEND_RATE = new Map()
116
+ const SPAWN_QUEUES = new Map()
117
+ const SPAWN_BACKOFF = new Map()
118
+
119
+ function spawnBackoffState(repoRoot, maxLiveWorkers) {
120
+ const key = String(repoRoot)
121
+ let state = SPAWN_BACKOFF.get(key)
122
+ if (!state) {
123
+ state = { failures: 0, blockedUntil: 0, budget: Math.max(1, Number(maxLiveWorkers) || 4) }
124
+ SPAWN_BACKOFF.set(key, state)
125
+ }
126
+ if (state.failures === 0) state.budget = Math.max(1, Number(maxLiveWorkers) || 4)
127
+ return state
128
+ }
129
+
130
+ function recordSpawnThrottle(repoRoot, maxLiveWorkers) {
131
+ const state = spawnBackoffState(repoRoot, maxLiveWorkers)
132
+ state.failures += 1
133
+ state.budget = Math.max(1, (Number(maxLiveWorkers) || 4) - 1)
134
+ if (state.failures >= 3) {
135
+ const delay = Math.min(60000, 5000 * 2 ** Math.min(4, state.failures - 3))
136
+ state.blockedUntil = Date.now() + delay
137
+ }
138
+ return state
139
+ }
140
+
141
+ function recordSpawnSuccess(repoRoot, maxLiveWorkers) {
142
+ const state = spawnBackoffState(repoRoot, maxLiveWorkers)
143
+ state.failures = 0
144
+ state.blockedUntil = 0
145
+ state.budget = Math.max(1, Number(maxLiveWorkers) || 4)
146
+ return state
147
+ }
148
+
149
+ function spawnBackoffView(repoRoot, maxLiveWorkers, inflight) {
150
+ const state = spawnBackoffState(repoRoot, maxLiveWorkers)
151
+ return {
152
+ inflight,
153
+ budget: Math.min(state.budget, Math.max(1, Number(maxLiveWorkers) || 4)),
154
+ blockedUntil: state.blockedUntil > Date.now() ? state.blockedUntil : 0,
155
+ }
156
+ }
157
+ const WATCHED_REPOS = new Set()
158
+ function watchRepo(repoRoot) {
159
+ WATCHED_REPOS.add(repoRoot)
160
+ if (WATCHED_REPOS.size > 100) {
161
+ const oldest = WATCHED_REPOS.values().next().value
162
+ WATCHED_REPOS.delete(oldest)
163
+ }
164
+ }
165
+
166
+ /** Serialize spawn decisions per repository so precheck + activation + record are atomic. */
167
+ function enqueueSpawn(key, operation) {
168
+ const previous = SPAWN_QUEUES.get(key) ?? Promise.resolve()
169
+ const run = previous.catch(() => {}).then(operation)
170
+ const settled = run.catch(() => {})
171
+ SPAWN_QUEUES.set(key, settled)
172
+ settled.finally(() => {
173
+ if (SPAWN_QUEUES.get(key) === settled) SPAWN_QUEUES.delete(key)
174
+ })
175
+ return run
176
+ }
177
+
178
+ /** In-memory per-repository tower_send rate limit (process-local, advisory). */
179
+ function checkSendRate(config, repoRoot) {
180
+ const max = Number(config.send?.maxPerMinute)
181
+ if (!max || max <= 0) return
182
+ const key = String(repoRoot)
183
+ const now = Date.now()
184
+ const bucket = SEND_RATE.get(key)
185
+ if (!bucket || now - bucket.start >= 60000) {
186
+ SEND_RATE.set(key, { start: now, count: 1 })
187
+ return
188
+ }
189
+ if (bucket.count >= max) {
190
+ throw new TowerToolError('rate-limited', `tower_send rate limit reached (${max}/min for ${key}); retry later or raise send.maxPerMinute`)
191
+ }
192
+ bucket.count += 1
193
+ }
194
+
195
+ async function verifyBatchReceiptFiles(root, batch, args) {
196
+ const problems = []
197
+ const frozen = new Set(batch.receiptPaths ?? [])
198
+ const declared = new Map()
199
+ for (const [rawPath, rawHash] of Object.entries(args.receiptHashes ?? {})) {
200
+ declared.set(String(rawPath), String(rawHash).trim().toLowerCase())
201
+ }
202
+ for (const entry of args.receipts ?? []) {
203
+ if (entry && typeof entry.path === 'string' && typeof entry.hash === 'string') {
204
+ declared.set(entry.path, entry.hash.trim().toLowerCase())
205
+ }
206
+ }
207
+ for (const [rel, reported] of declared) {
208
+ if (frozen.size > 0 && !frozen.has(rel)) continue
209
+ let actual
210
+ try {
211
+ const abs = path.resolve(root, rel)
212
+ const relative = path.relative(root, abs)
213
+ if (relative.startsWith('..') || path.isAbsolute(relative)) throw new TowerToolError('invalid-argument', 'path escapes the repository')
214
+ actual = crypto.createHash('sha256').update(await fsp.readFile(abs)).digest('hex')
215
+ } catch (error) {
216
+ problems.push({ code: 'batch-invalid', detail: `receipt "${rel}" cannot be read: ${String(error?.message ?? error)}` })
217
+ continue
218
+ }
219
+ if (reported && reported !== actual) {
220
+ problems.push({ code: 'batch-invalid', detail: `receipt "${rel}" hash ${reported.slice(0, 12)} does not match the file (${actual.slice(0, 12)})` })
221
+ }
222
+ }
223
+ return problems
224
+ }
225
+
226
+ async function interruptRosterAgent(ctx, tower, exec, input) {
227
+ const subagents = ctx.get('subagents')
228
+ if (!subagents || typeof subagents.interrupt !== 'function') {
229
+ throw new TowerToolError('spawn-unavailable', 'subagents service is unavailable; cannot interrupt')
230
+ }
231
+ const view = await tower.rosterView()
232
+ const entry = view.roster.find(
233
+ (row) =>
234
+ (input.agentId !== undefined && row.agentId === String(input.agentId)) ||
235
+ (input.to !== undefined && row.name === input.to),
236
+ )
237
+ if (!entry) throw new TowerToolError('invalid-argument', `unknown roster agent "${input.to ?? input.agentId}"`)
238
+ const { activity } = await observeActivity(ctx, tower)
239
+ const previousActivity = activity[entry.agentId] ?? ROSTER_ACTIVITY.unknown
240
+ if (!isRunningActivity(previousActivity)) {
241
+ return { interrupted: false, reason: `agent is ${previousActivity}, not running`, entry }
242
+ }
243
+ subagents.interrupt(entry.agentId, { kind: 'ancestor', agent: exec.agent })
244
+ const updated = await tower.recordInterrupt({
245
+ agentId: entry.agentId,
246
+ name: entry.name,
247
+ reason: input.reason ?? 'interrupted by tower',
248
+ })
249
+ return { interrupted: true, previousActivity, entry: updated }
250
+ }
251
+
252
+ /** autoGitInit support: Tower.open needs a repository, so prepare it first.
253
+ * Only absolute, existing directories are eligible; a typo must not git-init the
254
+ * DSH process cwd or create a repository at an unintended path. */
255
+ async function ensureRepository(repoRootInput, autoGitInit) {
256
+ try {
257
+ return await resolveRepo(repoRootInput)
258
+ } catch (error) {
259
+ if (autoGitInit !== true) throw error
260
+ if (typeof repoRootInput !== 'string' || !path.isAbsolute(repoRootInput)) {
261
+ throw new TowerToolError('repo-invalid', `autoGitInit requires an absolute directory path: ${repoRootInput}`)
262
+ }
263
+ const resolved = path.resolve(repoRootInput)
264
+ let stat = null
265
+ try {
266
+ stat = await fsp.stat(resolved)
267
+ } catch {
268
+ stat = null
269
+ }
270
+ if (!stat || !stat.isDirectory()) {
271
+ throw new TowerToolError('repo-invalid', `autoGitInit requires an existing directory: ${resolved}`)
272
+ }
273
+ if (resolved === path.parse(resolved).root) {
274
+ throw new TowerToolError('repo-invalid', 'autoGitInit refuses to initialize a filesystem root')
275
+ }
276
+ await git(resolved, ['init'])
277
+ const repo = await resolveRepo(resolved)
278
+ if (!(await hasAnyCommit(repo.root))) {
279
+ await git(repo.root, [
280
+ '-c',
281
+ 'user.name=dsh-tower',
282
+ '-c',
283
+ 'user.email=dsh-tower@localhost',
284
+ 'commit',
285
+ '--allow-empty',
286
+ '-m',
287
+ 'tower: initial empty commit',
288
+ ])
289
+ }
290
+ return repo
291
+ }
292
+ }
293
+
294
+ const OPERATIONAL_TOOLS = new Map([
295
+ ['tower_batch', 'exposeBatch'],
296
+ ['tower_resource', 'exposeLeases'],
297
+ ['tower_import_legacy', 'exposeImport'],
298
+ ['tower_legacy_map', 'exposeImport'],
299
+ ['tower_recover', 'exposeRecovery'],
300
+ ['tower_test', 'exposeTests'],
301
+ ['tower_doctor', 'exposeDoctor'],
302
+ ])
303
+
304
+ function plainSchema(value) {
305
+ return JSON.parse(JSON.stringify(value))
306
+ }
307
+
308
+ /**
309
+ * Register one tool. The TypeBox schema is the single source of truth for the
310
+ * runtime JSON Schema and the handler's argument type.
311
+ *
312
+ * @template {import('@sinclair/typebox').TSchema} S
313
+ * @param {any} ctx
314
+ * @param {{
315
+ * name: string,
316
+ * description: string,
317
+ * parameters: S,
318
+ * execute: (args: import('@sinclair/typebox').Static<S>, exec: any) => Promise<unknown>,
319
+ * aliases?: any,
320
+ * requiresMissionId?: any,
321
+ * board?: any,
322
+ * output?: any,
323
+ * }} definition
324
+ */
325
+ function define(ctx, definition) {
326
+ const { aliases, requiresMissionId, board, execute, ...rest } = definition
327
+ const toolsConfig = runtimeConfig().tools ?? {}
328
+ const flag = OPERATIONAL_TOOLS.get(rest.name)
329
+ if (flag && (toolsConfig.exposeOperational === false || toolsConfig[flag] === false)) return
330
+ ctx.effect(() =>
331
+ ctx.tools.register({
332
+ ...rest,
333
+ parameters: plainSchema(rest.parameters),
334
+ async execute(args, exec) {
335
+ const normalized = normalizeToolArgs(args, aliases)
336
+ if (requiresMissionId) {
337
+ const id = normalized.missionId
338
+ if (typeof id !== 'string' || id.trim().length === 0) {
339
+ throw new TowerToolError('invalid-argument', `${rest.name} requires missionId (alias: id)`)
340
+ }
341
+ normalized.missionId = id.trim()
342
+ }
343
+ let value
344
+ try {
345
+ if (runtimeState.workspaceHook && typeof normalized.repoRoot === 'string' && rest.name !== 'tower_init') {
346
+ await runtimeState.workspaceHook.ensure(normalized.repoRoot, exec, rest.name)
347
+ }
348
+ value = await execute(normalized, exec)
349
+ } catch (error) {
350
+ throw classifyError(error)
351
+ }
352
+ if (typeof normalized.repoRoot === 'string') watchRepo(normalized.repoRoot)
353
+ const event = rest.name.startsWith('tower_') ? rest.name.slice('tower_'.length) : null
354
+ if (event) {
355
+ try {
356
+ ctx.emit?.(`tower/${event}`, {
357
+ tool: rest.name,
358
+ repoRoot: typeof normalized.repoRoot === 'string' ? normalized.repoRoot : null,
359
+ value: value ?? null,
360
+ })
361
+ } catch {
362
+ // events are an optional side channel; never fail a tool for them
363
+ }
364
+ }
365
+ if (board === true && typeof normalized.repoRoot === 'string') {
366
+ try {
367
+ const opened = await Tower.open(normalized.repoRoot)
368
+ await opened.writeBoard()
369
+ } catch {
370
+ // board output is best effort
371
+ }
372
+ }
373
+ return value
374
+ },
375
+ }),
376
+ )
377
+ }
378
+
379
+ function textOf(blocks) {
380
+ if (!Array.isArray(blocks)) return null
381
+ return blocks
382
+ .map((block) => (block && block.type === 'text' ? block.text : `[${block && block.type}]`))
383
+ .join('\n')
384
+ }
385
+
386
+ /** Read `package.json tower.testDiscovery` and run it for one mission worktree.
387
+ * Returns `{configured:false}` when the repository has no discovery block. */
388
+ async function runMissionDiscovery(tower, mission) {
389
+ const testsConfig = tower.config.tests ?? {}
390
+ if (testsConfig.enabled === false || testsConfig.allowPackageDiscovery === false) {
391
+ return { configured: false }
392
+ }
393
+ const worktreePath = mission?.worktree?.path
394
+ if (!worktreePath) return { configured: false, error: 'mission has no worktree' }
395
+ let packageText
396
+ try {
397
+ packageText = await fsp.readFile(path.join(worktreePath, 'package.json'), 'utf8')
398
+ } catch (error) {
399
+ if (error?.code === 'ENOENT') return { configured: false }
400
+ throw error
401
+ }
402
+ const read = readDiscoveryConfig(packageText)
403
+ if (!read.configured) {
404
+ if (read.error) throw new TowerToolError('invalid-argument', `package.json test discovery is invalid: ${read.error}`)
405
+ return { configured: false, error: null }
406
+ }
407
+ const normalized = normalizeDiscovery(read.config)
408
+ if (!normalized.ok) {
409
+ throw new TowerToolError('invalid-argument', `invalid tower.testDiscovery: ${normalized.error}`)
410
+ }
411
+ const receipt = await runDiscovery({
412
+ worktreePath,
413
+ config: read.config,
414
+ timeoutMs: Number(testsConfig.timeoutMs) || 60000,
415
+ maxBytes: Number(testsConfig.maxBytes) || 16 * 1024 * 1024,
416
+ missionId: mission.id,
417
+ diagnosticsDir: path.join(tower.paths.dir, 'test-runs', `${mission.id}-${Date.now()}`),
418
+ })
419
+ const stored = await tower.recordTestRun({ missionId: mission.id, receipt })
420
+ const commands = Array.isArray(stored.commands) ? stored.commands : []
421
+ const check = {
422
+ name: 'tests',
423
+ command: commands.map((command) => `${command.id}: ${command.command}`).join('; ') || 'tower.testDiscovery',
424
+ exitCode: commands.every((command) => command.exitCode === 0) ? 0 : (commands.find((command) => command.exitCode !== 0)?.exitCode ?? 1),
425
+ evidence: stored.ok
426
+ ? `${stored.tests?.length ?? 0} test(s) passed at ${String(stored.treeTip ?? '').slice(0, 12)}; receipt ${stored.receiptPath ?? stored.diagnosticsPath}`
427
+ : `test discovery failed: ${String(stored.error ?? 'unknown').slice(0, 300)}`,
428
+ }
429
+ return { configured: true, receipt: stored, check }
430
+ }
431
+
432
+ async function observeActivity(ctx, tower) {
433
+ const view = await tower.rosterView()
434
+ const subagents = ctx.get('subagents')
435
+ const activity = {}
436
+ const parentStatus = {}
437
+ if (!subagents) return { activity, parentStatus }
438
+ const parents = [...new Set(view.roster.map((entry) => entry.parentSessionId).filter(Boolean))]
439
+ for (const parent of parents) {
440
+ try {
441
+ const rows = await subagents.listDescendants(parent)
442
+ for (const row of rows) activity[String(row.id)] = row.activity ?? ROSTER_ACTIVITY.unknown
443
+ parentStatus[parent] = PARENT_STATUS_OK
444
+ } catch (error) {
445
+ parentStatus[parent] = `unavailable: ${String(error?.message ?? error)}`
446
+ }
447
+ }
448
+ return { activity, parentStatus }
449
+ }
450
+
451
+ export function registerTowerTools(ctx, options = {}) {
452
+ runtimeState.workspaceHook = options.workspace ?? null
453
+ runtimeState.hmrBridge = options.hmrBridge ?? null
454
+ // A lightweight roster watchdog: the authoritative activity comes from the
455
+ // subagents catalog; a missing observation after spawn.staleMs is marked
456
+ // stale (never silently treated as stopped) and blocks merge via the gate.
457
+ const watchMs = Math.max(1000, Number(runtimeConfig().mode?.heartbeatMs) || 10000)
458
+ const timer = setInterval(() => {
459
+ void (async () => {
460
+ try {
461
+ if (!ctx.get('subagents')) return
462
+ for (const repoRoot of [...WATCHED_REPOS]) {
463
+ const tower = await Tower.open(repoRoot)
464
+ const view = await tower.rosterView()
465
+ const live = view.roster.filter((entry) => !ROSTER_INACTIVE_STATES.includes(entry.state))
466
+ if (live.length === 0) { WATCHED_REPOS.delete(repoRoot); continue }
467
+ const parents = [...new Set(live.map((entry) => entry.parentSessionId).filter(Boolean))]
468
+ if (parents.length === 0) continue
469
+ const { activity, parentStatus } = await observeActivity(ctx, tower)
470
+ const authoritative = parents.every((parent) => parentStatus[parent] === PARENT_STATUS_OK)
471
+ await tower.sweepRoster({
472
+ staleMs: Number(tower.config.spawn?.staleMs) || 0,
473
+ activity,
474
+ authoritative,
475
+ })
476
+ }
477
+ } catch {
478
+ // watchdog failures are best effort; tools still re-check on demand
479
+ }
480
+ })()
481
+ }, watchMs)
482
+ timer.unref?.()
483
+ ctx.effect(() => () => clearInterval(timer))
484
+ registerInitTool(ctx, { define, actorFor, requireTowerActor, workspaceHook: runtimeState.workspaceHook, ensureRepository })
485
+
486
+ registerPlanTool(ctx, { define, actorFor, requireTowerActor })
487
+
488
+ registerMissionTool(ctx, { define, actorFor, requireKnownActor })
489
+
490
+ registerStatusTool(ctx, { define, workspaceHook: runtimeState.workspaceHook, spawnBackoffView, observeActivity })
491
+
492
+ registerSpawnTool(ctx, { define, actorFor, requireTowerActor, enqueueSpawn, observeActivity, spawnBackoffState, spawnBackoffView, recordSpawnThrottle, recordSpawnSuccess, runMissionDiscovery, textOf })
493
+
494
+ registerRosterTool(ctx, { define, workspaceHook: runtimeState.workspaceHook, observeActivity, actorFor, requireTowerActor, interruptRosterAgent })
495
+
496
+ registerInterruptTool(ctx, { define, actorFor, requireTowerActor, interruptRosterAgent })
497
+
498
+ registerDiffTool(ctx, { define, actorFor, requireTowerActor })
499
+
500
+ registerAdoptTool(ctx, { define, actorFor, requireTowerActor })
501
+
502
+ registerSendTool(ctx, { define, actorFor, checkSendRate })
503
+
504
+ registerInboxTool(ctx, { define })
505
+
506
+ registerCompleteTool(ctx, { define, runMissionDiscovery })
507
+
508
+ registerFindingTool(ctx, { define, actorFor, requireKnownActor })
509
+
510
+ registerFindingsTool(ctx, { define, actorFor, requireKnownActor })
511
+
512
+ registerReviewTool(ctx, { define, actorFor })
513
+
514
+ registerPreflightTool(ctx, { define, observeActivity })
515
+
516
+ registerMergeTool(ctx, { define, actorFor, requireTowerActor, observeActivity })
517
+
518
+ registerRebaseTool(ctx, { define, actorFor, requireTowerActor, observeActivity })
519
+
520
+ registerTeardownTool(ctx, { define, actorFor, requireTowerActor, observeActivity })
521
+
522
+ registerDoctorTool(ctx, define)
523
+
524
+ registerRecoverTool(ctx, { define, actorFor, requireTowerActor })
525
+
526
+ registerTestTool(ctx, { define, actorFor, requireKnownActor, runMissionDiscovery })
527
+
528
+ registerBatchTool(ctx, { define, actorFor, requireTowerActor, summarizeBatch, verifyBatchReceiptFiles })
529
+
530
+ registerResourceTool(ctx, { define, actorFor, requireTowerActor })
531
+
532
+ registerImportLegacyTool(ctx, { define, actorFor, requireTowerActor })
533
+
534
+ registerLegacyMapTool(ctx, { define, actorFor, requireTowerActor })
535
+
536
+ registerReloadTool(ctx, { define, actorFor, requireTowerActor, sharedHmrBridge: runtimeState.hmrBridge })
537
+ }
538
+
539
+ export const __testSpawnBackoff = {
540
+ spawnBackoffState,
541
+ recordSpawnThrottle,
542
+ recordSpawnSuccess,
543
+ spawnBackoffView,
544
+ }
@@ -2,7 +2,7 @@
2
2
  // `mode.workspaceLock` is enabled. Same-user trusted model, like the rest of
3
3
  // the Tower store: it prevents accidental double control, not a malicious
4
4
  // writer.
5
- import fsp from 'node:fs/promises'
5
+ import { fsp } from '../adapters/store-fs.js'
6
6
  import path from 'node:path'
7
7
  import crypto from 'node:crypto'
8
8
 
@@ -67,7 +67,7 @@ async function removeLockIfMatches(file, expectedToken, expectedRaw) {
67
67
 
68
68
  /**
69
69
  * Try to become the repository's Tower session.
70
- * @returns {{acquired:boolean, record?:object, holder?:object|null, tookOver?:boolean, corrupt?:boolean}}
70
+ * @returns {Promise<{acquired:boolean, record?:object, holder?:object|null, tookOver?:boolean, corrupt?:boolean}>}
71
71
  */
72
72
  export async function acquireWorkspaceLock(commonDir, options) {
73
73
  const { token, sessionId, agentId, staleMs = 300000, now = Date.now() } = options
@@ -6,12 +6,13 @@
6
6
  // and must still be the Git worktree of the recorded branch in the recorded
7
7
  // repository. Every mismatch is rejected with an actionable message; nothing
8
8
  // is repaired or recreated here.
9
- import fsp from 'node:fs/promises'
9
+ import { fsp } from '../adapters/store-fs.js'
10
10
  import path from 'node:path'
11
11
 
12
12
  import { git } from './git.js'
13
13
 
14
14
  function fail(code, message) {
15
+ /** @type {Error & {code?: string}} */
15
16
  const error = new Error(message)
16
17
  error.code = code
17
18
  throw error
@@ -74,7 +75,7 @@ async function gitTop(resolved, label) {
74
75
  * @param {string} [input.missionId] mission id, used in error messages
75
76
  * @returns {Promise<{path:string, realPath:string}>}
76
77
  */
77
- export async function assertOwnership(input = {}) {
78
+ export async function assertOwnership(input = /** @type {any} */ ({})) {
78
79
  const storedPath = input?.storedPath
79
80
  const storedRealPath = input?.storedRealPath
80
81
  const branch = input?.branch
@@ -138,7 +139,7 @@ export async function assertOwnership(input = {}) {
138
139
  } catch (error) {
139
140
  fail('invalid-transition', `${label} ${resolved} has no readable HEAD: ${error.message}`)
140
141
  }
141
- const actualBranch = head === 'HEAD' ? null : head.trim()
142
+ const actualBranch = head === 'HEAD' ? null : String(head).trim()
142
143
  if (actualBranch !== branch) {
143
144
  fail(
144
145
  'invalid-transition',
@@ -146,7 +147,7 @@ export async function assertOwnership(input = {}) {
146
147
  )
147
148
  }
148
149
 
149
- let commonRaw
150
+ let commonRaw = ''
150
151
  try {
151
152
  commonRaw = await git(resolved, ['rev-parse', '--git-common-dir'])
152
153
  } catch (error) {
package/locale/en.json CHANGED
@@ -1,6 +1,28 @@
1
1
  {
2
2
  "meta": {
3
3
  "title": "Tower",
4
- "description": "Mission planning, isolated worktrees, and review-gated merges for multi-agent work."
4
+ "description": "Mission planning, isolated worktrees, worker/reviewer agents, and review-gated merges."
5
+ },
6
+ "components": {
7
+ "tower-bundle": {
8
+ "title": "Tower",
9
+ "summary": "Mission planning, isolated worktrees, worker/reviewer agents, and merge gates.",
10
+ "disables": "Turns off the whole Tower bundle; no orphan preset or skill is left behind."
11
+ },
12
+ "tower-skills": {
13
+ "title": "Tower skills",
14
+ "summary": "Playbooks for worker/reviewer agents and recovery after a blocked merge.",
15
+ "disables": "Internal section; toggled with the Tower bundle."
16
+ },
17
+ "preset-tower-worker": {
18
+ "title": "Tower worker",
19
+ "summary": "Writes code, runs verification, and commits only the files assigned to its mission.",
20
+ "disables": "Internal section; a missing preset is rejected before planning/mode entry."
21
+ },
22
+ "preset-tower-reviewer": {
23
+ "title": "Tower reviewer",
24
+ "summary": "Read-only checks for scope and acceptance, returning a clean verdict or findings.",
25
+ "disables": "Internal section; a missing preset is rejected before planning/mode entry when review is required."
26
+ }
5
27
  }
6
28
  }
package/locale/zh.json CHANGED
@@ -2,5 +2,27 @@
2
2
  "meta": {
3
3
  "title": "Tower",
4
4
  "description": "多代理任务规划、隔离工作树与审查门禁合并。"
5
+ },
6
+ "components": {
7
+ "tower-bundle": {
8
+ "title": "Tower 控制塔",
9
+ "summary": "任务规划、隔离工作树、执行/审查代理与合并门禁。",
10
+ "disables": "关闭后整个 Tower 一起停用;不会留下孤立的代理预设或技能。"
11
+ },
12
+ "tower-skills": {
13
+ "title": "Tower 技能包",
14
+ "summary": "执行与审查代理的操作手册,以及失败后的恢复指南。",
15
+ "disables": "内部 section;随 Tower 整体启停。"
16
+ },
17
+ "preset-tower-worker": {
18
+ "title": "Tower 执行代理",
19
+ "summary": "在独立分支里写代码、跑验证并提交,只改分给它的文件。",
20
+ "disables": "内部 section;缺失时会在规划/进入模式前被拒绝。"
21
+ },
22
+ "preset-tower-reviewer": {
23
+ "title": "Tower 审查代理",
24
+ "summary": "只读检查改动是否越界、是否达标,给出通过或问题清单。",
25
+ "disables": "内部 section;缺失且强制审查时会在规划/进入模式前被拒绝。"
26
+ }
5
27
  }
6
28
  }