dsh-tower 2.0.3 → 2.1.0-beta.1

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