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/tools.js DELETED
@@ -1,2460 +0,0 @@
1
- // Tower tools: init, plan, mission, status, spawn, roster, adopt, findings.
2
- import { Tower, ROSTER_INACTIVE_STATES } from './tower.js'
3
- import { TowerToolError, classifyError } from './errors.js'
4
- import { git, resolveRepo, hasAnyCommit } from './git.js'
5
- import { runtimeConfig } from './config.js'
6
- import { createHmrBridge } from './hmr.js'
7
- import { readStateLock } from './store.js'
8
- import { readDiscoveryConfig, normalizeDiscovery, runDiscovery } from './tests.js'
9
- import { validateCreate as validateBatchCreate, validateClose as validateBatchClose, summarize as summarizeBatch, BatchError } from './batch.js'
10
- import { acquireLease, releaseLease, inspectLeases } from './leases.js'
11
- import { importLegacySnapshot, validateLegacySnapshot, validateLegacyMap } from './legacy.js'
12
- import { runDoctor } from './doctor.js'
13
- import path from 'node:path'
14
- import fsp from 'node:fs/promises'
15
- import crypto from 'node:crypto'
16
-
17
- const S = {
18
- str: { type: 'string' },
19
- int: { type: 'integer' },
20
- bool: { type: 'boolean' },
21
- arr: (items) => ({ type: 'array', items }),
22
- obj: (properties = {}, required = []) => ({ type: 'object', properties, required, additionalProperties: true }),
23
- any: {},
24
- }
25
-
26
- const blockerSchema = S.obj({ code: S.str, detail: S.str, cleanup: S.str, timeout: S.bool }, ['code', 'detail'])
27
- const worktreeSchema = S.obj({ path: S.str, branch: S.str, baseCommit: S.str })
28
- const missionSummarySchema = S.obj({
29
- id: S.str,
30
- title: S.str,
31
- kind: S.str,
32
- status: S.str,
33
- scope: S.arr(S.str),
34
- deps: S.arr(S.str),
35
- owner: S.any,
36
- worktree: S.any,
37
- blockers: S.arr(S.str),
38
- tasks: S.any,
39
- scopeGeneration: S.int,
40
- mergedCommit: S.any,
41
- mergedAt: S.any,
42
- updatedAt: S.any,
43
- })
44
- const findingSummarySchema = S.obj({
45
- id: S.str,
46
- seq: S.int,
47
- type: S.str,
48
- title: S.str,
49
- severity: S.any,
50
- status: S.str,
51
- missionId: S.any,
52
- branch: S.any,
53
- reportedBy: S.any,
54
- at: S.any,
55
- updatedAt: S.any,
56
- closedAt: S.any,
57
- })
58
-
59
- // Per-tool output schemas. `additionalProperties: true` keeps future fields
60
- // additive; `required` lists only fields every successful path returns.
61
- const SCHEMAS = {
62
- init: S.obj(
63
- {
64
- created: S.bool,
65
- repoRoot: S.str,
66
- commonDir: S.str,
67
- statePath: S.str,
68
- base: S.str,
69
- revision: S.int,
70
- missions: S.int,
71
- },
72
- ['created', 'repoRoot', 'commonDir', 'statePath', 'base', 'revision', 'missions'],
73
- ),
74
- plan: S.obj({ revision: S.int, missions: S.arr(missionSummarySchema) }, ['revision', 'missions']),
75
- mission: S.obj({ revision: S.int, mission: S.obj() }, ['revision', 'mission']),
76
- status: S.obj(
77
- {
78
- repoRoot: S.str,
79
- commonDir: S.str,
80
- statePath: S.str,
81
- base: S.str,
82
- revision: S.int,
83
- counts: S.obj(),
84
- openTasks: S.int,
85
- roster: S.int,
86
- messages: S.int,
87
- unreadTower: S.int,
88
- findings: S.int,
89
- findingsByStatus: S.obj(),
90
- reviews: S.int,
91
- config: S.obj({ path: S.any, repoConfigPresent: S.bool, spawn: S.obj() }),
92
- recentFindings: S.arr(S.obj()),
93
- missions: S.arr(S.obj()),
94
- activity: S.arr(S.obj()),
95
- },
96
- ['repoRoot', 'commonDir', 'statePath', 'base', 'revision', 'counts', 'missions', 'activity'],
97
- ),
98
- spawn: S.obj(
99
- {
100
- name: S.str,
101
- agentId: S.str,
102
- role: S.str,
103
- missionId: S.str,
104
- preset: S.str,
105
- provider: S.str,
106
- maxDepth: S.int,
107
- agentOptions: S.any,
108
- worktree: worktreeSchema,
109
- created: S.bool,
110
- briefingChars: S.int,
111
- rosterEntry: S.obj(),
112
- stopReason: S.any,
113
- timedOut: S.bool,
114
- output: S.any,
115
- diagnostic: S.any,
116
- },
117
- ['name', 'agentId', 'role', 'missionId', 'preset', 'provider', 'worktree'],
118
- ),
119
- roster: S.obj(
120
- { revision: S.int, roster: S.arr(S.obj()), parentStatus: S.obj(), missions: S.arr(S.obj()) },
121
- ['revision', 'roster', 'parentStatus', 'missions'],
122
- ),
123
- diff: S.obj(
124
- {
125
- missionId: S.str,
126
- branch: S.str,
127
- baseTip: S.str,
128
- branchTip: S.str,
129
- mergeBase: S.str,
130
- files: S.arr(S.str),
131
- commits: S.arr(S.str),
132
- truncated: S.bool,
133
- patch: S.str,
134
- },
135
- ['missionId', 'branch', 'baseTip', 'branchTip', 'mergeBase', 'files', 'commits', 'truncated', 'patch'],
136
- ),
137
- adopt: S.obj(
138
- { retired: S.arr(S.str), skippedUnknownParents: S.arr(S.str), roster: S.arr(S.obj()), parentStatus: S.obj() },
139
- ['retired', 'skippedUnknownParents', 'roster', 'parentStatus'],
140
- ),
141
- send: S.obj({ message: S.obj(), deliveries: S.arr(S.obj()) }, ['message', 'deliveries']),
142
- inbox: S.obj(
143
- { reader: S.str, messages: S.arr(S.obj()), pageSize: S.int, unreadRemaining: S.int },
144
- ['reader', 'messages', 'pageSize', 'unreadRemaining'],
145
- ),
146
- complete: S.obj({ mission: S.obj(), commits: S.int, unread: S.int }, ['mission', 'commits', 'unread']),
147
- finding: S.obj(
148
- {
149
- id: S.str,
150
- seq: S.int,
151
- type: S.str,
152
- title: S.str,
153
- severity: S.any,
154
- summary: S.str,
155
- location: S.any,
156
- details: S.str,
157
- suggested_fix: S.str,
158
- missionId: S.any,
159
- branch: S.any,
160
- reportedBy: S.any,
161
- at: S.any,
162
- status: S.str,
163
- updatedAt: S.any,
164
- closedAt: S.any,
165
- notes: S.arr(S.obj()),
166
- },
167
- ['id', 'type', 'title', 'summary', 'details', 'suggested_fix', 'status'],
168
- ),
169
- findings: S.obj(
170
- {
171
- findings: S.arr(findingSummarySchema),
172
- finding: S.obj(),
173
- counts: S.obj(),
174
- total: S.int,
175
- filtered: S.int,
176
- action: S.str,
177
- },
178
- ['counts', 'total'],
179
- ),
180
- review: S.obj(
181
- { review: S.obj(), branchTip: S.str, missionStatus: S.str, round: S.int, reviewerRounds: S.int },
182
- ['review', 'branchTip', 'missionStatus', 'round', 'reviewerRounds'],
183
- ),
184
- preflight: S.obj(
185
- {
186
- ok: S.bool,
187
- blockers: S.arr(blockerSchema),
188
- missionId: S.str,
189
- branchTip: S.any,
190
- baseTip: S.any,
191
- changedFiles: S.any,
192
- review: S.any,
193
- },
194
- ['ok', 'blockers', 'missionId'],
195
- ),
196
- merge: S.obj(
197
- {
198
- ok: S.bool,
199
- missionId: S.str,
200
- mergedCommit: S.any,
201
- baseTip: S.any,
202
- branchTip: S.any,
203
- parents: S.any,
204
- blockers: S.arr(blockerSchema),
205
- warnings: S.any,
206
- alreadyMerged: S.bool,
207
- recoveredFromFailure: S.bool,
208
- },
209
- ['ok'],
210
- ),
211
- rebase: S.obj(
212
- {
213
- ok: S.bool,
214
- oldTip: S.any,
215
- newTip: S.any,
216
- baseTip: S.any,
217
- alreadyMerged: S.bool,
218
- alreadyUpToDate: S.bool,
219
- skipped: S.bool,
220
- reviewWaived: S.bool,
221
- note: S.str,
222
- blockers: S.arr(blockerSchema),
223
- },
224
- ['ok'],
225
- ),
226
- teardown: S.obj(
227
- { ok: S.bool, dryRun: S.bool, removed: S.arr(S.str), decisions: S.arr(S.obj()) },
228
- ['ok', 'dryRun', 'removed', 'decisions'],
229
- ),
230
- interrupt: S.obj(
231
- {
232
- ok: S.bool,
233
- interrupted: S.bool,
234
- previousActivity: S.str,
235
- entry: S.obj(),
236
- reason: S.str,
237
- },
238
- ['ok', 'interrupted'],
239
- ),
240
- reload: S.obj(
241
- {
242
- ok: S.bool,
243
- scheduled: S.bool,
244
- delayMs: S.int,
245
- requiredIds: S.arr(S.str),
246
- error: S.str,
247
- restartHint: S.str,
248
- },
249
- ['ok', 'scheduled'],
250
- ),
251
- doctor: S.obj({ ok: S.bool, checks: S.arr(S.obj()), environmentContract: S.obj() }, ['ok', 'checks']),
252
- recover: S.obj(
253
- { ok: S.bool, action: S.str, stateLock: S.any, cleared: S.bool, backup: S.any, error: S.str },
254
- ['ok', 'action'],
255
- ),
256
- test: S.obj(
257
- {
258
- ok: S.bool,
259
- configured: S.bool,
260
- treeTip: S.any,
261
- tests: S.arr(S.obj()),
262
- commands: S.arr(S.obj()),
263
- error: S.str,
264
- receipt: S.obj(),
265
- checks: S.arr(S.obj()),
266
- },
267
- ['ok', 'configured'],
268
- ),
269
- batch: S.obj(
270
- { ok: S.bool, action: S.str, batch: S.obj(), batches: S.arr(S.obj()), blockers: S.arr(blockerSchema), summary: S.obj() },
271
- ['ok', 'action'],
272
- ),
273
- resource: S.obj(
274
- { ok: S.bool, action: S.str, lease: S.obj(), leases: S.arr(S.obj()), released: S.bool },
275
- ['ok', 'action'],
276
- ),
277
- importLegacy: S.obj({ ok: S.bool, summary: S.obj(), snapshot: S.obj() }, ['ok', 'summary']),
278
- legacyMap: S.obj({ ok: S.bool, map: S.obj(), maps: S.arr(S.obj()) }, ['ok']),
279
- }
280
-
281
- function renderValue(_args, value) {
282
- return [{ type: 'text', text: JSON.stringify(value, null, 2) }]
283
- }
284
-
285
- function output(schema) {
286
- return { schema, render: renderValue }
287
- }
288
-
289
- const ROLE_ENUM = ['worker', 'reviewer']
290
-
291
- function normalizeToolArgs(args, aliases = {}) {
292
- const out = { ...(args ?? {}) }
293
- for (const [canonical, names] of Object.entries(aliases)) {
294
- const current = out[canonical]
295
- if (current !== undefined && current !== null) continue
296
- for (const name of names) {
297
- if (out[name] !== undefined && out[name] !== null) {
298
- out[canonical] = out[name]
299
- break
300
- }
301
- }
302
- }
303
- return out
304
- }
305
-
306
- /** Resolve the calling agent to a roster role; unknown callers are rejected. */
307
- async function actorFor(tower, exec) {
308
- const agentId = exec?.agent?.id !== undefined ? String(exec.agent.id) : null
309
- const unknown = { name: 'unknown', role: 'unknown', agentId }
310
- if (!agentId) return unknown
311
- let state
312
- try {
313
- state = await tower.load()
314
- } catch (error) {
315
- if (!/not initialized/i.test(String(error?.message ?? error))) throw error
316
- // No readable state: only tower_init may claim ownership (handled in that tool).
317
- return unknown
318
- }
319
- if (state.towerAgentId && String(state.towerAgentId) === agentId) {
320
- return { name: 'tower', role: 'tower', agentId }
321
- }
322
- let view
323
- try {
324
- view = await tower.rosterView()
325
- } catch {
326
- return unknown
327
- }
328
- const self = view.roster.find((entry) => entry.agentId === agentId)
329
- if (!self) return unknown
330
- if (self.role === 'tower') return { name: self.name, role: 'tower', agentId }
331
- return { name: self.name, role: self.role, agentId }
332
- }
333
-
334
- function requireKnownActor(actor, action) {
335
- if (!actor || actor.role === 'unknown') {
336
- throw new TowerToolError('not-authorized', `${action} requires a registered roster agent or the tower`)
337
- }
338
- }
339
-
340
- function requireTowerActor(actor, action) {
341
- if (actor.role !== 'tower') {
342
- throw new TowerToolError('not-authorized', `only the tower can ${action}; ${actor.name} is a ${actor.role}`)
343
- }
344
- }
345
-
346
- const SEND_RATE = new Map()
347
- const SPAWN_QUEUES = new Map()
348
- const WATCHED_REPOS = new Set()
349
- function watchRepo(repoRoot) {
350
- WATCHED_REPOS.add(repoRoot)
351
- if (WATCHED_REPOS.size > 100) {
352
- const oldest = WATCHED_REPOS.values().next().value
353
- WATCHED_REPOS.delete(oldest)
354
- }
355
- }
356
- let workspaceHook = null
357
- let sharedHmrBridge = null
358
-
359
- /** Serialize spawn decisions per repository so precheck + activation + record are atomic. */
360
- function enqueueSpawn(key, operation) {
361
- const previous = SPAWN_QUEUES.get(key) ?? Promise.resolve()
362
- const run = previous.catch(() => {}).then(operation)
363
- const settled = run.catch(() => {})
364
- SPAWN_QUEUES.set(key, settled)
365
- settled.finally(() => {
366
- if (SPAWN_QUEUES.get(key) === settled) SPAWN_QUEUES.delete(key)
367
- })
368
- return run
369
- }
370
-
371
- /** In-memory per-repository tower_send rate limit (process-local, advisory). */
372
- function checkSendRate(config, repoRoot) {
373
- const max = Number(config.send?.maxPerMinute)
374
- if (!max || max <= 0) return
375
- const key = String(repoRoot)
376
- const now = Date.now()
377
- const bucket = SEND_RATE.get(key)
378
- if (!bucket || now - bucket.start >= 60000) {
379
- SEND_RATE.set(key, { start: now, count: 1 })
380
- return
381
- }
382
- if (bucket.count >= max) {
383
- throw new TowerToolError('rate-limited', `tower_send rate limit reached (${max}/min for ${key}); retry later or raise send.maxPerMinute`)
384
- }
385
- bucket.count += 1
386
- }
387
-
388
- async function verifyBatchReceiptFiles(root, batch, args) {
389
- const problems = []
390
- const frozen = new Set(batch.receiptPaths ?? [])
391
- const declared = new Map()
392
- for (const [rawPath, rawHash] of Object.entries(args.receiptHashes ?? {})) {
393
- declared.set(String(rawPath), String(rawHash).trim().toLowerCase())
394
- }
395
- for (const entry of args.receipts ?? []) {
396
- if (entry && typeof entry.path === 'string' && typeof entry.hash === 'string') {
397
- declared.set(entry.path, entry.hash.trim().toLowerCase())
398
- }
399
- }
400
- for (const [rel, reported] of declared) {
401
- if (frozen.size > 0 && !frozen.has(rel)) continue
402
- let actual
403
- try {
404
- const abs = path.resolve(root, rel)
405
- const relative = path.relative(root, abs)
406
- if (relative.startsWith('..') || path.isAbsolute(relative)) throw new Error('path escapes the repository')
407
- actual = crypto.createHash('sha256').update(await fsp.readFile(abs)).digest('hex')
408
- } catch (error) {
409
- problems.push({ code: 'batch-invalid', detail: `receipt "${rel}" cannot be read: ${String(error?.message ?? error)}` })
410
- continue
411
- }
412
- if (reported && reported !== actual) {
413
- problems.push({ code: 'batch-invalid', detail: `receipt "${rel}" hash ${reported.slice(0, 12)} does not match the file (${actual.slice(0, 12)})` })
414
- }
415
- }
416
- return problems
417
- }
418
-
419
- async function interruptRosterAgent(ctx, tower, exec, input) {
420
- const subagents = ctx.get('subagents')
421
- if (!subagents || typeof subagents.interrupt !== 'function') {
422
- throw new TowerToolError('spawn-unavailable', 'subagents service is unavailable; cannot interrupt')
423
- }
424
- const view = await tower.rosterView()
425
- const entry = view.roster.find(
426
- (row) =>
427
- (input.agentId !== undefined && row.agentId === String(input.agentId)) ||
428
- (input.to !== undefined && row.name === input.to),
429
- )
430
- if (!entry) throw new TowerToolError('invalid-argument', `unknown roster agent "${input.to ?? input.agentId}"`)
431
- const { activity } = await observeActivity(ctx, tower)
432
- const previousActivity = activity[entry.agentId] ?? 'unknown'
433
- if (previousActivity !== 'running') {
434
- return { interrupted: false, reason: `agent is ${previousActivity}, not running`, entry }
435
- }
436
- subagents.interrupt(entry.agentId, { kind: 'ancestor', agent: exec.agent })
437
- const updated = await tower.recordInterrupt({
438
- agentId: entry.agentId,
439
- name: entry.name,
440
- reason: input.reason ?? 'interrupted by tower',
441
- })
442
- return { interrupted: true, previousActivity, entry: updated }
443
- }
444
-
445
- /** autoGitInit support: Tower.open needs a repository, so prepare it first.
446
- * Only absolute, existing directories are eligible; a typo must not git-init the
447
- * DSH process cwd or create a repository at an unintended path. */
448
- async function ensureRepository(repoRootInput, autoGitInit) {
449
- try {
450
- return await resolveRepo(repoRootInput)
451
- } catch (error) {
452
- if (autoGitInit !== true) throw error
453
- if (typeof repoRootInput !== 'string' || !path.isAbsolute(repoRootInput)) {
454
- throw new TowerToolError('repo-invalid', `autoGitInit requires an absolute directory path: ${repoRootInput}`)
455
- }
456
- const resolved = path.resolve(repoRootInput)
457
- let stat = null
458
- try {
459
- stat = await fsp.stat(resolved)
460
- } catch {
461
- stat = null
462
- }
463
- if (!stat || !stat.isDirectory()) {
464
- throw new TowerToolError('repo-invalid', `autoGitInit requires an existing directory: ${resolved}`)
465
- }
466
- if (resolved === path.parse(resolved).root) {
467
- throw new TowerToolError('repo-invalid', 'autoGitInit refuses to initialize a filesystem root')
468
- }
469
- await git(resolved, ['init'])
470
- const repo = await resolveRepo(resolved)
471
- if (!(await hasAnyCommit(repo.root))) {
472
- await git(repo.root, [
473
- '-c',
474
- 'user.name=dsh-tower',
475
- '-c',
476
- 'user.email=dsh-tower@localhost',
477
- 'commit',
478
- '--allow-empty',
479
- '-m',
480
- 'tower: initial empty commit',
481
- ])
482
- }
483
- return repo
484
- }
485
- }
486
-
487
- function define(ctx, definition) {
488
- const { aliases, requiresMissionId, board, execute, ...rest } = definition
489
- ctx.effect(() =>
490
- ctx.tools.register({
491
- ...rest,
492
- async execute(args, exec) {
493
- const normalized = normalizeToolArgs(args, aliases)
494
- if (requiresMissionId) {
495
- const id = normalized.missionId
496
- if (typeof id !== 'string' || id.trim().length === 0) {
497
- throw new TowerToolError('invalid-argument', `${rest.name} requires missionId (alias: id)`)
498
- }
499
- normalized.missionId = id.trim()
500
- }
501
- let value
502
- try {
503
- if (workspaceHook && typeof normalized.repoRoot === 'string' && rest.name !== 'tower_init') {
504
- await workspaceHook.ensure(normalized.repoRoot, exec, rest.name)
505
- }
506
- value = await execute(normalized, exec)
507
- } catch (error) {
508
- throw classifyError(error)
509
- }
510
- if (typeof normalized.repoRoot === 'string') watchRepo(normalized.repoRoot)
511
- const event = rest.name.startsWith('tower_') ? rest.name.slice('tower_'.length) : null
512
- if (event) {
513
- try {
514
- ctx.emit?.(`tower/${event}`, {
515
- tool: rest.name,
516
- repoRoot: typeof normalized.repoRoot === 'string' ? normalized.repoRoot : null,
517
- value: value ?? null,
518
- })
519
- } catch {
520
- // events are an optional side channel; never fail a tool for them
521
- }
522
- }
523
- if (board === true && typeof normalized.repoRoot === 'string') {
524
- try {
525
- const opened = await Tower.open(normalized.repoRoot)
526
- await opened.writeBoard()
527
- } catch {
528
- // board output is best effort
529
- }
530
- }
531
- return value
532
- },
533
- }),
534
- )
535
- }
536
-
537
- function textOf(blocks) {
538
- if (!Array.isArray(blocks)) return null
539
- return blocks
540
- .map((block) => (block && block.type === 'text' ? block.text : `[${block && block.type}]`))
541
- .join('\n')
542
- }
543
-
544
- /** Read `package.json tower.testDiscovery` and run it for one mission worktree.
545
- * Returns `{configured:false}` when the repository has no discovery block. */
546
- async function runMissionDiscovery(tower, mission) {
547
- const testsConfig = tower.config.tests ?? {}
548
- if (testsConfig.enabled === false || testsConfig.allowPackageDiscovery === false) {
549
- return { configured: false }
550
- }
551
- const worktreePath = mission?.worktree?.path
552
- if (!worktreePath) return { configured: false, error: 'mission has no worktree' }
553
- let packageText
554
- try {
555
- packageText = await fsp.readFile(path.join(worktreePath, 'package.json'), 'utf8')
556
- } catch (error) {
557
- if (error?.code === 'ENOENT') return { configured: false }
558
- throw error
559
- }
560
- const read = readDiscoveryConfig(packageText)
561
- if (!read.configured) {
562
- if (read.error) throw new TowerToolError('invalid-argument', `package.json test discovery is invalid: ${read.error}`)
563
- return { configured: false, error: null }
564
- }
565
- const normalized = normalizeDiscovery(read.config)
566
- if (!normalized.ok) {
567
- throw new TowerToolError('invalid-argument', `invalid tower.testDiscovery: ${normalized.error}`)
568
- }
569
- const receipt = await runDiscovery({
570
- worktreePath,
571
- config: read.config,
572
- timeoutMs: Number(testsConfig.timeoutMs) || 60000,
573
- maxBytes: Number(testsConfig.maxBytes) || 16 * 1024 * 1024,
574
- missionId: mission.id,
575
- diagnosticsDir: path.join(tower.paths.dir, 'test-runs', `${mission.id}-${Date.now()}`),
576
- })
577
- const stored = await tower.recordTestRun({ missionId: mission.id, receipt })
578
- const commands = Array.isArray(stored.commands) ? stored.commands : []
579
- const check = {
580
- name: 'tests',
581
- command: commands.map((command) => `${command.id}: ${command.command}`).join('; ') || 'tower.testDiscovery',
582
- exitCode: commands.every((command) => command.exitCode === 0) ? 0 : (commands.find((command) => command.exitCode !== 0)?.exitCode ?? 1),
583
- evidence: stored.ok
584
- ? `${stored.tests?.length ?? 0} test(s) passed at ${String(stored.treeTip ?? '').slice(0, 12)}; receipt ${stored.receiptPath ?? stored.diagnosticsPath}`
585
- : `test discovery failed: ${String(stored.error ?? 'unknown').slice(0, 300)}`,
586
- }
587
- return { configured: true, receipt: stored, check }
588
- }
589
-
590
- async function observeActivity(ctx, tower) {
591
- const view = await tower.rosterView()
592
- const subagents = ctx.get('subagents')
593
- const activity = {}
594
- const parentStatus = {}
595
- if (!subagents) return { activity, parentStatus }
596
- const parents = [...new Set(view.roster.map((entry) => entry.parentSessionId).filter(Boolean))]
597
- for (const parent of parents) {
598
- try {
599
- const rows = await subagents.listDescendants(parent)
600
- for (const row of rows) activity[String(row.id)] = row.activity ?? 'unknown'
601
- parentStatus[parent] = 'ok'
602
- } catch (error) {
603
- parentStatus[parent] = `unavailable: ${String(error?.message ?? error)}`
604
- }
605
- }
606
- return { activity, parentStatus }
607
- }
608
-
609
- function clip(text, max = 2000) {
610
- const value = String(text ?? '')
611
- return value.length > max ? `${value.slice(0, max)}\n… truncated` : value
612
- }
613
-
614
- export function workerBriefing({
615
- repoRoot,
616
- base,
617
- mission,
618
- deps,
619
- worktree,
620
- instructions,
621
- reviews = [],
622
- findings = [],
623
- completionReport = null,
624
- }) {
625
- const lines = []
626
- lines.push(`# Tower mission ${mission.id} — ${mission.title}`)
627
- lines.push('')
628
- lines.push(`You are the tower worker for mission ${mission.id} in repository ${repoRoot}.`)
629
- lines.push('')
630
- lines.push(`Work ONLY in this git worktree: ${worktree.path}`)
631
- lines.push(`Branch: ${worktree.branch} (based on ${base} @ ${String(worktree.baseCommit).slice(0, 12)})`)
632
- lines.push('')
633
- lines.push(`Kind: ${mission.kind}`)
634
- lines.push(
635
- `Scope (only files you may touch): ${mission.scope.length > 0 ? mission.scope.join(', ') : '(none — survey is read-only)'}`,
636
- )
637
- if (mission.tasks.length > 0) {
638
- lines.push('Tasks:')
639
- for (const task of mission.tasks) lines.push(`- [${task.status}] ${task.text}`)
640
- }
641
- if (deps.length > 0) {
642
- lines.push('Dependencies:')
643
- for (const dep of deps) lines.push(`- ${dep.id} ${dep.title ?? ''} (${dep.status})`)
644
- }
645
- if (mission.context) {
646
- lines.push('Context (verbatim from the user):')
647
- lines.push(mission.context)
648
- }
649
- if ((mission.references ?? []).length > 0) {
650
- lines.push('References (read-only pointers; read them before deciding):')
651
- for (const reference of mission.references) lines.push(`- ${reference}`)
652
- }
653
- if (mission.reviewPolicy === 'none') {
654
- lines.push(`Review policy: none (user exemption: ${mission.reviewExemptionReason ?? 'missing reason'})`)
655
- }
656
- if (completionReport) {
657
- lines.push('')
658
- lines.push(`Previous completion report (by ${completionReport.by ?? 'worker'}):`)
659
- lines.push(clip(completionReport.report, 1500))
660
- if ((completionReport.checks ?? []).length > 0) {
661
- lines.push('Previous checks:')
662
- for (const check of completionReport.checks) lines.push(`- ${check.name}${check.exitCode !== undefined ? ` (exit ${check.exitCode})` : ''}`)
663
- }
664
- }
665
- if (reviews.length > 0) {
666
- lines.push('')
667
- lines.push('Previous review rounds for this branch (newest first) — address every finding:')
668
- for (const review of reviews) {
669
- lines.push(`- R${review.round} by ${review.reviewer}: ${review.status} / ${review.mergeVerdict}`)
670
- if (review.findings) lines.push(` findings: ${clip(review.findings, 1200).replaceAll('\n', '\n ')}`)
671
- if (review.decision) lines.push(` decision: ${clip(review.decision, 600)}`)
672
- }
673
- }
674
- if (findings.length > 0) {
675
- lines.push('')
676
- lines.push('Findings related to this mission:')
677
- for (const finding of findings) lines.push(`- ${finding.id} [${finding.status}] ${finding.severity ?? 'severity?'} ${finding.title}`)
678
- }
679
- lines.push('')
680
- lines.push('Rules:')
681
- lines.push(`- Pass workdir="${worktree.path}" on every shell command; never edit the main checkout.`)
682
- lines.push('- Stay inside the declared scope; out-of-scope changes are rejected later.')
683
- lines.push('- Commit your work on the mission branch. Do not merge, push, or spawn other agents.')
684
- lines.push(
685
- `- Update progress: tower_mission({repoRoot:"${repoRoot}",id:"${mission.id}",task_done:"..."}) or blocker:"...".`,
686
- )
687
- lines.push(
688
- `- When the work is finished and every task is closed, call tower_complete({repoRoot:"${repoRoot}",missionId:"${mission.id}",report:"<full handoff>",checks:[{name,command,exitCode,evidence}]}). Unread inbox messages block completion, so read them with tower_inbox first.`,
689
- )
690
- lines.push('- Your final message is the full handoff: files changed, how you verified, what remains.')
691
- if (instructions) {
692
- lines.push('')
693
- lines.push('Extra tower instructions:')
694
- lines.push(instructions)
695
- }
696
- return lines.join('\n')
697
- }
698
-
699
- export function reviewerBriefing({
700
- repoRoot,
701
- base,
702
- mission,
703
- deps,
704
- worktree,
705
- instructions,
706
- reviews = [],
707
- findings = [],
708
- completionReport = null,
709
- }) {
710
- const lines = []
711
- lines.push(`# Tower review — mission ${mission.id} — ${mission.title}`)
712
- lines.push('')
713
- lines.push(`You are the independent reviewer for mission ${mission.id} in repository ${repoRoot}.`)
714
- lines.push('')
715
- lines.push(`Review branch: ${worktree.branch}`)
716
- lines.push(`Worktree: ${worktree.path}`)
717
- lines.push(`Reviewed base: ${base} @ ${String(worktree.baseCommit).slice(0, 12)}`)
718
- lines.push('')
719
- lines.push(`Kind: ${mission.kind}`)
720
- if (mission.tasks.length > 0) {
721
- lines.push('The worker was asked to complete:')
722
- for (const task of mission.tasks) lines.push(`- [${task.status}] ${task.text}`)
723
- }
724
- if (deps.length > 0) {
725
- lines.push('Dependencies:')
726
- for (const dep of deps) lines.push(`- ${dep.id} ${dep.title ?? ''} (${dep.status})`)
727
- }
728
- if (mission.context) {
729
- lines.push('Context (verbatim from the user):')
730
- lines.push(mission.context)
731
- }
732
- if ((mission.references ?? []).length > 0) {
733
- lines.push('References (read-only pointers):')
734
- for (const reference of mission.references) lines.push(`- ${reference}`)
735
- }
736
- if (completionReport) {
737
- lines.push('')
738
- lines.push(`Completion report (by ${completionReport.by ?? 'worker'}):`)
739
- lines.push(clip(completionReport.report, 2000))
740
- if ((completionReport.checks ?? []).length > 0) {
741
- lines.push('Completion checks the worker ran:')
742
- for (const check of completionReport.checks) {
743
- lines.push(
744
- `- ${check.name}${check.exitCode !== undefined ? ` (exit ${check.exitCode})` : ''}${check.evidence ? ` — ${clip(check.evidence, 400)}` : ''}`,
745
- )
746
- }
747
- }
748
- }
749
- if (reviews.length > 0) {
750
- lines.push('')
751
- lines.push('Prior review rounds for this branch (newest first):')
752
- for (const review of reviews) {
753
- lines.push(`- R${review.round} by ${review.reviewer}: ${review.status} / ${review.mergeVerdict}`)
754
- if (review.findings) lines.push(` findings: ${clip(review.findings, 1200).replaceAll('\n', '\n ')}`)
755
- }
756
- }
757
- if (findings.length > 0) {
758
- lines.push('')
759
- lines.push('Related findings:')
760
- for (const finding of findings) lines.push(`- ${finding.id} [${finding.status}] ${finding.severity ?? 'severity?'} ${finding.title}`)
761
- }
762
- lines.push('')
763
- lines.push('Rules:')
764
- lines.push('- Read-only: do not modify files, commit, or merge.')
765
- lines.push('- Inspect the diff from the reviewed base to the branch tip and map every task to the diff.')
766
- lines.push('- Check scope, correctness, tests, and security. Report severity and concrete evidence.')
767
- lines.push(
768
- `- Submit the formal verdict with tower_review({repoRoot:"${repoRoot}",missionId:"${mission.id}",status:"clean",merge:"merge",findings:"none",checks:["..."],decision:"..."}). Use status "p1-Nitems"/"p2-Nitems" and merge "fix-then-merge"/"hold" for problems; a non-clean verdict returns the mission to active.`,
769
- )
770
- lines.push(
771
- `- File out-of-scope issues with tower_finding({repoRoot:"${repoRoot}",type:"bug",title:"...",summary:"...",details:"...",suggested_fix:"..."}) instead of fixing them.`,
772
- )
773
- lines.push('- Your final message is the review report; the tool call above is the formal verdict.')
774
- if (instructions) {
775
- lines.push('')
776
- lines.push('Extra tower instructions:')
777
- lines.push(instructions)
778
- }
779
- return lines.join('\n')
780
- }
781
-
782
- export function registerTowerTools(ctx, options = {}) {
783
- workspaceHook = options.workspace ?? null
784
- sharedHmrBridge = options.hmrBridge ?? null
785
- // A lightweight roster watchdog: the authoritative activity comes from the
786
- // subagents catalog; a missing observation after spawn.staleMs is marked
787
- // stale (never silently treated as stopped) and blocks merge via the gate.
788
- const watchMs = Math.max(1000, Number(runtimeConfig().mode?.heartbeatMs) || 10000)
789
- const timer = setInterval(() => {
790
- void (async () => {
791
- try {
792
- if (!ctx.get('subagents')) return
793
- for (const repoRoot of [...WATCHED_REPOS]) {
794
- const tower = await Tower.open(repoRoot)
795
- const view = await tower.rosterView()
796
- const live = view.roster.filter((entry) => !ROSTER_INACTIVE_STATES.includes(entry.state))
797
- if (live.length === 0) { WATCHED_REPOS.delete(repoRoot); continue }
798
- const parents = [...new Set(live.map((entry) => entry.parentSessionId).filter(Boolean))]
799
- if (parents.length === 0) continue
800
- const { activity, parentStatus } = await observeActivity(ctx, tower)
801
- const authoritative = parents.every((parent) => parentStatus[parent] === 'ok')
802
- await tower.sweepRoster({
803
- staleMs: Number(tower.config.spawn?.staleMs) || 0,
804
- activity,
805
- authoritative,
806
- })
807
- }
808
- } catch {
809
- // watchdog failures are best effort; tools still re-check on demand
810
- }
811
- })()
812
- }, watchMs)
813
- timer.unref?.()
814
- ctx.effect(() => () => clearInterval(timer))
815
- define(ctx, {
816
- name: 'tower_init',
817
- description:
818
- 'Initialize Tower mission state for a repository (absolute repoRoot). Creates <git-common-dir>/dsh-tower/state.json and an activity log; idempotent on an existing workspace. Options: autoGitInit (git init + empty initial commit when the directory is not a repository), snapshotDirty (snapshot uncommitted work into a dangling commit used as the mission baseline), reanchor (change base when every mission is merged/abandoned). Only the tower may initialize.',
819
- parameters: {
820
- type: 'object',
821
- properties: {
822
- repoRoot: { type: 'string', description: 'Absolute path inside the target Git repository.' },
823
- base: { type: 'string', description: 'Optional local base branch (defaults to the checked-out branch).' },
824
- autoGitInit: { type: 'boolean', description: 'Run git init (and an empty initial commit) when repoRoot is not a repository yet.' },
825
- snapshotDirty: { type: 'boolean', description: 'Snapshot uncommitted tracked changes into a dangling commit used as the mission baseline (untracked files are not included).' },
826
- reanchor: { type: 'boolean', description: 'Update the recorded base when no mission is active.' },
827
- },
828
- required: ['repoRoot'],
829
- additionalProperties: false,
830
- },
831
- aliases: { auto_git_init: ['autoGitInit'], snapshot_dirty: ['snapshotDirty'] },
832
- board: true,
833
- output: output(SCHEMAS.init),
834
- async execute(args, exec) {
835
- const profileInit = runtimeConfig().init ?? {}
836
- let repo = null
837
- try {
838
- repo = await resolveRepo(args.repoRoot)
839
- } catch (error) {
840
- const autoGitInit =
841
- args.autoGitInit === true || (args.autoGitInit === undefined && profileInit.autoGitInit === true)
842
- repo = await ensureRepository(args.repoRoot, autoGitInit)
843
- }
844
- const tower = await Tower.open(repo.root)
845
- const initConfig = tower.config.init ?? {}
846
- const snapshotDirty =
847
- args.snapshotDirty === true || (args.snapshotDirty === undefined && initConfig.snapshotDirty === true)
848
- const agentId = exec?.agent?.id !== undefined ? String(exec.agent.id) : null
849
- if (!agentId) {
850
- throw new TowerToolError('not-authorized', 'tower_init requires a caller agent id; refusing to treat an unidentified caller as the tower')
851
- }
852
- let state = null
853
- try {
854
- state = await tower.load()
855
- } catch {
856
- state = null
857
- }
858
- if (state?.towerAgentId) {
859
- const actor = await actorFor(tower, exec)
860
- requireTowerActor(actor, 'initialize Tower state')
861
- } else if (state) {
862
- // Legacy state predating owner binding: an explicit tower_init may
863
- // claim ownership once. Other tools remain fail-closed until then.
864
- const actor = await actorFor(tower, exec)
865
- if (actor.role !== 'tower' && actor.role !== 'unknown') {
866
- requireTowerActor(actor, 'claim Tower ownership')
867
- }
868
- }
869
- const base = args.base ?? workspaceHook?.basePreference?.(exec) ?? undefined
870
- return tower.init({ base, snapshotDirty, reanchor: args.reanchor, towerAgentId: agentId })
871
- },
872
- })
873
-
874
- define(ctx, {
875
- name: 'tower_plan',
876
- description:
877
- 'Plan one or more missions in an initialized repository. Each mission has a printable-ASCII title, relative scope globs, optional task checklist, optional mission-id dependencies, and kind build|survey. Assigns M1.. ids and auto-adds a dependency when build scopes overlap (serializes merges); rejects dependency cycles.',
878
- parameters: {
879
- type: 'object',
880
- properties: {
881
- repoRoot: { type: 'string', description: 'Absolute path inside the target Git repository.' },
882
- missions: {
883
- type: 'array',
884
- description: 'Missions to create, in intended order.',
885
- items: {
886
- type: 'object',
887
- properties: {
888
- title: { type: 'string', description: 'Printable ASCII title; used as the mission label.' },
889
- scope: { type: 'array', items: { type: 'string' }, description: 'Relative file globs this mission may touch; empty only for survey.' },
890
- tasks: { type: 'array', items: { type: 'string' }, description: 'Verifiable task items the worker ticks off.' },
891
- deps: { type: 'array', items: { type: 'string' }, description: 'Mission ids that must merge before this one.' },
892
- kind: { type: 'string', enum: ['build', 'survey'], description: 'survey = read-only investigation that reserves no scope.' },
893
- context: { type: 'string', description: "The user's own key sentences, verbatim (max 4096 chars)." },
894
- references: {
895
- type: 'array',
896
- maxItems: 20,
897
- items: { type: 'string' },
898
- description: 'Read-only pointers/files the worker should read (max 20); copied into the briefing.',
899
- },
900
- reviewPolicy: {
901
- type: 'string',
902
- enum: ['required-clean', 'none'],
903
- description: 'required-clean (mission default) means a clean review is required when review.requireReview is on; none needs reviewExemptionReason, and with the review gate on it also needs gates.allowReviewExemption.',
904
- },
905
- reviewExemptionReason: {
906
- type: 'string',
907
- description: 'Non-empty justification when reviewPolicy is "none".',
908
- },
909
- },
910
- required: ['title'],
911
- additionalProperties: false,
912
- },
913
- },
914
- },
915
- required: ['repoRoot', 'missions'],
916
- additionalProperties: false,
917
- },
918
- board: true,
919
- output: output(SCHEMAS.plan),
920
- async execute(args, exec) {
921
- const tower = await Tower.open(args.repoRoot)
922
- const actor = await actorFor(tower, exec)
923
- requireTowerActor(actor, 'plan missions')
924
- return tower.plan({ missions: args.missions })
925
- },
926
- })
927
-
928
- define(ctx, {
929
- name: 'tower_mission',
930
- description:
931
- 'Update one mission: lifecycle status, decision note, blocker, clear blockers, mark a task done, drop a task with a reason, or widen the scope (tower only). "merged" is never set here; only a future tower_merge records it. Merged and abandoned are terminal.',
932
- parameters: {
933
- type: 'object',
934
- properties: {
935
- repoRoot: { type: 'string', description: 'Absolute path inside the target Git repository.' },
936
- id: { type: 'string', description: 'Mission id, e.g. M1 (canonical; alias: missionId).' },
937
- missionId: { type: 'string', description: 'Alias for id; both are accepted.' },
938
- status: {
939
- type: 'string',
940
- enum: ['planned', 'active', 'completed', 'blocked', 'paused', 'abandoned'],
941
- description: 'New lifecycle status.',
942
- },
943
- note: { type: 'string', description: 'Append a decision-log note.' },
944
- blocker: { type: 'string', description: 'Report a blocker (also sets status to blocked).' },
945
- clear_blockers: { type: 'boolean', description: 'Clear all recorded blockers.' },
946
- clearBlockers: { type: 'boolean', description: 'Alias for clear_blockers.' },
947
- task_done: { type: 'string', description: 'Mark the first open task containing this text as done.' },
948
- taskDone: { type: 'string', description: 'Alias for task_done.' },
949
- task_drop: { type: 'string', description: 'Mark the first open task containing this text as dropped.' },
950
- taskDrop: { type: 'string', description: 'Alias for task_drop.' },
951
- task_drop_reason: { type: 'string', description: 'Required with task_drop: why the task is legitimately descoped.' },
952
- taskDropReason: { type: 'string', description: 'Alias for task_drop_reason.' },
953
- scope: { type: 'array', items: { type: 'string' }, description: 'Tower only: replacement scope globs; bumps scopeGeneration.' },
954
- pause_cause: {
955
- type: 'string',
956
- enum: ['user', 'failure', 'resource', 'dependency'],
957
- description: 'With status=paused: why the mission was paused (default user).',
958
- },
959
- authorization: { type: 'string', description: 'Authorization reference recorded when resuming a user-paused mission.' },
960
- },
961
- required: ['repoRoot'],
962
- additionalProperties: false,
963
- },
964
- aliases: {"id":["missionId"],"task_done":["taskDone"],"task_drop":["taskDrop"],"task_drop_reason":["taskDropReason"],"clear_blockers":["clearBlockers"]},
965
- board: true,
966
- output: output(SCHEMAS.mission),
967
- async execute(args, exec) {
968
- const tower = await Tower.open(args.repoRoot)
969
- const actor = await actorFor(tower, exec)
970
- requireKnownActor(actor, 'tower_mission')
971
- return tower.mission({
972
- actor,
973
- id: args.id,
974
- status: args.status,
975
- note: args.note,
976
- blocker: args.blocker,
977
- clear_blockers: args.clear_blockers,
978
- task_done: args.task_done,
979
- task_drop: args.task_drop,
980
- task_drop_reason: args.task_drop_reason,
981
- scope: args.scope,
982
- pause_cause: args.pause_cause,
983
- authorization: args.authorization,
984
- })
985
- },
986
- })
987
-
988
- define(ctx, {
989
- name: 'tower_status',
990
- description:
991
- 'Read the Tower dashboard for a repository: state path, base branch, revision, mission status counts, paged mission summaries (scope/deps/tasks/worktree/owner), roster size, findings, config path and the activity tail. Pass missionId for one full mission (tasks, notes, reviews, findings, completion report); offset/limit page the mission list (default 20, max 100); includeGate adds preflight gate digests for the page; format:"markdown" returns the human-readable mission board. Read-only.',
992
- parameters: {
993
- type: 'object',
994
- properties: {
995
- repoRoot: { type: 'string', description: 'Absolute path inside the target Git repository.' },
996
- missionId: { type: 'string', description: 'Return full detail for one mission instead of the dashboard list.' },
997
- offset: { type: 'integer', minimum: 0, description: 'Mission list offset (default 0).' },
998
- limit: { type: 'integer', minimum: 1, maximum: 100, description: 'Mission list page size (default 20).' },
999
- includeGate: { type: 'boolean', description: 'Compute tower_preflight-style gate digests for missions on this page.' },
1000
- format: { type: 'string', enum: ['json', 'markdown'], description: 'markdown returns the MISSIONS.md board text.' },
1001
- },
1002
- required: ['repoRoot'],
1003
- additionalProperties: false,
1004
- },
1005
- aliases: { mission_id: ['missionId'], include_gate: ['includeGate'] },
1006
- output: output(SCHEMAS.status),
1007
- async execute(args) {
1008
- const tower = await Tower.open(args.repoRoot)
1009
- const boardPath = tower.boardFile()
1010
- if (args.missionId) {
1011
- const [status, detail] = await Promise.all([
1012
- tower.status({ missionIds: [args.missionId] }),
1013
- tower.missionDetail(args.missionId),
1014
- ])
1015
- return {
1016
- ...status,
1017
- missions: status.missions.filter((mission) => mission.id === args.missionId),
1018
- missionId: args.missionId,
1019
- detail,
1020
- boardPath,
1021
- }
1022
- }
1023
- const state = await tower.load()
1024
- const total = state.missions.length
1025
- const offset = Number.isInteger(args.offset) && args.offset > 0 ? args.offset : 0
1026
- const limit = Number.isInteger(args.limit) ? Math.max(1, Math.min(100, args.limit)) : 20
1027
- const pageIds = state.missions.slice(offset, offset + limit).map((mission) => mission.id)
1028
- const status = await tower.status({ missionIds: pageIds })
1029
- let page = status.missions
1030
- if (args.includeGate === true) {
1031
- const { activity, parentStatus } = await observeActivity(ctx, tower)
1032
- page = await Promise.all(
1033
- page.map(async (mission) => {
1034
- try {
1035
- const gate = await tower.evaluateGate({ missionId: mission.id, activity, parentStatus })
1036
- return {
1037
- ...mission,
1038
- gate: {
1039
- ok: gate.ok,
1040
- blockers: (gate.blockers ?? []).map((blocker) => ({ code: blocker.code, detail: blocker.detail })),
1041
- },
1042
- }
1043
- } catch (error) {
1044
- return { ...mission, gate: { ok: false, error: classifyError(error).message } }
1045
- }
1046
- }),
1047
- )
1048
- }
1049
- const result = {
1050
- ...status,
1051
- missions: page,
1052
- total,
1053
- offset,
1054
- nextOffset: offset + page.length < total ? offset + page.length : null,
1055
- boardPath,
1056
- }
1057
- if (args.format === 'markdown') {
1058
- const state = await tower.load()
1059
- result.markdown = tower.boardMarkdown(state)
1060
- }
1061
- return result
1062
- },
1063
- })
1064
-
1065
- define(ctx, {
1066
- name: 'tower_spawn',
1067
- description:
1068
- 'Spawn a worker or reviewer for a mission. Workers use the tower-worker preset; reviewers require a completed mission and use the read-only tower-reviewer preset. Provisions (or reuses) the mission git worktree and branch, generates a self-contained briefing, and starts a background subagent in that worktree. The child final report is delivered back to this session; tower_roster shows authoritative activity. Records the agent in the roster; reviewer spawns record the branch tip so a reviewer that modifies the branch cannot submit a review. Returns immediately unless wait=true.',
1069
- parameters: {
1070
- type: 'object',
1071
- properties: {
1072
- repoRoot: { type: 'string', description: 'Absolute path inside the target Git repository.' },
1073
- missionId: { type: 'string', description: 'Mission id, e.g. M1 (canonical; alias: id).' },
1074
- id: { type: 'string', description: 'Alias for missionId; both are accepted.' },
1075
- role: { type: 'string', enum: ROLE_ENUM, description: 'worker executes; reviewer audits the mission branch.' },
1076
- name: { type: 'string', description: 'Unique roster name; defaults to <role>-<missionId>.' },
1077
- instructions: { type: 'string', description: 'Extra tower instructions appended to the briefing.' },
1078
- worktreeRoot: { type: 'string', description: 'Optional absolute parent directory for the worktree.' },
1079
- wait: { type: 'boolean', description: 'Wait for the child result instead of returning immediately (testing/debug).' },
1080
- waitMs: { type: 'number', description: 'Max wait with wait=true, default 180000; clamped to spawn.maxWaitMs (default 90000).' },
1081
- wait_ms: { type: 'number', description: 'Alias for waitMs.' },
1082
- takeover: { type: 'boolean', description: 'Replace a live worker entry that is no longer running (increments ownerGeneration).' },
1083
- includeDirtyBase: { type: 'boolean', description: 'Use the recorded snapshotCommit baseline when true (default true).' },
1084
- },
1085
- required: ['repoRoot'],
1086
- additionalProperties: false,
1087
- },
1088
- aliases: {"missionId":["id"],"waitMs":["wait_ms"]},
1089
- requiresMissionId: true,
1090
- board: true,
1091
- output: output(SCHEMAS.spawn),
1092
- async execute(args, exec) {
1093
- const tower = await Tower.open(args.repoRoot)
1094
- const actor = await actorFor(tower, exec)
1095
- requireTowerActor(actor, 'spawn tower agents')
1096
- const role = args.role ?? 'worker'
1097
- if (!ROLE_ENUM.includes(role)) throw new TowerToolError('invalid-argument', `role must be one of ${ROLE_ENUM.join('|')}`)
1098
- // Repository config (if any) already overrides the profile config here.
1099
- const config = tower.config
1100
- const preset = role === 'reviewer' ? config.spawn.reviewerPreset : config.spawn.workerPreset
1101
- const model = role === 'reviewer' ? config.spawn.reviewerModel : config.spawn.workerModel
1102
- const context = await tower.missionContext(args.missionId)
1103
- if (role === 'reviewer') {
1104
- if (!context.mission.worktree) {
1105
- throw new TowerToolError('reviewer-prerequisite', 'reviewer needs an existing mission worktree; spawn a worker first')
1106
- }
1107
- if (context.mission.status !== 'completed') {
1108
- throw new TowerToolError('reviewer-prerequisite', `reviewer requires a completed mission; ${context.mission.id} is ${context.mission.status}`)
1109
- }
1110
- }
1111
- if (role === 'worker' && ['merged', 'abandoned'].includes(context.mission.status)) {
1112
- throw new TowerToolError(
1113
- 'invalid-transition',
1114
- `mission ${context.mission.id} is ${context.mission.status}; a worker can only be spawned for a planned or active mission`,
1115
- )
1116
- }
1117
- const spawned = await enqueueSpawn(tower.root, async () => {
1118
- const view = await tower.rosterView()
1119
- const inactiveStates = ROSTER_INACTIVE_STATES
1120
- // Resolve a unique roster name BEFORE any activation. The default name
1121
- // is derived per role/mission and gets a numeric suffix on rework rounds.
1122
- let name
1123
- if (typeof args.name === 'string' && args.name.trim()) {
1124
- name = args.name.trim()
1125
- if (view.roster.some((entry) => entry.name === name)) {
1126
- throw new TowerToolError('invalid-argument', 'roster name already used: ' + name)
1127
- }
1128
- } else {
1129
- const base = role + '-' + args.missionId.toLowerCase()
1130
- let candidate = base
1131
- let suffix = 2
1132
- while (view.roster.some((entry) => entry.name === candidate)) {
1133
- candidate = base + '-' + suffix
1134
- suffix += 1
1135
- }
1136
- name = candidate
1137
- }
1138
- let takeover = args.takeover === true
1139
- if (role === 'worker') {
1140
- // Pre-check the single-owner and live-worker rules BEFORE provisioning
1141
- // or starting a child: a rejection must not leak an activation/worktree.
1142
- const liveWorkers = view.roster.filter(
1143
- (entry) => entry.role === 'worker' && !inactiveStates.includes(entry.state),
1144
- )
1145
- const sameMission = liveWorkers.filter((entry) => entry.missionId === args.missionId)
1146
- if (sameMission.length > 1) {
1147
- throw new TowerToolError(
1148
- 'invalid-transition',
1149
- 'mission ' + args.missionId + ' has ' + sameMission.length + ' live worker entries; inspect tower_roster before spawning',
1150
- )
1151
- }
1152
- if (sameMission.length === 1 && !takeover) {
1153
- throw new TowerToolError(
1154
- 'invalid-transition',
1155
- 'mission ' + args.missionId + ' already has a live worker "' + sameMission[0].name + '"; stop it with tower_interrupt, or pass takeover:true after it is no longer running',
1156
- )
1157
- }
1158
- if (takeover) {
1159
- if (sameMission.length === 1) {
1160
- const worker = sameMission[0]
1161
- const observed = await observeActivity(ctx, tower)
1162
- if (observed.parentStatus[worker.parentSessionId] !== 'ok') {
1163
- throw new TowerToolError(
1164
- 'takeover-refused',
1165
- 'cannot verify that worker ' + worker.name + ' stopped: its parent catalog is unavailable',
1166
- )
1167
- }
1168
- const activity = observed.activity[worker.agentId]
1169
- if (activity === 'running') {
1170
- throw new TowerToolError(
1171
- 'takeover-refused',
1172
- 'worker ' + worker.name + ' is still running; call tower_interrupt (or wait) before takeover',
1173
- )
1174
- }
1175
- if (activity === undefined) {
1176
- throw new TowerToolError(
1177
- 'takeover-refused',
1178
- 'worker ' + worker.name + ' is not in the live catalog; run tower_adopt before takeover',
1179
- )
1180
- }
1181
- } else {
1182
- takeover = false
1183
- }
1184
- }
1185
- const replacing = takeover ? sameMission : []
1186
- const remaining = liveWorkers.filter((entry) => !replacing.includes(entry))
1187
- const max = Number(config.spawn.maxLiveWorkers) || 4
1188
- if (remaining.length >= max) {
1189
- throw new TowerToolError(
1190
- 'worker-limit',
1191
- 'live worker limit reached (' + max + '); stop a worker with tower_interrupt or raise spawn.maxLiveWorkers',
1192
- )
1193
- }
1194
- }
1195
-
1196
- // Service/provider/preset checks happen before provisioning, so a failed
1197
- // precheck cannot leave a worktree or roster revision behind.
1198
- const subagents = ctx.get('subagents')
1199
- if (!subagents) throw new TowerToolError('spawn-unavailable', 'subagents service is unavailable; cannot spawn')
1200
- const providers = subagents.list()
1201
- const provider = config.spawn.provider || (providers.includes('spawn') ? 'spawn' : providers[0])
1202
- if (!provider) throw new TowerToolError('provider-missing', 'no subagent provider is registered')
1203
- const presets = ctx.get('agentPresets')
1204
- if (presets && typeof presets.list === 'function') {
1205
- const available = await presets.list()
1206
- if (!available.some((entry) => String(entry.id) === preset)) {
1207
- throw new TowerToolError('preset-missing', 'agent preset "' + preset + '" is not registered; check the Tower bundle preset rows and restart DSH')
1208
- }
1209
- }
1210
- if (typeof config.spawn.requireLease === 'string' && config.spawn.requireLease.trim()) {
1211
- const requiredLease = config.spawn.requireLease.trim()
1212
- const info = await inspectLeases(tower.commonDir)
1213
- const lease = info.leases.find((entry) => entry.name === requiredLease && entry.expired !== true)
1214
- if (!lease) {
1215
- throw new TowerToolError(
1216
- 'lease-busy',
1217
- `spawn.requireLease="${requiredLease}" is not held; acquire it with tower_resource before spawning`,
1218
- )
1219
- }
1220
- if (lease.holder !== actor.name && lease.holder !== String(exec.agent.id)) {
1221
- throw new TowerToolError(
1222
- 'lease-busy',
1223
- `spawn.requireLease="${requiredLease}" is held by "${lease.holder}", not by this tower`,
1224
- )
1225
- }
1226
- }
1227
-
1228
- const provisioned = await tower.provision({
1229
- missionId: args.missionId,
1230
- worktreeRoot: args.worktreeRoot,
1231
- includeDirtyBase: args.includeDirtyBase !== false,
1232
- })
1233
- const { mission, worktree } = provisioned
1234
- const contextWithWorktree = {
1235
- ...context,
1236
- mission: {
1237
- ...context.mission,
1238
- worktree: { path: worktree.path, branch: worktree.branch, baseCommit: worktree.baseCommit },
1239
- },
1240
- }
1241
- const briefing =
1242
- role === 'worker'
1243
- ? workerBriefing({
1244
- repoRoot: tower.root,
1245
- base: context.base,
1246
- mission: contextWithWorktree.mission,
1247
- deps: context.deps,
1248
- worktree,
1249
- instructions: args.instructions,
1250
- reviews: context.reviews,
1251
- findings: context.findings,
1252
- completionReport: context.completionReport,
1253
- })
1254
- : reviewerBriefing({
1255
- repoRoot: tower.root,
1256
- base: context.base,
1257
- mission: contextWithWorktree.mission,
1258
- deps: context.deps,
1259
- worktree,
1260
- instructions: args.instructions,
1261
- reviews: context.reviews,
1262
- findings: context.findings,
1263
- completionReport: context.completionReport,
1264
- })
1265
-
1266
- const agentOptions = {}
1267
- for (const key of ['provider', 'model', 'reasoningEffort', 'maxTokens']) {
1268
- if (model && model[key] !== undefined && model[key] !== null && model[key] !== '') agentOptions[key] = model[key]
1269
- }
1270
-
1271
- if (role === 'worker' && tower.config.tests?.admission === 'spawn') {
1272
- const discovery = await runMissionDiscovery(tower, mission)
1273
- if (discovery.configured && discovery.receipt.ok !== true) {
1274
- throw new TowerToolError(
1275
- 'tests-failed',
1276
- `test admission failed for ${mission.id}: ${discovery.receipt.error}; no child was started`,
1277
- )
1278
- }
1279
- }
1280
-
1281
- let activation = null
1282
- let agentId = null
1283
- let entry = null
1284
- await tower.reserveRoster({
1285
- missionId: mission.id,
1286
- name,
1287
- role,
1288
- parentSessionId: String(exec.agent.id),
1289
- cwd: worktree.path,
1290
- branch: worktree.branch,
1291
- })
1292
- try {
1293
- activation = await subagents.startActivation({
1294
- provider,
1295
- label: name,
1296
- request: {
1297
- cwd: worktree.path,
1298
- agentPreset: preset,
1299
- prompt: [{ type: 'text', text: briefing }],
1300
- parent: exec.agent,
1301
- maxDepth: config.spawn.maxDepth ?? 1,
1302
- ...(Object.keys(agentOptions).length > 0 ? { agentOptions } : {}),
1303
- },
1304
- signal: exec.signal,
1305
- delivery: 'parent',
1306
- })
1307
- agentId = String(activation.childId)
1308
- entry = await tower.recordSpawn({
1309
- missionId: mission.id,
1310
- name,
1311
- role,
1312
- agentId,
1313
- parentSessionId: String(exec.agent.id),
1314
- cwd: worktree.path,
1315
- branch: worktree.branch,
1316
- takeover,
1317
- })
1318
- } catch (error) {
1319
- // The child may already be running even when recordSpawn failed: stop
1320
- // it and mark the reserved entry failed so it cannot keep writing
1321
- // unobserved.
1322
- if (agentId) {
1323
- try {
1324
- await Promise.resolve(subagents.interrupt(agentId, { kind: 'ancestor', agent: exec.agent }))
1325
- } catch {
1326
- // best effort; the error below is the actionable one
1327
- }
1328
- }
1329
- await tower
1330
- .recordSpawnOutcome({
1331
- missionId: mission.id,
1332
- name,
1333
- state: 'failed',
1334
- detail: `spawn failed${agentId ? ` after activation ${agentId}` : ' before activation'}: ${String(error?.message ?? error).slice(0, 300)}`,
1335
- })
1336
- .catch(() => {})
1337
- throw error
1338
- }
1339
-
1340
- const recordOutcome = (state, detail) =>
1341
- tower.recordSpawnOutcome({ missionId: mission.id, name, state, detail }).catch(() => {})
1342
-
1343
- const report = {
1344
- name,
1345
- agentId,
1346
- role,
1347
- missionId: mission.id,
1348
- preset,
1349
- provider,
1350
- maxDepth: config.spawn.maxDepth ?? 1,
1351
- ...(Object.keys(agentOptions).length > 0 ? { agentOptions } : {}),
1352
- worktree: { path: worktree.path, branch: worktree.branch, baseCommit: worktree.baseCommit },
1353
- created: provisioned.created,
1354
- briefingChars: briefing.length,
1355
- takeover,
1356
- rosterEntry: entry,
1357
- }
1358
-
1359
- return { report, activation, recordOutcome }
1360
- })
1361
- const { report, activation, recordOutcome } = spawned
1362
- if (args.wait !== true) {
1363
- activation.result
1364
- .then((result) => {
1365
- const state = result?.stopReason === 'completed' ? 'inactive' : 'failed'
1366
- return recordOutcome(state, result?.diagnostic ?? result?.stopReason ?? null)
1367
- })
1368
- .catch((error) => recordOutcome('failed', String(error?.message ?? error)))
1369
- return report
1370
- }
1371
- const waitMs = Math.max(1000, Math.min(config.spawn.maxWaitMs ?? 90000, args.waitMs ?? 180000))
1372
- let timer
1373
- const timeout = new Promise((resolve) => {
1374
- timer = setTimeout(() => resolve({ __timeout: true }), waitMs)
1375
- })
1376
- let result
1377
- try {
1378
- result = await Promise.race([activation.result, timeout])
1379
- } catch (error) {
1380
- report.stopReason = 'failed'
1381
- report.diagnostic = String(error?.message ?? error)
1382
- await recordOutcome('failed', report.diagnostic)
1383
- throw error
1384
- } finally {
1385
- clearTimeout(timer)
1386
- }
1387
- if (result && result.__timeout) {
1388
- report.timedOut = true
1389
- recordOutcome('unknown', 'spawn wait timed out; check tower_roster')
1390
- } else {
1391
- report.stopReason = result?.stopReason ?? null
1392
- report.output = textOf(result?.output)
1393
- report.diagnostic = result?.diagnostic ?? null
1394
- const state = report.stopReason === 'completed' ? 'inactive' : 'failed'
1395
- if (state === 'failed') report.diagnostic = report.diagnostic ?? report.stopReason
1396
- await recordOutcome(state, report.diagnostic ?? null)
1397
- }
1398
- return report
1399
-
1400
- },
1401
- })
1402
-
1403
- define(ctx, {
1404
- name: 'tower_roster',
1405
- description:
1406
- 'List tower roster agents with authoritative live activity from the subagent catalog (running/inactive/missing/unknown). Read-only; never resumes children. Use before completion or merge decisions.',
1407
- parameters: {
1408
- type: 'object',
1409
- properties: {
1410
- repoRoot: { type: 'string', description: 'Absolute path inside the target Git repository.' },
1411
- },
1412
- required: ['repoRoot'],
1413
- additionalProperties: false,
1414
- },
1415
- output: output(SCHEMAS.roster),
1416
- async execute(args) {
1417
- const tower = await Tower.open(args.repoRoot)
1418
- const view = await tower.rosterView()
1419
- const subagents = ctx.get('subagents')
1420
- if (!subagents) throw new TowerToolError('spawn-unavailable', 'subagents service is unavailable; cannot observe roster')
1421
-
1422
- const parents = [...new Set(view.roster.map((entry) => entry.parentSessionId).filter(Boolean))]
1423
- const activity = new Map()
1424
- const parentStatus = {}
1425
- for (const parent of parents) {
1426
- try {
1427
- const rows = await subagents.listDescendants(parent)
1428
- for (const row of rows) activity.set(String(row.id), row.activity ?? 'unknown')
1429
- parentStatus[parent] = 'ok'
1430
- } catch (error) {
1431
- parentStatus[parent] = `unavailable: ${String(error?.message ?? error)}`
1432
- }
1433
- }
1434
-
1435
- const staleMs = Number(tower.config.spawn?.staleMs) || 0
1436
- const now = Date.now()
1437
- const roster = view.roster.map((entry) => {
1438
- const observed = activity.get(entry.agentId)
1439
- const known = parentStatus[entry.parentSessionId] === 'ok'
1440
- const stale =
1441
- staleMs > 0 &&
1442
- observed === undefined &&
1443
- known &&
1444
- !['retired', 'failed', 'interrupted', 'missing', 'stale'].includes(entry.state) &&
1445
- now - Number(entry.spawnedAt ?? 0) > staleMs
1446
- return {
1447
- ...entry,
1448
- activity: observed ?? (known ? 'missing' : 'unknown'),
1449
- observed: observed !== undefined,
1450
- stale,
1451
- }
1452
- })
1453
- return { revision: view.revision, roster, parentStatus, missions: view.missions }
1454
- },
1455
- })
1456
-
1457
- define(ctx, {
1458
- name: 'tower_interrupt',
1459
- description:
1460
- 'Interrupt one running tower roster agent (worker or reviewer) and record it as interrupted in the roster. Only the tower may interrupt. Idempotent: an agent that is not running returns interrupted:false. Use it before takeover after a runaway worker, or to stop work that is no longer wanted.',
1461
- parameters: {
1462
- type: 'object',
1463
- properties: {
1464
- repoRoot: { type: 'string', description: 'Absolute path inside the target Git repository.' },
1465
- to: { type: 'string', description: 'Roster name to interrupt.' },
1466
- agentId: { type: 'string', description: 'Agent/session id to interrupt (alternative to to).' },
1467
- agent_id: { type: 'string', description: 'Alias for agentId.' },
1468
- reason: { type: 'string', description: 'Why the agent is being interrupted (recorded in the roster).' },
1469
- },
1470
- required: ['repoRoot'],
1471
- additionalProperties: false,
1472
- },
1473
- aliases: { agentId: ['agent_id'] },
1474
- output: output(SCHEMAS.interrupt),
1475
- async execute(args, exec) {
1476
- const tower = await Tower.open(args.repoRoot)
1477
- const actor = await actorFor(tower, exec)
1478
- requireTowerActor(actor, 'interrupt tower agents')
1479
- if (!args.to && !args.agentId) throw new TowerToolError('invalid-argument', 'tower_interrupt requires to (roster name) or agentId')
1480
- const result = await interruptRosterAgent(ctx, tower, exec, {
1481
- to: args.to,
1482
- agentId: args.agentId,
1483
- reason: args.reason,
1484
- })
1485
- return { ok: true, ...result }
1486
- },
1487
- })
1488
-
1489
- define(ctx, {
1490
- name: 'tower_diff',
1491
- description:
1492
- 'Read-only view of a mission branch for review: merge base, changed files, commits, and a capped patch against the recorded base. Safe for reviewers; it never modifies the repository.',
1493
- parameters: {
1494
- type: 'object',
1495
- properties: {
1496
- repoRoot: { type: 'string', description: 'Absolute path to the main checkout root.' },
1497
- missionId: { type: 'string', description: 'Mission id, e.g. M1 (canonical; alias: id).' },
1498
- id: { type: 'string', description: 'Alias for missionId; both are accepted.' },
1499
- maxBytes: { type: 'integer', minimum: 1024, description: 'Patch cap in bytes (default 65536).' },
1500
- },
1501
- required: ['repoRoot'],
1502
- additionalProperties: false,
1503
- },
1504
- aliases: {"missionId":["id"]},
1505
- requiresMissionId: true,
1506
- output: output(SCHEMAS.diff),
1507
- async execute(args) {
1508
- const tower = await Tower.open(args.repoRoot)
1509
- return tower.diff({ missionId: args.missionId, maxBytes: args.maxBytes })
1510
- },
1511
- })
1512
-
1513
- define(ctx, {
1514
- name: 'tower_adopt',
1515
- description:
1516
- 'Reconcile the roster with the durable subagent catalog after a restart or new session. Agents that are provably gone are marked retired (records are kept) or missing (retireMissing:false, so the tower can inspect/take over); agents whose parent catalog cannot be read stay unknown and are never changed. Only the tower may adopt.',
1517
- parameters: {
1518
- type: 'object',
1519
- properties: {
1520
- repoRoot: { type: 'string', description: 'Absolute path inside the target Git repository.' },
1521
- retireMissing: { type: 'boolean', description: 'Retire provably gone agents (default true); false marks them missing instead.' },
1522
- },
1523
- required: ['repoRoot'],
1524
- additionalProperties: false,
1525
- },
1526
- aliases: { retire_missing: ['retireMissing'] },
1527
- output: output(SCHEMAS.adopt),
1528
- async execute(args, exec) {
1529
- const tower = await Tower.open(args.repoRoot)
1530
- const actor = await actorFor(tower, exec)
1531
- requireTowerActor(actor, 'adopt the roster')
1532
- const view = await tower.rosterView()
1533
- const subagents = ctx.get('subagents')
1534
- if (!subagents) throw new TowerToolError('spawn-unavailable', 'subagents service is unavailable; cannot adopt')
1535
-
1536
- const parents = [...new Set(view.roster.map((entry) => entry.parentSessionId).filter(Boolean))]
1537
- const activity = new Map()
1538
- const parentStatus = {}
1539
- for (const parent of parents) {
1540
- try {
1541
- const rows = await subagents.listDescendants(parent)
1542
- for (const row of rows) activity.set(String(row.id), row.activity ?? 'unknown')
1543
- parentStatus[parent] = 'ok'
1544
- } catch (error) {
1545
- parentStatus[parent] = `unavailable: ${String(error?.message ?? error)}`
1546
- }
1547
- }
1548
-
1549
- const retiredCandidates = []
1550
- const presentIds = []
1551
- const absentIds = []
1552
- for (const entry of view.roster) {
1553
- if (entry.state === 'retired') continue
1554
- if (parentStatus[entry.parentSessionId] !== 'ok') continue
1555
- if (activity.has(entry.agentId)) {
1556
- presentIds.push(entry.agentId)
1557
- } else {
1558
- absentIds.push(entry.agentId)
1559
- }
1560
- }
1561
- const observations = await tower.recordRosterObservations({ present: presentIds, absent: absentIds, confirmations: 2 })
1562
- retiredCandidates.push(...observations.candidates)
1563
- const retireMissing = args.retireMissing !== false
1564
- const { retired } = retireMissing ? await tower.markRetired(retiredCandidates) : { retired: [] }
1565
- const { missing } = retireMissing ? { missing: [] } : await tower.markMissing(retiredCandidates)
1566
- const after = await tower.rosterView()
1567
- return {
1568
- retired,
1569
- missing,
1570
- unconfirmed: absentIds.filter((agentId) => !retiredCandidates.includes(agentId)),
1571
- skippedUnknownParents: Object.entries(parentStatus)
1572
- .filter(([, status]) => status !== 'ok')
1573
- .map(([parent]) => parent),
1574
- roster: after.roster,
1575
- parentStatus,
1576
- }
1577
- },
1578
- })
1579
-
1580
- define(ctx, {
1581
- name: 'tower_send',
1582
- description:
1583
- 'Send a durable inbox message to a tower participant: a roster agent name, "tower", or "all" (broadcast). The message is persisted first; when the recipient is a currently running roster agent the tower also steers it into that child\'s current turn. Returns delivery results.',
1584
- parameters: {
1585
- type: 'object',
1586
- properties: {
1587
- repoRoot: { type: 'string', description: 'Absolute path inside the target Git repository.' },
1588
- to: { type: 'string', description: 'Recipient: a roster agent name, "tower", or "all".' },
1589
- subject: { type: 'string', description: 'One-line subject.' },
1590
- body: { type: 'string', description: 'Full message body (markdown).' },
1591
- scope: { type: 'string', description: 'Optional scope tag, e.g. the mission id.' },
1592
- interrupt: {
1593
- type: 'boolean',
1594
- description: 'Interrupt the recipient\'s current turn before delivering (Kimi-style "message interrupts the running turn"); default false = queue at the next step boundary.',
1595
- },
1596
- consentRef: { type: 'string', description: 'Optional audit reference recording who consented to this instruction.' },
1597
- consent_ref: { type: 'string', description: 'Alias for consentRef.' },
1598
- },
1599
- required: ['repoRoot', 'to', 'subject', 'body'],
1600
- additionalProperties: false,
1601
- },
1602
- aliases: { consentRef: ['consent_ref'] },
1603
- output: output(SCHEMAS.send),
1604
- async execute(args, exec) {
1605
- const tower = await Tower.open(args.repoRoot)
1606
- checkSendRate(tower.config, args.repoRoot)
1607
- const actor = await actorFor(tower, exec)
1608
- const view = await tower.rosterView()
1609
- const from = actor.role === 'tower' ? 'tower' : actor.name
1610
- if (actor.role === 'unknown') {
1611
- throw new TowerToolError('not-authorized', 'tower_send requires a registered roster agent or the tower')
1612
- }
1613
- if (args.to === 'all' && actor.role !== 'tower') {
1614
- throw new TowerToolError('not-authorized', 'only the tower may broadcast to "all"')
1615
- }
1616
- const sendConfig = tower.config.send ?? {}
1617
- const interrupt =
1618
- args.interrupt === undefined ? sendConfig.interruptDefault === true : args.interrupt === true
1619
- if (interrupt && actor.role !== 'tower') {
1620
- throw new TowerToolError('not-authorized', 'only the tower may interrupt a recipient with tower_send')
1621
- }
1622
- if (actor.role !== 'tower') {
1623
- const self = view.roster.find((entry) => entry.agentId === actor.agentId)
1624
- const target = view.roster.find((entry) => entry.name === args.to)
1625
- const allowed =
1626
- args.to === 'tower' ||
1627
- (self && target && target.agentId !== self.agentId && target.missionId === self.missionId)
1628
- if (!allowed) {
1629
- throw new TowerToolError('not-authorized', `${actor.name} may only message the tower or agents on the same mission`)
1630
- }
1631
- }
1632
- if (typeof args.subject !== 'string' || args.subject.trim().length === 0) {
1633
- throw new TowerToolError('invalid-argument', 'tower_send requires a subject')
1634
- }
1635
- if (typeof args.body !== 'string' || args.body.trim().length === 0) {
1636
- throw new TowerToolError('invalid-argument', 'tower_send requires a body')
1637
- }
1638
- const message = await tower.recordMessage({
1639
- from,
1640
- to: args.to,
1641
- subject: args.subject.trim(),
1642
- body: args.body,
1643
- scope: args.scope,
1644
- consentRef: args.consentRef,
1645
- })
1646
-
1647
- const subagents = ctx.get('subagents')
1648
- const results = []
1649
- if (!subagents) {
1650
- results.push({ target: '*', pushed: false, reason: 'subagents service unavailable; persisted only' })
1651
- } else {
1652
- const roster = view.roster
1653
- const parents = [...new Set(roster.map((entry) => entry.parentSessionId).filter(Boolean))]
1654
- const activity = new Map()
1655
- for (const parent of parents) {
1656
- try {
1657
- const rows = await subagents.listDescendants(parent)
1658
- for (const row of rows) activity.set(String(row.id), row.activity ?? 'unknown')
1659
- } catch {
1660
- // an unreadable parent catalog only means "do not wake it"
1661
- }
1662
- }
1663
- const targets =
1664
- args.to === 'all' ? roster.map((entry) => entry.name) : [args.to]
1665
- for (const targetName of targets) {
1666
- if (targetName === 'tower') {
1667
- results.push({ target: 'tower', pushed: false, reason: 'tower reads its inbox' })
1668
- continue
1669
- }
1670
- const entry = roster.find((candidate) => candidate.name === targetName)
1671
- if (!entry) {
1672
- results.push({ target: targetName, pushed: false, reason: 'not in roster' })
1673
- continue
1674
- }
1675
- if (activity.get(entry.agentId) !== 'running') {
1676
- results.push({ target: targetName, pushed: false, reason: 'not running; queued in inbox' })
1677
- continue
1678
- }
1679
- let interrupted = false
1680
- if (interrupt) {
1681
- try {
1682
- subagents.interrupt(entry.agentId, { kind: 'ancestor', agent: exec.agent })
1683
- interrupted = true
1684
- await tower.recordInterrupt({
1685
- agentId: entry.agentId,
1686
- reason: `tower_send${message?.id ? ` ${message.id}` : ''}: ${args.subject.trim().slice(0, 120)}`,
1687
- })
1688
- } catch (error) {
1689
- results.push({ target: targetName, pushed: false, reason: `interrupt failed: ${String(error?.message ?? error)}` })
1690
- continue
1691
- }
1692
- }
1693
- try {
1694
- await subagents.sendMessage(
1695
- exec.agent,
1696
- entry.agentId,
1697
- [
1698
- {
1699
- type: 'text',
1700
- text: `[tower inbox ${message.id}] ${message.subject}\n\n${message.body}`,
1701
- },
1702
- ],
1703
- { signal: exec.signal },
1704
- )
1705
- results.push({ target: targetName, pushed: true, interrupted })
1706
- } catch (error) {
1707
- results.push({ target: targetName, pushed: false, reason: String(error?.message ?? error) })
1708
- }
1709
- }
1710
- }
1711
- if (results.length > 0) await tower.recordDelivery(message.id, results)
1712
- return {
1713
- message: { id: message.id, from, to: message.to, subject: message.subject },
1714
- deliveries: results,
1715
- }
1716
- },
1717
- })
1718
-
1719
- define(ctx, {
1720
- name: 'tower_inbox',
1721
- description:
1722
- 'Read your tower inbox: messages addressed to you plus broadcasts, newest first. Only returned messages are acknowledged; the result reports unreadRemaining, so keep reading until it reaches 0. Set include_read for history. Unread messages block tower_complete.',
1723
- parameters: {
1724
- type: 'object',
1725
- properties: {
1726
- repoRoot: { type: 'string', description: 'Absolute path inside the target Git repository.' },
1727
- limit: { type: 'number', description: 'Max messages to return (default 20), newest first.' },
1728
- include_read: { type: 'boolean', description: 'Include previously acknowledged messages for history lookup.' },
1729
- includeRead: { type: 'boolean', description: 'Alias for include_read.' },
1730
- },
1731
- required: ['repoRoot'],
1732
- additionalProperties: false,
1733
- },
1734
- aliases: {"include_read":["includeRead"]},
1735
- output: output(SCHEMAS.inbox),
1736
- async execute(args, exec) {
1737
- const tower = await Tower.open(args.repoRoot)
1738
- const view = await tower.rosterView()
1739
- const self = view.roster.find((entry) => entry.agentId === String(exec.agent.id))
1740
- const reader = self ? self.name : 'tower'
1741
- return tower.inbox({ reader, limit: args.limit, includeRead: args.include_read === true })
1742
- },
1743
- })
1744
-
1745
- define(ctx, {
1746
- name: 'tower_complete',
1747
- description:
1748
- 'Worker-only: report mission completion. Enforced by the store: every task done or dropped, no blockers, the current owner, the mission branch has at least one commit since its spawn base, and no unread inbox messages. checks records verifiable evidence (name/command/exitCode/evidence); gates.requireChecks can require at least one before merge. Stores the full report; completion means ready for preflight and optional review, not merged.',
1749
- parameters: {
1750
- type: 'object',
1751
- properties: {
1752
- repoRoot: { type: 'string', description: 'Absolute path inside the target Git repository.' },
1753
- missionId: { type: 'string', description: 'Mission id, e.g. M1 (canonical; alias: id).' },
1754
- id: { type: 'string', description: 'Alias for missionId; both are accepted.' },
1755
- report: { type: 'string', description: 'Full completion report for the tower and the next review round.' },
1756
- checks: {
1757
- type: 'array',
1758
- description: 'Optional evidence ledger entries: {name, command?, exitCode?, evidence?}.',
1759
- items: {
1760
- type: 'object',
1761
- properties: {
1762
- name: { type: 'string', description: 'What was checked.' },
1763
- command: { type: 'string', description: 'Command or method used.' },
1764
- exitCode: { type: 'integer', description: 'Observed exit code.' },
1765
- evidence: { type: 'string', description: 'Short evidence line/log reference.' },
1766
- },
1767
- required: ['name'],
1768
- additionalProperties: false,
1769
- },
1770
- },
1771
- },
1772
- required: ['repoRoot', 'report'],
1773
- additionalProperties: false,
1774
- },
1775
- aliases: {"missionId":["id"]},
1776
- requiresMissionId: true,
1777
- board: true,
1778
- output: output(SCHEMAS.complete),
1779
- async execute(args, exec) {
1780
- const tower = await Tower.open(args.repoRoot)
1781
- // Validate authorization/state before executing repository-controlled
1782
- // test code or writing a test receipt.
1783
- await tower.checkCompletion({
1784
- missionId: args.missionId,
1785
- agentId: String(exec.agent.id),
1786
- report: args.report,
1787
- })
1788
- const context = await tower.missionContext(args.missionId)
1789
- const checks = [...(Array.isArray(args.checks) ? args.checks : [])]
1790
- const testsConfig = tower.config.tests ?? {}
1791
- if (testsConfig.enabled !== false && testsConfig.admission === 'complete') {
1792
- const discovery = await runMissionDiscovery(tower, { id: args.missionId, worktree: context.mission.worktree })
1793
- if (discovery.configured && discovery.receipt.ok !== true) {
1794
- throw new TowerToolError('tests-failed', `test discovery failed for ${args.missionId}: ${discovery.receipt.error}`)
1795
- }
1796
- if (discovery.configured && testsConfig.autoChecks !== false && discovery.check) {
1797
- if (checks.length >= 20) checks[19] = discovery.check
1798
- else checks.push(discovery.check)
1799
- }
1800
- }
1801
- return tower.complete({
1802
- missionId: args.missionId,
1803
- agentId: String(exec.agent.id),
1804
- report: args.report,
1805
- checks,
1806
- })
1807
- },
1808
- })
1809
-
1810
- define(ctx, {
1811
- name: 'tower_finding',
1812
- description:
1813
- 'File a structured finding (bug / improve / vuln / idea) for the tower to route. Use this for anything notable OUTSIDE your mission scope instead of editing out-of-scope files. Severity is one of low|medium|high|critical. Use tower_findings to list, read, or close findings later.',
1814
- parameters: {
1815
- type: 'object',
1816
- properties: {
1817
- repoRoot: { type: 'string', description: 'Absolute path inside the target Git repository.' },
1818
- type: { type: 'string', enum: ['bug', 'improve', 'vuln', 'idea'], description: 'Finding category.' },
1819
- title: { type: 'string', description: 'Short finding title.' },
1820
- severity: { type: 'string', enum: ['low', 'medium', 'high', 'critical'], description: 'Optional severity.' },
1821
- summary: { type: 'string', description: 'What was found, in a sentence or two.' },
1822
- location: { type: 'string', description: 'File/symbol the finding concerns.' },
1823
- details: { type: 'string', description: 'Full details: evidence, reproduction, impact.' },
1824
- suggested_fix: { type: 'string', description: 'What you would do about it.' },
1825
- missionId: { type: 'string', description: 'Optional mission this finding relates to (alias: mission_id).' },
1826
- mission_id: { type: 'string', description: 'Alias for missionId.' },
1827
- },
1828
- required: ['repoRoot', 'type', 'title', 'summary', 'details', 'suggested_fix'],
1829
- additionalProperties: false,
1830
- },
1831
- aliases: {"missionId":["mission_id"]},
1832
- board: true,
1833
- output: output(SCHEMAS.finding),
1834
- async execute(args, exec) {
1835
- const tower = await Tower.open(args.repoRoot)
1836
- const actor = await actorFor(tower, exec)
1837
- requireKnownActor(actor, 'tower_finding')
1838
- const view = await tower.rosterView()
1839
- const self = view.roster.find((entry) => entry.agentId === String(exec.agent.id))
1840
- return tower.addFinding({
1841
- type: args.type,
1842
- title: args.title,
1843
- severity: args.severity,
1844
- summary: args.summary,
1845
- location: args.location,
1846
- details: args.details,
1847
- suggested_fix: args.suggested_fix,
1848
- missionId: args.missionId,
1849
- reportedBy: self ? self.name : 'tower',
1850
- })
1851
- },
1852
- })
1853
-
1854
- define(ctx, {
1855
- name: 'tower_findings',
1856
- description:
1857
- 'List, read, or update findings for a repository. Without id it lists findings newest-first with counts by status (filters: status, type, severity, missionId, open, limit). With id it returns that finding; with id plus status and/or note it records a lifecycle transition. Statuses are open -> triaged -> fixed|wontfix, and fixed/wontfix can be reopened to open. Only the tower or a worker may change a finding; reviewers may only list and read. Read-only when no id is given.',
1858
- parameters: {
1859
- type: 'object',
1860
- properties: {
1861
- repoRoot: { type: 'string', description: 'Absolute path inside the target Git repository.' },
1862
- id: { type: 'string', description: 'Finding id, e.g. F1. When present the call reads or updates that finding.' },
1863
- status: {
1864
- type: 'string',
1865
- enum: ['open', 'triaged', 'fixed', 'wontfix', 'all'],
1866
- description: 'With id: target status. Without id: filter (default all).',
1867
- },
1868
- note: { type: 'string', description: 'Optional note recorded together with an update.' },
1869
- type: { type: 'string', enum: ['bug', 'improve', 'vuln', 'idea'], description: 'Filter by finding type.' },
1870
- severity: { type: 'string', enum: ['low', 'medium', 'high', 'critical'], description: 'Filter by severity.' },
1871
- missionId: { type: 'string', description: 'Filter by mission id (alias: mission_id).' },
1872
- mission_id: { type: 'string', description: 'Alias for missionId.' },
1873
- open: { type: 'boolean', description: 'Only findings that are not fixed or wontfix.' },
1874
- limit: { type: 'integer', minimum: 1, maximum: 200, description: 'Max findings to list (default 50).' },
1875
- },
1876
- required: ['repoRoot'],
1877
- additionalProperties: false,
1878
- },
1879
- aliases: {"missionId":["mission_id"]},
1880
- output: output(SCHEMAS.findings),
1881
- async execute(args, exec) {
1882
- const tower = await Tower.open(args.repoRoot)
1883
- const actor = await actorFor(tower, exec)
1884
- requireKnownActor(actor, 'tower_findings')
1885
- const view = await tower.rosterView()
1886
- const self = view.roster.find((entry) => entry.agentId === String(exec.agent.id))
1887
- return tower.findings({
1888
- id: args.id,
1889
- status: args.status,
1890
- note: args.note,
1891
- type: args.type,
1892
- severity: args.severity,
1893
- missionId: args.missionId,
1894
- open: args.open,
1895
- limit: args.limit,
1896
- actor: { name: self ? self.name : 'tower', role: self ? self.role : 'tower' },
1897
- })
1898
- },
1899
- })
1900
-
1901
- define(ctx, {
1902
- name: 'tower_review',
1903
- description:
1904
- 'Submit an independent review verdict for a completed mission, bound to the current branch tip. status is "clean", "p1-Nitems", or "p2-Nitems"; merge is "merge", "fix-then-merge", or "hold" (only clean + merge is later approval). Non-clean returns the mission to active. Reviewers are capped at 5 rounds per branch; a tower-submitted review is break-glass and requires breakGlassReason.',
1905
- parameters: {
1906
- type: 'object',
1907
- properties: {
1908
- repoRoot: { type: 'string', description: 'Absolute path inside the target Git repository.' },
1909
- missionId: { type: 'string', description: 'Mission id, e.g. M1 (canonical; alias: id).' },
1910
- id: { type: 'string', description: 'Alias for missionId; both are accepted.' },
1911
- status: { type: 'string', description: 'Verdict: "clean", or "p1-Nitems" / "p2-Nitems" with the number of findings at that priority.' },
1912
- merge: { type: 'string', enum: ['merge', 'fix-then-merge', 'hold'], description: 'Merge recommendation for the tower.' },
1913
- findings: { type: 'string', description: 'Full findings text (markdown); write "none" when clean.' },
1914
- checks: { type: 'array', items: { type: 'string' }, description: 'Checklist items you verified.' },
1915
- decision: { type: 'string', description: 'The reasoning behind your verdict.' },
1916
- breakGlassReason: { type: 'string', description: 'Tower-only exceptional self-review: why no independent reviewer was used.' },
1917
- },
1918
- required: ['repoRoot', 'status', 'merge', 'findings', 'decision'],
1919
- additionalProperties: false,
1920
- },
1921
- aliases: {"missionId":["id"]},
1922
- requiresMissionId: true,
1923
- board: true,
1924
- output: output(SCHEMAS.review),
1925
- async execute(args, exec) {
1926
- const tower = await Tower.open(args.repoRoot)
1927
- const actor = await actorFor(tower, exec)
1928
- if (!['reviewer', 'tower'].includes(actor.role)) {
1929
- throw new TowerToolError('not-authorized', `only an assigned reviewer or the tower may review; ${actor.name} is ${actor.role}`)
1930
- }
1931
- if (actor.role === 'tower' && (typeof args.breakGlassReason !== 'string' || args.breakGlassReason.trim().length === 0)) {
1932
- throw new TowerToolError('not-authorized', 'a tower-submitted review is break-glass and requires a non-empty breakGlassReason')
1933
- }
1934
- return tower.submitReview({
1935
- missionId: args.missionId,
1936
- agentId: String(exec.agent.id),
1937
- actor,
1938
- status: args.status,
1939
- merge: args.merge,
1940
- findings: args.findings,
1941
- checks: args.checks,
1942
- decision: args.decision,
1943
- breakGlassReason: args.breakGlassReason,
1944
- })
1945
- },
1946
- })
1947
-
1948
- define(ctx, {
1949
- name: 'tower_preflight',
1950
- description:
1951
- 'Read-only merge gate for one mission: returns ok plus stable blocker codes (branch-missing, deps-unmerged, read-only-survey, stale-base, out-of-scope, base-dirty, branch-owned-by-closed-missions, and mission/worker guards). Review blockers (no-review, review-hold, not-clean, review-not-approved, tip-moved, review-mission-mismatch) apply only when review.requireReview is on. Never changes anything.',
1952
- parameters: {
1953
- type: 'object',
1954
- properties: {
1955
- repoRoot: { type: 'string', description: 'Absolute path inside the target Git repository.' },
1956
- missionId: { type: 'string', description: 'Mission id, e.g. M1 (canonical; alias: id).' },
1957
- id: { type: 'string', description: 'Alias for missionId; both are accepted.' },
1958
- },
1959
- required: ['repoRoot'],
1960
- additionalProperties: false,
1961
- },
1962
- aliases: {"missionId":["id"]},
1963
- requiresMissionId: true,
1964
- output: output(SCHEMAS.preflight),
1965
- async execute(args) {
1966
- const tower = await Tower.open(args.repoRoot)
1967
- const { activity, parentStatus } = await observeActivity(ctx, tower)
1968
- const gate = await tower.evaluateGate({ missionId: args.missionId, activity, parentStatus })
1969
- return {
1970
- ok: gate.ok,
1971
- blockers: gate.blockers,
1972
- missionId: args.missionId,
1973
- branchTip: gate.branchTip ?? null,
1974
- baseTip: gate.baseTip ?? null,
1975
- changedFiles: gate.changedFiles ?? [],
1976
- review: gate.review
1977
- ? {
1978
- round: gate.review.round,
1979
- status: gate.review.status,
1980
- mergeVerdict: gate.review.mergeVerdict,
1981
- reviewer: gate.review.reviewer,
1982
- reviewedCommit: gate.review.reviewedCommit,
1983
- }
1984
- : null,
1985
- }
1986
- },
1987
- })
1988
-
1989
- define(ctx, {
1990
- name: 'tower_merge',
1991
- description:
1992
- 'Merge a mission branch into the recorded base branch with --no-ff, after the hard gate passes. Verifies the two merge parents and records the mission as merged. If the merge commit already landed (manual merge, crashed or slow pre-merge hook), it detects the merge commit and records it as reconciled instead of merging again. A failed gate returns stable blocker codes without merging; a hook failure aborts cleanly and returns "merge-hook-failed" while a real content conflict returns "merge-conflict".',
1993
- parameters: {
1994
- type: 'object',
1995
- properties: {
1996
- repoRoot: { type: 'string', description: 'Absolute path inside the target Git repository.' },
1997
- missionId: { type: 'string', description: 'Mission id, e.g. M1 (canonical; alias: id).' },
1998
- id: { type: 'string', description: 'Alias for missionId; both are accepted.' },
1999
- timeoutMs: {
2000
- type: 'integer',
2001
- minimum: 1000,
2002
- description: 'Optional timeout for the merge command in milliseconds, including pre-merge hooks (default 15 minutes).',
2003
- },
2004
- timeout_ms: {
2005
- type: 'integer',
2006
- minimum: 1000,
2007
- description: 'Alias for timeoutMs.',
2008
- },
2009
- },
2010
- required: ['repoRoot'],
2011
- additionalProperties: false,
2012
- },
2013
- aliases: {"missionId":["id"],"timeoutMs":["timeout_ms"]},
2014
- requiresMissionId: true,
2015
- board: true,
2016
- output: output(SCHEMAS.merge),
2017
- async execute(args, exec) {
2018
- const tower = await Tower.open(args.repoRoot)
2019
- const actor = await actorFor(tower, exec)
2020
- requireTowerActor(actor, 'merge missions')
2021
- const { activity, parentStatus } = await observeActivity(ctx, tower)
2022
- return tower.merge({ missionId: args.missionId, activity, parentStatus, timeoutMs: args.timeoutMs })
2023
- },
2024
- })
2025
-
2026
- define(ctx, {
2027
- name: 'tower_rebase',
2028
- description:
2029
- 'Rebase a mission branch onto the current base tip inside its own worktree. Refuses while its worker is running or the worktree is dirty. A clean conflict-free rebase waives re-review for the old review; a conflict is aborted and the mission is marked blocked. If the branch is already contained in the base, it returns alreadyMerged and leaves the branch untouched so tower_merge can record the landed merge.',
2030
- parameters: {
2031
- type: 'object',
2032
- properties: {
2033
- repoRoot: { type: 'string', description: 'Absolute path inside the target Git repository.' },
2034
- missionId: { type: 'string', description: 'Mission id, e.g. M1 (canonical; alias: id).' },
2035
- id: { type: 'string', description: 'Alias for missionId; both are accepted.' },
2036
- },
2037
- required: ['repoRoot'],
2038
- additionalProperties: false,
2039
- },
2040
- aliases: {"missionId":["id"]},
2041
- requiresMissionId: true,
2042
- board: true,
2043
- output: output(SCHEMAS.rebase),
2044
- async execute(args, exec) {
2045
- const tower = await Tower.open(args.repoRoot)
2046
- const actor = await actorFor(tower, exec)
2047
- requireTowerActor(actor, 'rebase missions')
2048
- const { activity } = await observeActivity(ctx, tower)
2049
- return tower.rebase({ missionId: args.missionId, activity })
2050
- },
2051
- })
2052
-
2053
- define(ctx, {
2054
- name: 'tower_teardown',
2055
- description:
2056
- 'Tear down eligible mission worktrees after their missions are merged or abandoned. A worktree whose roster agent is running is always kept (force cannot override); dirty worktrees need force; exclude keeps named missions. dry_run previews the remove/keep decisions without changing anything.',
2057
- parameters: {
2058
- type: 'object',
2059
- properties: {
2060
- repoRoot: { type: 'string', description: 'Absolute path inside the target Git repository.' },
2061
- force: { type: 'boolean', description: 'Remove worktrees that contain uncommitted changes (never a live agent).' },
2062
- exclude: { type: 'array', items: { type: 'string' }, description: 'Mission ids to keep.' },
2063
- dry_run: { type: 'boolean', description: 'Preview remove/keep decisions without changing disk or state.' },
2064
- dryRun: { type: 'boolean', description: 'Alias for dry_run.' },
2065
- },
2066
- required: ['repoRoot'],
2067
- additionalProperties: false,
2068
- },
2069
- aliases: {"dry_run":["dryRun"]},
2070
- board: true,
2071
- output: output(SCHEMAS.teardown),
2072
- async execute(args, exec) {
2073
- const tower = await Tower.open(args.repoRoot)
2074
- const actor = await actorFor(tower, exec)
2075
- requireTowerActor(actor, 'tear down worktrees')
2076
- const { activity } = await observeActivity(ctx, tower)
2077
- return tower.teardown({
2078
- force: args.force === true,
2079
- exclude: args.exclude,
2080
- dryRun: args.dry_run === true,
2081
- activity,
2082
- })
2083
- },
2084
- })
2085
-
2086
- define(ctx, {
2087
- name: 'tower_doctor',
2088
- description:
2089
- 'Read-only environment and repository health check: git, state schema, config, presets/subagents/HMR, workspace lock, roster staleness, worktree ownership, test discovery, leases. Returns checks with pass/warn/fail and an environment contract. Never writes state.',
2090
- parameters: {
2091
- type: 'object',
2092
- properties: { repoRoot: { type: 'string', description: 'Absolute path inside the target Git repository.' } },
2093
- required: ['repoRoot'],
2094
- additionalProperties: false,
2095
- },
2096
- output: output(SCHEMAS.doctor),
2097
- async execute(args, exec) {
2098
- let tower
2099
- let configError = null
2100
- try {
2101
- tower = await Tower.open(args.repoRoot)
2102
- } catch (error) {
2103
- const message = String(error?.message ?? error)
2104
- if (!/config-(invalid|unknown-key)/.test(message) && error?.code !== 'config-invalid' && error?.code !== 'config-unknown-key') throw error
2105
- configError = message
2106
- const repo = await resolveRepo(args.repoRoot)
2107
- tower = new Tower(repo, { ...runtimeConfig(), configPath: null, repoConfigPresent: true })
2108
- }
2109
- const report = await runDoctor({ tower, ctx, agent: exec.agent })
2110
- if (!configError) return report
2111
- return {
2112
- ...report,
2113
- ok: false,
2114
- checks: [
2115
- {
2116
- id: 'config',
2117
- status: 'fail',
2118
- detail: configError,
2119
- fix: 'repair <git-common-dir>/dsh-tower/config.json and re-run tower_doctor',
2120
- },
2121
- ...(report.checks ?? []),
2122
- ],
2123
- }
2124
- },
2125
- })
2126
-
2127
- define(ctx, {
2128
- name: 'tower_recover',
2129
- description:
2130
- 'Read-only recovery diagnostics and an explicit stale-state-lock repair. status reports the state lock owner; clear-stale-lock requires confirm:"CLEAR_STALE_LOCK" and only moves a lock whose PID is provably dead to a backup file. It never resets Tower state or worktrees.',
2131
- parameters: {
2132
- type: 'object',
2133
- properties: {
2134
- repoRoot: { type: 'string', description: 'Absolute path inside the target Git repository.' },
2135
- action: { type: 'string', enum: ['status', 'clear-stale-lock'], description: 'Recovery action.' },
2136
- confirm: { type: 'string', description: 'Required literal CLEAR_STALE_LOCK for clear-stale-lock.' },
2137
- },
2138
- required: ['repoRoot', 'action'],
2139
- additionalProperties: false,
2140
- },
2141
- output: output(SCHEMAS.recover),
2142
- async execute(args, exec) {
2143
- const tower = await Tower.open(args.repoRoot)
2144
- const actor = await actorFor(tower, exec)
2145
- requireTowerActor(actor, 'recover Tower state')
2146
- const lock = await readStateLock(tower.paths)
2147
- const publicLock = lock.record
2148
- ? { pid: lock.record.pid ?? null, startedAt: lock.record.startedAt ?? null, at: lock.record.at ?? null, alive: lock.alive }
2149
- : null
2150
- if (args.action === 'status') {
2151
- return { ok: true, action: 'status', stateLock: publicLock, cleared: false }
2152
- }
2153
- if (args.confirm !== 'CLEAR_STALE_LOCK') {
2154
- throw new TowerToolError('invalid-argument', 'clear-stale-lock requires confirm:"CLEAR_STALE_LOCK"')
2155
- }
2156
- if (!lock.record) return { ok: true, action: 'clear-stale-lock', stateLock: null, cleared: false }
2157
- if (lock.alive !== false) {
2158
- throw new TowerToolError('state-locked', `state lock pid ${lock.record.pid ?? 'unknown'} is still alive; refusing to clear it`)
2159
- }
2160
- const backup = `${tower.paths.lock}.recovered-${Date.now()}`
2161
- await fsp.rename(tower.paths.lock, backup)
2162
- return { ok: true, action: 'clear-stale-lock', stateLock: publicLock, cleared: true, backup }
2163
- },
2164
- })
2165
-
2166
- define(ctx, {
2167
- name: 'tower_test',
2168
- description:
2169
- 'Run the mission worktree\'s declared tower.testDiscovery (package.json) and record the receipt. Returns ok:false with diagnostics when discovery fails; does not change mission status. Use tower_complete for automatic admission checks.',
2170
- parameters: {
2171
- type: 'object',
2172
- properties: {
2173
- repoRoot: { type: 'string', description: 'Absolute path inside the target Git repository.' },
2174
- missionId: { type: 'string', description: 'Mission id, e.g. M1 (alias: id).' },
2175
- id: { type: 'string', description: 'Alias for missionId.' },
2176
- },
2177
- required: ['repoRoot', 'missionId'],
2178
- additionalProperties: false,
2179
- },
2180
- aliases: { missionId: ['id'] },
2181
- requiresMissionId: true,
2182
- output: output(SCHEMAS.test),
2183
- async execute(args, exec) {
2184
- const tower = await Tower.open(args.repoRoot)
2185
- const actor = await actorFor(tower, exec)
2186
- requireKnownActor(actor, 'tower_test')
2187
- const context = await tower.missionContext(args.missionId)
2188
- if (!context.mission.worktree) throw new TowerToolError('invalid-transition', `mission ${args.missionId} has no worktree`)
2189
- if (actor.role === 'reviewer') throw new TowerToolError('not-authorized', 'reviewers may not run test discovery')
2190
- if (actor.role === 'worker' && context.mission.owner && context.mission.owner !== actor.name) {
2191
- throw new TowerToolError('not-authorized', `worker ${actor.name} does not own mission ${args.missionId}`)
2192
- }
2193
- await tower.assertWorktree(context.mission.worktree)
2194
- const discovery = await runMissionDiscovery(tower, context.mission)
2195
- if (!discovery.configured) {
2196
- return {
2197
- ok: false,
2198
- configured: false,
2199
- error: discovery.error ?? 'tower.testDiscovery is not configured in the worktree package.json',
2200
- tests: [],
2201
- commands: [],
2202
- }
2203
- }
2204
- const receipt = discovery.receipt
2205
- return {
2206
- ok: receipt.ok === true,
2207
- configured: true,
2208
- treeTip: receipt.treeTip ?? null,
2209
- tests: receipt.tests ?? [],
2210
- commands: receipt.commands ?? [],
2211
- error: receipt.error ?? undefined,
2212
- receipt,
2213
- checks: discovery.check ? [discovery.check] : [],
2214
- }
2215
- },
2216
- })
2217
-
2218
- define(ctx, {
2219
- name: 'tower_batch',
2220
- description:
2221
- 'Manage finite evidence batches: create freezes missionIds/requiredChecks/receiptPaths, status summarizes members, close verifies every required check and receipt hash, abandon retires a stale open batch. Membership and exclusions are immutable after create; close/abandon never remove missions.',
2222
- parameters: {
2223
- type: 'object',
2224
- properties: {
2225
- repoRoot: { type: 'string', description: 'Absolute path inside the target Git repository.' },
2226
- action: { type: 'string', enum: ['create', 'status', 'close', 'abandon'], description: 'Batch operation.' },
2227
- missionIds: { type: 'array', items: { type: 'string' }, description: 'Exact mission ids in this batch (create).' },
2228
- exclusions: { type: 'array', items: { type: 'string' }, description: 'Paused legacy mission ids excluded from the batch.' },
2229
- requiredChecks: { type: 'array', items: { type: 'string' }, description: 'Check names every build member must record (default ["check"]).' },
2230
- receiptPaths: { type: 'array', items: { type: 'string' }, description: 'Exact relative receipt files checked at close.' },
2231
- batchId: { type: 'string', description: 'Batch id (status/close/abandon).' },
2232
- reason: { type: 'string', description: 'Reason recorded when abandoning a batch.' },
2233
- checks: { type: 'array', items: { type: 'object' }, description: 'Close-time extra check evidence.' },
2234
- treeTip: { type: 'string', description: 'Expected reviewed/tested commit for close evidence.' },
2235
- receiptHashes: { type: 'object', description: 'Relative receipt path -> sha256 observed by the caller.' },
2236
- },
2237
- required: ['repoRoot', 'action'],
2238
- additionalProperties: false,
2239
- },
2240
- board: true,
2241
- output: output(SCHEMAS.batch),
2242
- async execute(args, exec) {
2243
- const tower = await Tower.open(args.repoRoot)
2244
- const actor = await actorFor(tower, exec)
2245
- if (args.action === 'status') {
2246
- const state = await tower.load()
2247
- const batch = args.batchId
2248
- ? (state.batches ?? []).find((entry) => entry.id === args.batchId)
2249
- : null
2250
- if (args.batchId && !batch) throw new TowerToolError('invalid-argument', `unknown batch "${args.batchId}"`)
2251
- return {
2252
- ok: true,
2253
- action: 'status',
2254
- batches: (state.batches ?? []).map((entry) => summarizeBatch(state, entry)),
2255
- ...(batch ? { batch: summarizeBatch(state, batch) } : {}),
2256
- ...(args.batchId ? { summary: summarizeBatch(state, batch) } : {}),
2257
- }
2258
- }
2259
- requireTowerActor(actor, args.action === 'create' ? 'create a batch' : args.action === 'abandon' ? 'abandon a batch' : 'close a batch')
2260
- if (args.action === 'create') {
2261
- const batch = await tower.createBatch(args, actor.name)
2262
- return { ok: true, action: 'create', batch, batches: (await tower.batchStatus()).batches }
2263
- }
2264
- if (typeof args.batchId !== 'string' || args.batchId.trim().length === 0) {
2265
- throw new TowerToolError('invalid-argument', `tower_batch ${args.action} requires batchId`)
2266
- }
2267
- if (args.action === 'abandon') {
2268
- const result = await tower.abandonBatch(args.batchId.trim(), actor.name, args.reason)
2269
- return { ok: true, action: 'abandon', batch: result.batch, blockers: [] }
2270
- }
2271
- const closeState = await tower.load()
2272
- const closeBatch = (closeState.batches ?? []).find((entry) => entry.id === args.batchId.trim())
2273
- if (closeBatch) {
2274
- const receiptProblems = await verifyBatchReceiptFiles(tower.root, closeBatch, args)
2275
- if (receiptProblems.length > 0) {
2276
- return { ok: false, action: 'close', batch: closeBatch, blockers: receiptProblems, checks: [] }
2277
- }
2278
- }
2279
- const result = await tower.closeBatch(args.batchId.trim(), {
2280
- checks: args.checks,
2281
- treeTip: args.treeTip,
2282
- receiptHashes: args.receiptHashes,
2283
- }, actor.name)
2284
- return {
2285
- ok: result.ok,
2286
- action: 'close',
2287
- batch: result.batch,
2288
- blockers: (result.blockers ?? []).map((blocker) => ({
2289
- code: blocker.code ?? 'batch-invalid',
2290
- detail: blocker.detail ?? blocker.message ?? String(blocker),
2291
- ...(blocker.cleanup ? { cleanup: blocker.cleanup } : {}),
2292
- ...(blocker.timeout ? { timeout: true } : {}),
2293
- })),
2294
- checks: result.checks ?? [],
2295
- }
2296
- },
2297
- })
2298
-
2299
- define(ctx, {
2300
- name: 'tower_resource',
2301
- description:
2302
- 'Advisory resource leases per repository (coordination only; no OS CPU/memory hard cap). acquire/release require a token and are tower-only; status is read-only. Expired leases are never taken over silently.',
2303
- parameters: {
2304
- type: 'object',
2305
- properties: {
2306
- repoRoot: { type: 'string', description: 'Absolute path inside the target Git repository.' },
2307
- action: { type: 'string', enum: ['acquire', 'release', 'status'], description: 'Lease operation.' },
2308
- name: { type: 'string', description: 'Lease name, e.g. plugin-load or heavy-ci.' },
2309
- holder: { type: 'string', description: 'Holder label (defaults to the caller).' },
2310
- token: { type: 'string', description: 'Release token; generated on acquire when omitted.' },
2311
- ttlMs: { type: 'integer', minimum: 1000, description: 'Lease lifetime (default leases.ttlMs).' },
2312
- force: { type: 'boolean', description: 'Explicitly release an expired lease (token still required).' },
2313
- },
2314
- required: ['repoRoot', 'action'],
2315
- additionalProperties: false,
2316
- },
2317
- output: output(SCHEMAS.resource),
2318
- async execute(args, exec) {
2319
- const tower = await Tower.open(args.repoRoot)
2320
- const actor = await actorFor(tower, exec)
2321
- const redact = (leases) => leases.map(({ token, ...lease }) => lease)
2322
- if (args.action === 'status') {
2323
- const info = await inspectLeases(tower.commonDir)
2324
- const leases = redact(info.leases ?? [])
2325
- return { ok: true, action: 'status', ...info, leases }
2326
- }
2327
- requireTowerActor(actor, args.action === 'acquire' ? 'acquire a lease' : 'release a lease')
2328
- if (typeof args.name !== 'string' || args.name.trim().length === 0) {
2329
- throw new TowerToolError('invalid-argument', 'tower_resource requires name')
2330
- }
2331
- const name = args.name.trim()
2332
- const ttlMs = Number(args.ttlMs ?? tower.config.leases?.ttlMs ?? 900000)
2333
- if (args.action === 'acquire') {
2334
- const lease = await acquireLease(tower.commonDir, {
2335
- name,
2336
- holder: args.holder ?? actor.name,
2337
- token: args.token ?? crypto.randomUUID(),
2338
- pid: process.pid,
2339
- ttlMs,
2340
- })
2341
- return { ok: true, action: 'acquire', lease }
2342
- }
2343
- const token = args.token
2344
- if (typeof token !== 'string' || token.length === 0) {
2345
- throw new TowerToolError('invalid-argument', 'tower_resource release requires token')
2346
- }
2347
- const released = await releaseLease(tower.commonDir, { name, token, force: args.force === true })
2348
- return { ok: true, action: 'release', released, leases: redact((await inspectLeases(tower.commonDir)).leases) }
2349
- },
2350
- })
2351
-
2352
- define(ctx, {
2353
- name: 'tower_import_legacy',
2354
- description:
2355
- 'Read-only import of a Tower Codex (schemaVersion 1) or DSH legacy state file. Stores the full payload under <common>/dsh-tower/legacy/<sha256>.json and records only a summary in state.legacySnapshots; never activates, adopts, or resumes paused missions.',
2356
- parameters: {
2357
- type: 'object',
2358
- properties: {
2359
- repoRoot: { type: 'string', description: 'Absolute path inside the target Git repository.' },
2360
- sourcePath: { type: 'string', description: 'Absolute path to the legacy JSON state file.' },
2361
- },
2362
- required: ['repoRoot', 'sourcePath'],
2363
- additionalProperties: false,
2364
- },
2365
- board: true,
2366
- output: output(SCHEMAS.importLegacy),
2367
- async execute(args, exec) {
2368
- const tower = await Tower.open(args.repoRoot)
2369
- const actor = await actorFor(tower, exec)
2370
- requireTowerActor(actor, 'import a legacy snapshot')
2371
- const imported = await importLegacySnapshot({
2372
- repoRoot: tower.root,
2373
- commonDir: tower.commonDir,
2374
- sourcePath: args.sourcePath,
2375
- })
2376
- const recorded = await tower.recordLegacySnapshot(imported.snapshot)
2377
- return { ok: true, summary: imported.summary, snapshot: recorded.snapshot }
2378
- },
2379
- })
2380
-
2381
- define(ctx, {
2382
- name: 'tower_legacy_map',
2383
- description:
2384
- 'Audit-only mapping between a legacy snapshot id and a current mission. map records sourceHash/oldId/newMissionId; status lists mappings. Mapping never adopts or resumes a legacy mission.',
2385
- parameters: {
2386
- type: 'object',
2387
- properties: {
2388
- repoRoot: { type: 'string', description: 'Absolute path inside the target Git repository.' },
2389
- action: { type: 'string', enum: ['map', 'status'], description: 'Mapping operation.' },
2390
- sourceHash: { type: 'string', description: 'Legacy snapshot sha256 (map).' },
2391
- oldId: { type: 'string', description: 'Legacy mission id (map).' },
2392
- newMissionId: { type: 'string', description: 'Current mission id; optional audit pointer.' },
2393
- },
2394
- required: ['repoRoot', 'action'],
2395
- additionalProperties: false,
2396
- },
2397
- output: output(SCHEMAS.legacyMap),
2398
- async execute(args, exec) {
2399
- const tower = await Tower.open(args.repoRoot)
2400
- const actor = await actorFor(tower, exec)
2401
- const state = await tower.load()
2402
- if (args.action === 'status') {
2403
- return { ok: true, maps: state.legacyMaps ?? [] }
2404
- }
2405
- requireTowerActor(actor, 'map a legacy mission')
2406
- const validation = validateLegacyMap(state, args)
2407
- if (validation.ok !== true) {
2408
- throw new TowerToolError('invalid-argument', validation.error ?? 'invalid legacy mapping')
2409
- }
2410
- const map = { ...validation.map, by: actor.name, at: Date.now() }
2411
- const recorded = await tower.recordLegacyMap(map)
2412
- return { ok: true, map: recorded.map, maps: [...(state.legacyMaps ?? []), recorded.map] }
2413
- },
2414
- })
2415
-
2416
- // Dev-only hot reload. The tool is registered whenever dev.hotReload is on;
2417
- // it reports reload-unavailable when the profile has no HMR service. When the
2418
- // Host passed a shared bridge, its status() reflects the command's reloads too.
2419
- const dev = runtimeConfig().dev ?? {}
2420
- if (dev.hotReload !== false) {
2421
- const bridge =
2422
- sharedHmrBridge ??
2423
- createHmrBridge(ctx, {
2424
- requiredIds: dev.reloadRequiredIds,
2425
- delayMs: dev.reloadDelayMs,
2426
- logger: ctx.logger ?? console,
2427
- })
2428
- define(ctx, {
2429
- name: 'tower_reload',
2430
- description:
2431
- 'Dev-only: ask DSH HMR to reconcile this profile and reload the Tower Host plugin from disk. The reload is scheduled a few milliseconds after this call returns (so the result reaches the model first). Use after editing the plugin source in a linked profile; plugin_manager install/update already triggers reconciliation by itself. Registered while dev.hotReload is on; fails with reload-unavailable when the HMR service is absent.',
2432
- parameters: {
2433
- type: 'object',
2434
- properties: {
2435
- repoRoot: { type: 'string', description: 'Optional repository root; when present the caller must be the tower.' },
2436
- },
2437
- required: [],
2438
- additionalProperties: false,
2439
- },
2440
- output: output(SCHEMAS.reload),
2441
- async execute(args, exec) {
2442
- if (typeof args.repoRoot === 'string') {
2443
- const tower = await Tower.open(args.repoRoot)
2444
- const actor = await actorFor(tower, exec)
2445
- requireTowerActor(actor, 'reload the Tower plugin')
2446
- }
2447
- if (!bridge.available) {
2448
- throw new TowerToolError('reload-unavailable', 'this DSH profile has no HMR service; restart DSH instead')
2449
- }
2450
- const outcome = bridge.schedule()
2451
- return {
2452
- ok: true,
2453
- ...outcome,
2454
- ...(outcome.lastError ? { error: outcome.lastError } : {}),
2455
- restartHint: 'If the new code does not appear, restart DSH or check the profile hmr entry.',
2456
- }
2457
- },
2458
- })
2459
- }
2460
- }