dsh-tower 1.3.0 → 2.0.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 (59) hide show
  1. package/CHANGELOG.md +109 -0
  2. package/CONTRIBUTING.md +1 -1
  3. package/README.en.md +44 -8
  4. package/README.md +42 -8
  5. package/cordis.patch.yml +11 -1
  6. package/docs/README.md +10 -5
  7. package/docs/architecture/overview.md +13 -9
  8. package/docs/faq.md +45 -2
  9. package/docs/guide/hot-reload.md +189 -0
  10. package/docs/guide/install-upgrade-migration.md +11 -0
  11. package/docs/guide/quickstart.md +33 -1
  12. package/docs/guide/scenarios-and-philosophy.md +309 -0
  13. package/docs/operations/runbook.md +23 -0
  14. package/docs/operations/security.md +23 -0
  15. package/docs/reference/blockers.md +10 -2
  16. package/docs/reference/config.md +65 -2
  17. package/docs/reference/doctor.md +33 -0
  18. package/docs/reference/errors.md +50 -0
  19. package/docs/reference/events.md +45 -0
  20. package/docs/reference/operations.md +55 -0
  21. package/docs/reference/testing.md +61 -0
  22. package/docs/reference/tools.md +65 -23
  23. package/docs/zh/README.md +60 -0
  24. package/docs/zh/faq.md +81 -0
  25. package/docs/zh/guide/hot-reload.md +178 -0
  26. package/docs/zh/guide/install-upgrade-migration.md +57 -0
  27. package/docs/zh/guide/quickstart.md +96 -0
  28. package/docs/zh/guide/scenarios-and-philosophy.md +211 -0
  29. package/docs/zh/operations/security.md +42 -0
  30. package/docs/zh/reference/blockers.md +62 -0
  31. package/docs/zh/reference/config.md +113 -0
  32. package/docs/zh/reference/doctor.md +31 -0
  33. package/docs/zh/reference/errors.md +52 -0
  34. package/docs/zh/reference/events.md +46 -0
  35. package/docs/zh/reference/operations.md +50 -0
  36. package/docs/zh/reference/testing.md +60 -0
  37. package/docs/zh/reference/tools.md +75 -0
  38. package/index.js +261 -13
  39. package/lib/batch.js +578 -0
  40. package/lib/config.js +275 -9
  41. package/lib/doctor.js +171 -0
  42. package/lib/errors.js +111 -0
  43. package/lib/hmr.js +196 -0
  44. package/lib/leases.js +317 -0
  45. package/lib/legacy.js +413 -0
  46. package/lib/modeStore.js +28 -1
  47. package/lib/store.js +24 -1
  48. package/lib/tests.js +628 -0
  49. package/lib/tools.js +1566 -184
  50. package/lib/tower.js +986 -103
  51. package/lib/workspaceLock.js +117 -0
  52. package/lib/worktreeOwnership.js +166 -0
  53. package/package.json +4 -1
  54. package/reviewer.js +1 -0
  55. package/scripts/check-docs.mjs +135 -0
  56. package/scripts/enable-dev-hmr.mjs +347 -0
  57. package/skills/tower-review.md +4 -2
  58. package/skills/tower-worker-protocol.md +3 -1
  59. package/worker.js +4 -2
package/lib/tools.js CHANGED
@@ -1,22 +1,489 @@
1
- // Tower tools: init, plan, mission, status, spawn, roster, adopt.
2
- import { Tower } from './tower.js'
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'
3
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'
4
15
 
5
- const OUTPUT = {
6
- schema: { type: 'object' },
7
- render: (_args, value) => [{ type: 'text', text: JSON.stringify(value, null, 2) }],
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 }
8
282
  }
9
283
 
10
284
  const ROLE_ENUM = ['worker', 'reviewer']
11
285
 
12
- function define(ctx, definition) {
13
- ctx.effect(() => ctx.tools.register(definition))
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
14
299
  }
15
300
 
16
301
  function fail(message) {
17
302
  throw new Error(message)
18
303
  }
19
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
+
20
487
  function textOf(blocks) {
21
488
  if (!Array.isArray(blocks)) return null
22
489
  return blocks
@@ -24,6 +491,52 @@ function textOf(blocks) {
24
491
  .join('\n')
25
492
  }
26
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
+
27
540
  async function observeActivity(ctx, tower) {
28
541
  const view = await tower.rosterView()
29
542
  const subagents = ctx.get('subagents')
@@ -43,7 +556,22 @@ async function observeActivity(ctx, tower) {
43
556
  return { activity, parentStatus }
44
557
  }
45
558
 
46
- function workerBriefing({ repoRoot, base, mission, deps, worktree, instructions }) {
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
+ }) {
47
575
  const lines = []
48
576
  lines.push(`# Tower mission ${mission.id} — ${mission.title}`)
49
577
  lines.push('')
@@ -68,6 +596,36 @@ function workerBriefing({ repoRoot, base, mission, deps, worktree, instructions
68
596
  lines.push('Context (verbatim from the user):')
69
597
  lines.push(mission.context)
70
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
+ }
71
629
  lines.push('')
72
630
  lines.push('Rules:')
73
631
  lines.push(`- Pass workdir="${worktree.path}" on every shell command; never edit the main checkout.`)
@@ -77,7 +635,7 @@ function workerBriefing({ repoRoot, base, mission, deps, worktree, instructions
77
635
  `- Update progress: tower_mission({repoRoot:"${repoRoot}",id:"${mission.id}",task_done:"..."}) or blocker:"...".`,
78
636
  )
79
637
  lines.push(
80
- `- When the work is finished and every task is closed, call tower_complete({repoRoot:"${repoRoot}",missionId:"${mission.id}",report:"<full handoff>"}). Unread inbox messages block completion, so read them with tower_inbox first.`,
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.`,
81
639
  )
82
640
  lines.push('- Your final message is the full handoff: files changed, how you verified, what remains.')
83
641
  if (instructions) {
@@ -88,7 +646,17 @@ function workerBriefing({ repoRoot, base, mission, deps, worktree, instructions
88
646
  return lines.join('\n')
89
647
  }
90
648
 
91
- function reviewerBriefing({ repoRoot, base, mission, deps, worktree, instructions }) {
649
+ export function reviewerBriefing({
650
+ repoRoot,
651
+ base,
652
+ mission,
653
+ deps,
654
+ worktree,
655
+ instructions,
656
+ reviews = [],
657
+ findings = [],
658
+ completionReport = null,
659
+ }) {
92
660
  const lines = []
93
661
  lines.push(`# Tower review — mission ${mission.id} — ${mission.title}`)
94
662
  lines.push('')
@@ -111,6 +679,36 @@ function reviewerBriefing({ repoRoot, base, mission, deps, worktree, instruction
111
679
  lines.push('Context (verbatim from the user):')
112
680
  lines.push(mission.context)
113
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
+ }
114
712
  lines.push('')
115
713
  lines.push('Rules:')
116
714
  lines.push('- Read-only: do not modify files, commit, or merge.')
@@ -131,24 +729,76 @@ function reviewerBriefing({ repoRoot, base, mission, deps, worktree, instruction
131
729
  return lines.join('\n')
132
730
  }
133
731
 
134
- export function registerTowerTools(ctx) {
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))
135
765
  define(ctx, {
136
766
  name: 'tower_init',
137
767
  description:
138
- '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. Optionally records the local base branch missions will later fork from and merge back into.',
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.',
139
769
  parameters: {
140
770
  type: 'object',
141
771
  properties: {
142
772
  repoRoot: { type: 'string', description: 'Absolute path inside the target Git repository.' },
143
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.' },
144
777
  },
145
778
  required: ['repoRoot'],
146
779
  additionalProperties: false,
147
780
  },
148
- output: OUTPUT,
149
- async execute(args) {
150
- const tower = await Tower.open(args.repoRoot)
151
- return tower.init({ base: args.base })
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 })
152
802
  },
153
803
  })
154
804
 
@@ -172,6 +822,21 @@ export function registerTowerTools(ctx) {
172
822
  deps: { type: 'array', items: { type: 'string' }, description: 'Mission ids that must merge before this one.' },
173
823
  kind: { type: 'string', enum: ['build', 'survey'], description: 'survey = read-only investigation that reserves no scope.' },
174
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
+ },
175
840
  },
176
841
  required: ['title'],
177
842
  additionalProperties: false,
@@ -181,9 +846,12 @@ export function registerTowerTools(ctx) {
181
846
  required: ['repoRoot', 'missions'],
182
847
  additionalProperties: false,
183
848
  },
184
- output: OUTPUT,
185
- async execute(args) {
849
+ board: true,
850
+ output: output(SCHEMAS.plan),
851
+ async execute(args, exec) {
186
852
  const tower = await Tower.open(args.repoRoot)
853
+ const actor = await actorFor(tower, exec)
854
+ requireTowerActor(actor, 'plan missions')
187
855
  return tower.plan({ missions: args.missions })
188
856
  },
189
857
  })
@@ -196,7 +864,8 @@ export function registerTowerTools(ctx) {
196
864
  type: 'object',
197
865
  properties: {
198
866
  repoRoot: { type: 'string', description: 'Absolute path inside the target Git repository.' },
199
- id: { type: 'string', description: 'Mission id, e.g. M1.' },
867
+ id: { type: 'string', description: 'Mission id, e.g. M1 (canonical; alias: missionId).' },
868
+ missionId: { type: 'string', description: 'Alias for id; both are accepted.' },
200
869
  status: {
201
870
  type: 'string',
202
871
  enum: ['planned', 'active', 'completed', 'blocked', 'paused', 'abandoned'],
@@ -205,18 +874,32 @@ export function registerTowerTools(ctx) {
205
874
  note: { type: 'string', description: 'Append a decision-log note.' },
206
875
  blocker: { type: 'string', description: 'Report a blocker (also sets status to blocked).' },
207
876
  clear_blockers: { type: 'boolean', description: 'Clear all recorded blockers.' },
877
+ clearBlockers: { type: 'boolean', description: 'Alias for clear_blockers.' },
208
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.' },
209
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.' },
210
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.' },
211
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.' },
212
891
  },
213
- required: ['repoRoot', 'id'],
892
+ required: ['repoRoot'],
214
893
  additionalProperties: false,
215
894
  },
216
- output: OUTPUT,
217
- async execute(args) {
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) {
218
899
  const tower = await Tower.open(args.repoRoot)
900
+ const actor = await actorFor(tower, exec)
219
901
  return tower.mission({
902
+ actor,
220
903
  id: args.id,
221
904
  status: args.status,
222
905
  note: args.note,
@@ -226,6 +909,8 @@ export function registerTowerTools(ctx) {
226
909
  task_drop: args.task_drop,
227
910
  task_drop_reason: args.task_drop_reason,
228
911
  scope: args.scope,
912
+ pause_cause: args.pause_cause,
913
+ authorization: args.authorization,
229
914
  })
230
915
  },
231
916
  })
@@ -233,19 +918,77 @@ export function registerTowerTools(ctx) {
233
918
  define(ctx, {
234
919
  name: 'tower_status',
235
920
  description:
236
- 'Read the Tower dashboard for a repository: state path, base branch, revision, mission status counts, per-mission summary (scope/deps/tasks/worktree/owner), roster size, and the activity tail. Read-only.',
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.',
237
922
  parameters: {
238
923
  type: 'object',
239
924
  properties: {
240
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.' },
241
931
  },
242
932
  required: ['repoRoot'],
243
933
  additionalProperties: false,
244
934
  },
245
- output: OUTPUT,
935
+ aliases: { mission_id: ['missionId'], include_gate: ['includeGate'] },
936
+ output: output(SCHEMAS.status),
246
937
  async execute(args) {
247
938
  const tower = await Tower.open(args.repoRoot)
248
- return tower.status()
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
249
992
  },
250
993
  })
251
994
 
@@ -257,23 +1000,33 @@ export function registerTowerTools(ctx) {
257
1000
  type: 'object',
258
1001
  properties: {
259
1002
  repoRoot: { type: 'string', description: 'Absolute path inside the target Git repository.' },
260
- missionId: { type: 'string', description: 'Mission id, e.g. M1.' },
1003
+ missionId: { type: 'string', description: 'Mission id, e.g. M1 (canonical; alias: id).' },
1004
+ id: { type: 'string', description: 'Alias for missionId; both are accepted.' },
261
1005
  role: { type: 'string', enum: ROLE_ENUM, description: 'worker executes; reviewer audits the mission branch.' },
262
1006
  name: { type: 'string', description: 'Unique roster name; defaults to <role>-<missionId>.' },
263
1007
  instructions: { type: 'string', description: 'Extra tower instructions appended to the briefing.' },
264
1008
  worktreeRoot: { type: 'string', description: 'Optional absolute parent directory for the worktree.' },
265
1009
  wait: { type: 'boolean', description: 'Wait for the child result instead of returning immediately (testing/debug).' },
266
- waitMs: { type: 'number', description: 'Max wait with wait=true, default 180000.' },
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).' },
267
1014
  },
268
- required: ['repoRoot', 'missionId'],
1015
+ required: ['repoRoot'],
269
1016
  additionalProperties: false,
270
1017
  },
271
- output: OUTPUT,
1018
+ aliases: {"missionId":["id"],"waitMs":["wait_ms"]},
1019
+ requiresMissionId: true,
1020
+ board: true,
1021
+ output: output(SCHEMAS.spawn),
272
1022
  async execute(args, exec) {
273
1023
  const tower = await Tower.open(args.repoRoot)
1024
+ const actor = await actorFor(tower, exec)
1025
+ requireTowerActor(actor, 'spawn tower agents')
274
1026
  const role = args.role ?? 'worker'
275
1027
  if (!ROLE_ENUM.includes(role)) fail(`role must be one of ${ROLE_ENUM.join('|')}`)
276
- const config = runtimeConfig()
1028
+ // Repository config (if any) already overrides the profile config here.
1029
+ const config = tower.config
277
1030
  const preset = role === 'reviewer' ? config.spawn.reviewerPreset : config.spawn.workerPreset
278
1031
  const model = role === 'reviewer' ? config.spawn.reviewerModel : config.spawn.workerModel
279
1032
  const context = await tower.missionContext(args.missionId)
@@ -285,137 +1038,278 @@ export function registerTowerTools(ctx) {
285
1038
  fail(`reviewer requires a completed mission awaiting review; ${context.mission.id} is ${context.mission.status}`)
286
1039
  }
287
1040
  }
288
- const provisioned = await tower.provision({
289
- missionId: args.missionId,
290
- worktreeRoot: args.worktreeRoot,
291
- })
292
- const { mission, worktree } = provisioned
293
- const contextWithWorktree = {
294
- ...context,
295
- mission: {
296
- ...context.mission,
297
- worktree: { path: worktree.path, branch: worktree.branch, baseCommit: worktree.baseCommit },
298
- },
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
+ )
299
1045
  }
300
- const name = (args.name ?? `${role}-${mission.id.toLowerCase()}`).trim()
301
- const briefing =
302
- role === 'worker'
303
- ? workerBriefing({
304
- repoRoot: tower.root,
305
- base: context.base,
306
- mission: contextWithWorktree.mission,
307
- deps: context.deps,
308
- worktree,
309
- instructions: args.instructions,
310
- })
311
- : reviewerBriefing({
312
- repoRoot: tower.root,
313
- base: context.base,
314
- mission: contextWithWorktree.mission,
315
- deps: context.deps,
316
- worktree,
317
- instructions: args.instructions,
318
- })
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
+ }
319
1124
 
320
- const subagents = ctx.get('subagents')
321
- if (!subagents) fail('subagents service is unavailable; cannot spawn')
322
- const providers = subagents.list()
323
- const provider = config.spawn.provider || (providers.includes('spawn') ? 'spawn' : providers[0])
324
- if (!provider) fail('no subagent provider is registered')
325
- const presets = ctx.get('agentPresets')
326
- if (presets && typeof presets.list === 'function') {
327
- const available = await presets.list()
328
- if (!available.some((entry) => String(entry.id) === preset)) {
329
- fail(`agent preset "${preset}" is not registered; check the Tower bundle's preset rows and restart DSH`)
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
+ }
330
1155
  }
331
- }
332
1156
 
333
- const agentOptions = {}
334
- for (const key of ['provider', 'model', 'reasoningEffort', 'maxTokens']) {
335
- if (model && model[key] !== undefined && model[key] !== null && model[key] !== '') agentOptions[key] = model[key]
336
- }
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
+ })
337
1194
 
338
- const activation = await subagents.startActivation({
339
- provider,
340
- label: name,
341
- request: {
342
- cwd: worktree.path,
343
- agentPreset: preset,
344
- prompt: [{ type: 'text', text: briefing }],
345
- parent: exec.agent,
346
- maxDepth: config.spawn.maxDepth ?? 1,
347
- ...(Object.keys(agentOptions).length > 0 ? { agentOptions } : {}),
348
- },
349
- signal: exec.signal,
350
- delivery: 'parent',
351
- })
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
+ }
352
1199
 
353
- const agentId = String(activation.childId)
354
- const entry = await tower.recordSpawn({
355
- missionId: mission.id,
356
- name,
357
- role,
358
- agentId,
359
- parentSessionId: String(exec.agent.id),
360
- cwd: worktree.path,
361
- branch: worktree.branch,
362
- })
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
+ }
363
1209
 
364
- const recordOutcome = (state, detail) =>
365
- tower.recordSpawnOutcome({ missionId: mission.id, name, state, detail }).catch(() => {})
366
-
367
- const report = {
368
- name,
369
- agentId,
370
- role,
371
- missionId: mission.id,
372
- preset,
373
- provider,
374
- maxDepth: config.spawn.maxDepth ?? 1,
375
- ...(Object.keys(agentOptions).length > 0 ? { agentOptions } : {}),
376
- worktree: { path: worktree.path, branch: worktree.branch, baseCommit: worktree.baseCommit },
377
- created: provisioned.created,
378
- briefingChars: briefing.length,
379
- rosterEntry: entry,
380
- }
381
-
382
- if (args.wait === true) {
383
- const waitMs = Math.max(1000, Math.min(600000, args.waitMs ?? 180000))
384
- let timer
385
- const timeout = new Promise((resolve) => {
386
- timer = setTimeout(() => resolve({ __timeout: true }), waitMs)
387
- })
388
- let result
1210
+ let activation = null
1211
+ let agentId = null
1212
+ let entry = null
389
1213
  try {
390
- result = await Promise.race([activation.result, timeout])
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
+ })
391
1239
  } catch (error) {
392
- report.stopReason = 'failed'
393
- report.diagnostic = String(error?.message ?? error)
394
- await recordOutcome('failed', report.diagnostic)
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
+ }
395
1250
  throw error
396
- } finally {
397
- clearTimeout(timer)
398
1251
  }
399
- if (result && result.__timeout) {
400
- report.timedOut = true
401
- recordOutcome('unknown', 'spawn wait timed out; check tower_roster')
402
- } else {
403
- report.stopReason = result?.stopReason ?? null
404
- report.output = textOf(result?.output)
405
- report.diagnostic = result?.diagnostic ?? null
406
- const state = report.stopReason === 'completed' ? 'inactive' : 'failed'
407
- if (state === 'failed') report.diagnostic = report.diagnostic ?? report.stopReason
408
- await recordOutcome(state, report.diagnostic ?? null)
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,
409
1270
  }
410
- } else {
1271
+
1272
+ return { report, activation, recordOutcome }
1273
+ })
1274
+ const { report, activation, recordOutcome } = spawned
1275
+ if (args.wait !== true) {
411
1276
  activation.result
412
1277
  .then((result) => {
413
1278
  const state = result?.stopReason === 'completed' ? 'inactive' : 'failed'
414
1279
  return recordOutcome(state, result?.diagnostic ?? result?.stopReason ?? null)
415
1280
  })
416
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)
417
1310
  }
418
1311
  return report
1312
+
419
1313
  },
420
1314
  })
421
1315
 
@@ -431,7 +1325,7 @@ export function registerTowerTools(ctx) {
431
1325
  required: ['repoRoot'],
432
1326
  additionalProperties: false,
433
1327
  },
434
- output: OUTPUT,
1328
+ output: output(SCHEMAS.roster),
435
1329
  async execute(args) {
436
1330
  const tower = await Tower.open(args.repoRoot)
437
1331
  const view = await tower.rosterView()
@@ -451,19 +1345,60 @@ export function registerTowerTools(ctx) {
451
1345
  }
452
1346
  }
453
1347
 
1348
+ const staleMs = Number(tower.config.spawn?.staleMs) || 0
1349
+ const now = Date.now()
454
1350
  const roster = view.roster.map((entry) => {
455
1351
  const observed = activity.get(entry.agentId)
456
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
457
1359
  return {
458
1360
  ...entry,
459
1361
  activity: observed ?? (known ? 'missing' : 'unknown'),
460
1362
  observed: observed !== undefined,
1363
+ stale,
461
1364
  }
462
1365
  })
463
1366
  return { revision: view.revision, roster, parentStatus, missions: view.missions }
464
1367
  },
465
1368
  })
466
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
+
467
1402
  define(ctx, {
468
1403
  name: 'tower_diff',
469
1404
  description:
@@ -472,13 +1407,16 @@ export function registerTowerTools(ctx) {
472
1407
  type: 'object',
473
1408
  properties: {
474
1409
  repoRoot: { type: 'string', description: 'Absolute path to the main checkout root.' },
475
- missionId: { type: 'string', description: 'Mission id, e.g. M1.' },
1410
+ missionId: { type: 'string', description: 'Mission id, e.g. M1 (canonical; alias: id).' },
1411
+ id: { type: 'string', description: 'Alias for missionId; both are accepted.' },
476
1412
  maxBytes: { type: 'integer', minimum: 1024, description: 'Patch cap in bytes (default 65536).' },
477
1413
  },
478
- required: ['repoRoot', 'missionId'],
1414
+ required: ['repoRoot'],
479
1415
  additionalProperties: false,
480
1416
  },
481
- output: OUTPUT,
1417
+ aliases: {"missionId":["id"]},
1418
+ requiresMissionId: true,
1419
+ output: output(SCHEMAS.diff),
482
1420
  async execute(args) {
483
1421
  const tower = await Tower.open(args.repoRoot)
484
1422
  return tower.diff({ missionId: args.missionId, maxBytes: args.maxBytes })
@@ -488,18 +1426,22 @@ export function registerTowerTools(ctx) {
488
1426
  define(ctx, {
489
1427
  name: 'tower_adopt',
490
1428
  description:
491
- '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); agents whose parent catalog cannot be read stay unknown and are never retired. Does not resume, reassign, or delete any mission or worktree.',
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.',
492
1430
  parameters: {
493
1431
  type: 'object',
494
1432
  properties: {
495
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.' },
496
1435
  },
497
1436
  required: ['repoRoot'],
498
1437
  additionalProperties: false,
499
1438
  },
500
- output: OUTPUT,
501
- async execute(args) {
1439
+ aliases: { retire_missing: ['retireMissing'] },
1440
+ output: output(SCHEMAS.adopt),
1441
+ async execute(args, exec) {
502
1442
  const tower = await Tower.open(args.repoRoot)
1443
+ const actor = await actorFor(tower, exec)
1444
+ requireTowerActor(actor, 'adopt the roster')
503
1445
  const view = await tower.rosterView()
504
1446
  const subagents = ctx.get('subagents')
505
1447
  if (!subagents) fail('subagents service is unavailable; cannot adopt')
@@ -523,10 +1465,13 @@ export function registerTowerTools(ctx) {
523
1465
  if (parentStatus[entry.parentSessionId] !== 'ok') continue
524
1466
  if (!activity.has(entry.agentId)) retiredCandidates.push(entry.agentId)
525
1467
  }
526
- const { retired } = await tower.markRetired(retiredCandidates)
1468
+ const retireMissing = args.retireMissing !== false
1469
+ const { retired } = retireMissing ? await tower.markRetired(retiredCandidates) : { retired: [] }
1470
+ const { missing } = retireMissing ? { missing: [] } : await tower.markMissing(retiredCandidates)
527
1471
  const after = await tower.rosterView()
528
1472
  return {
529
1473
  retired,
1474
+ missing,
530
1475
  skippedUnknownParents: Object.entries(parentStatus)
531
1476
  .filter(([, status]) => status !== 'ok')
532
1477
  .map(([parent]) => parent),
@@ -548,16 +1493,24 @@ export function registerTowerTools(ctx) {
548
1493
  subject: { type: 'string', description: 'One-line subject.' },
549
1494
  body: { type: 'string', description: 'Full message body (markdown).' },
550
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.' },
551
1502
  },
552
1503
  required: ['repoRoot', 'to', 'subject', 'body'],
553
1504
  additionalProperties: false,
554
1505
  },
555
- output: OUTPUT,
1506
+ aliases: { consentRef: ['consent_ref'] },
1507
+ output: output(SCHEMAS.send),
556
1508
  async execute(args, exec) {
557
1509
  const tower = await Tower.open(args.repoRoot)
1510
+ checkSendRate(tower.config, args.repoRoot)
1511
+ const actor = await actorFor(tower, exec)
558
1512
  const view = await tower.rosterView()
559
- const self = view.roster.find((entry) => entry.agentId === String(exec.agent.id))
560
- const from = self ? self.name : 'tower'
1513
+ const from = actor.role === 'tower' ? 'tower' : actor.name
561
1514
  if (typeof args.subject !== 'string' || args.subject.trim().length === 0) {
562
1515
  fail('tower_send requires a subject')
563
1516
  }
@@ -570,6 +1523,7 @@ export function registerTowerTools(ctx) {
570
1523
  subject: args.subject.trim(),
571
1524
  body: args.body,
572
1525
  scope: args.scope,
1526
+ consentRef: args.consentRef,
573
1527
  })
574
1528
 
575
1529
  const subagents = ctx.get('subagents')
@@ -588,6 +1542,9 @@ export function registerTowerTools(ctx) {
588
1542
  // an unreadable parent catalog only means "do not wake it"
589
1543
  }
590
1544
  }
1545
+ const sendConfig = tower.config.send ?? {}
1546
+ const interrupt =
1547
+ args.interrupt === undefined ? sendConfig.interruptDefault === true : args.interrupt === true
591
1548
  const targets =
592
1549
  args.to === 'all' ? roster.map((entry) => entry.name) : [args.to]
593
1550
  for (const targetName of targets) {
@@ -604,6 +1561,16 @@ export function registerTowerTools(ctx) {
604
1561
  results.push({ target: targetName, pushed: false, reason: 'not running; queued in inbox' })
605
1562
  continue
606
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
+ }
607
1574
  try {
608
1575
  await subagents.sendMessage(
609
1576
  exec.agent,
@@ -616,7 +1583,7 @@ export function registerTowerTools(ctx) {
616
1583
  ],
617
1584
  { signal: exec.signal },
618
1585
  )
619
- results.push({ target: targetName, pushed: true })
1586
+ results.push({ target: targetName, pushed: true, interrupted })
620
1587
  } catch (error) {
621
1588
  results.push({ target: targetName, pushed: false, reason: String(error?.message ?? error) })
622
1589
  }
@@ -640,11 +1607,13 @@ export function registerTowerTools(ctx) {
640
1607
  repoRoot: { type: 'string', description: 'Absolute path inside the target Git repository.' },
641
1608
  limit: { type: 'number', description: 'Max messages to return (default 20), newest first.' },
642
1609
  include_read: { type: 'boolean', description: 'Include previously acknowledged messages for history lookup.' },
1610
+ includeRead: { type: 'boolean', description: 'Alias for include_read.' },
643
1611
  },
644
1612
  required: ['repoRoot'],
645
1613
  additionalProperties: false,
646
1614
  },
647
- output: OUTPUT,
1615
+ aliases: {"include_read":["includeRead"]},
1616
+ output: output(SCHEMAS.inbox),
648
1617
  async execute(args, exec) {
649
1618
  const tower = await Tower.open(args.repoRoot)
650
1619
  const view = await tower.rosterView()
@@ -657,24 +1626,64 @@ export function registerTowerTools(ctx) {
657
1626
  define(ctx, {
658
1627
  name: 'tower_complete',
659
1628
  description:
660
- 'Worker-only: report mission completion. Enforced by the store: every task done or dropped, no blockers, the mission branch has at least one commit since its spawn base, and no unread inbox messages. Stores the full report; completion means awaiting review, not merged.',
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.',
661
1630
  parameters: {
662
1631
  type: 'object',
663
1632
  properties: {
664
1633
  repoRoot: { type: 'string', description: 'Absolute path inside the target Git repository.' },
665
- missionId: { type: 'string', description: 'Mission id, e.g. M1.' },
1634
+ missionId: { type: 'string', description: 'Mission id, e.g. M1 (canonical; alias: id).' },
1635
+ id: { type: 'string', description: 'Alias for missionId; both are accepted.' },
666
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
+ },
667
1652
  },
668
- required: ['repoRoot', 'missionId', 'report'],
1653
+ required: ['repoRoot', 'report'],
669
1654
  additionalProperties: false,
670
1655
  },
671
- output: OUTPUT,
1656
+ aliases: {"missionId":["id"]},
1657
+ requiresMissionId: true,
1658
+ board: true,
1659
+ output: output(SCHEMAS.complete),
672
1660
  async execute(args, exec) {
673
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
+ }
674
1682
  return tower.complete({
675
1683
  missionId: args.missionId,
676
1684
  agentId: String(exec.agent.id),
677
1685
  report: args.report,
1686
+ checks,
678
1687
  })
679
1688
  },
680
1689
  })
@@ -682,7 +1691,7 @@ export function registerTowerTools(ctx) {
682
1691
  define(ctx, {
683
1692
  name: 'tower_finding',
684
1693
  description:
685
- '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.',
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.',
686
1695
  parameters: {
687
1696
  type: 'object',
688
1697
  properties: {
@@ -694,12 +1703,15 @@ export function registerTowerTools(ctx) {
694
1703
  location: { type: 'string', description: 'File/symbol the finding concerns.' },
695
1704
  details: { type: 'string', description: 'Full details: evidence, reproduction, impact.' },
696
1705
  suggested_fix: { type: 'string', description: 'What you would do about it.' },
697
- missionId: { type: 'string', description: 'Optional mission this finding relates to.' },
1706
+ missionId: { type: 'string', description: 'Optional mission this finding relates to (alias: mission_id).' },
1707
+ mission_id: { type: 'string', description: 'Alias for missionId.' },
698
1708
  },
699
1709
  required: ['repoRoot', 'type', 'title', 'summary', 'details', 'suggested_fix'],
700
1710
  additionalProperties: false,
701
1711
  },
702
- output: OUTPUT,
1712
+ aliases: {"missionId":["mission_id"]},
1713
+ board: true,
1714
+ output: output(SCHEMAS.finding),
703
1715
  async execute(args, exec) {
704
1716
  const tower = await Tower.open(args.repoRoot)
705
1717
  const view = await tower.rosterView()
@@ -718,6 +1730,51 @@ export function registerTowerTools(ctx) {
718
1730
  },
719
1731
  })
720
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
+
721
1778
  define(ctx, {
722
1779
  name: 'tower_review',
723
1780
  description:
@@ -726,7 +1783,8 @@ export function registerTowerTools(ctx) {
726
1783
  type: 'object',
727
1784
  properties: {
728
1785
  repoRoot: { type: 'string', description: 'Absolute path inside the target Git repository.' },
729
- missionId: { type: 'string', description: 'Mission id, e.g. M1.' },
1786
+ missionId: { type: 'string', description: 'Mission id, e.g. M1 (canonical; alias: id).' },
1787
+ id: { type: 'string', description: 'Alias for missionId; both are accepted.' },
730
1788
  status: { type: 'string', description: 'Verdict: "clean", or "p1-Nitems" / "p2-Nitems" with the number of findings at that priority.' },
731
1789
  merge: { type: 'string', enum: ['merge', 'fix-then-merge', 'hold'], description: 'Merge recommendation for the tower.' },
732
1790
  findings: { type: 'string', description: 'Full findings text (markdown); write "none" when clean.' },
@@ -734,10 +1792,13 @@ export function registerTowerTools(ctx) {
734
1792
  decision: { type: 'string', description: 'The reasoning behind your verdict.' },
735
1793
  breakGlassReason: { type: 'string', description: 'Tower-only exceptional self-review: why no independent reviewer was used.' },
736
1794
  },
737
- required: ['repoRoot', 'missionId', 'status', 'merge', 'findings', 'decision'],
1795
+ required: ['repoRoot', 'status', 'merge', 'findings', 'decision'],
738
1796
  additionalProperties: false,
739
1797
  },
740
- output: OUTPUT,
1798
+ aliases: {"missionId":["id"]},
1799
+ requiresMissionId: true,
1800
+ board: true,
1801
+ output: output(SCHEMAS.review),
741
1802
  async execute(args, exec) {
742
1803
  const tower = await Tower.open(args.repoRoot)
743
1804
  return tower.submitReview({
@@ -761,23 +1822,26 @@ export function registerTowerTools(ctx) {
761
1822
  type: 'object',
762
1823
  properties: {
763
1824
  repoRoot: { type: 'string', description: 'Absolute path inside the target Git repository.' },
764
- missionId: { type: 'string', description: 'Mission id, e.g. M1.' },
1825
+ missionId: { type: 'string', description: 'Mission id, e.g. M1 (canonical; alias: id).' },
1826
+ id: { type: 'string', description: 'Alias for missionId; both are accepted.' },
765
1827
  },
766
- required: ['repoRoot', 'missionId'],
1828
+ required: ['repoRoot'],
767
1829
  additionalProperties: false,
768
1830
  },
769
- output: OUTPUT,
1831
+ aliases: {"missionId":["id"]},
1832
+ requiresMissionId: true,
1833
+ output: output(SCHEMAS.preflight),
770
1834
  async execute(args) {
771
1835
  const tower = await Tower.open(args.repoRoot)
772
- const { activity } = await observeActivity(ctx, tower)
773
- const gate = await tower.evaluateGate({ missionId: args.missionId, activity })
1836
+ const { activity, parentStatus } = await observeActivity(ctx, tower)
1837
+ const gate = await tower.evaluateGate({ missionId: args.missionId, activity, parentStatus })
774
1838
  return {
775
1839
  ok: gate.ok,
776
1840
  blockers: gate.blockers,
777
1841
  missionId: args.missionId,
778
- branchTip: gate.branchTip,
779
- baseTip: gate.baseTip,
780
- changedFiles: gate.changedFiles,
1842
+ branchTip: gate.branchTip ?? null,
1843
+ baseTip: gate.baseTip ?? null,
1844
+ changedFiles: gate.changedFiles ?? [],
781
1845
  review: gate.review
782
1846
  ? {
783
1847
  round: gate.review.round,
@@ -799,21 +1863,32 @@ export function registerTowerTools(ctx) {
799
1863
  type: 'object',
800
1864
  properties: {
801
1865
  repoRoot: { type: 'string', description: 'Absolute path inside the target Git repository.' },
802
- missionId: { type: 'string', description: 'Mission id, e.g. M1.' },
1866
+ missionId: { type: 'string', description: 'Mission id, e.g. M1 (canonical; alias: id).' },
1867
+ id: { type: 'string', description: 'Alias for missionId; both are accepted.' },
803
1868
  timeoutMs: {
804
1869
  type: 'integer',
805
1870
  minimum: 1000,
806
1871
  description: 'Optional timeout for the merge command in milliseconds, including pre-merge hooks (default 15 minutes).',
807
1872
  },
1873
+ timeout_ms: {
1874
+ type: 'integer',
1875
+ minimum: 1000,
1876
+ description: 'Alias for timeoutMs.',
1877
+ },
808
1878
  },
809
- required: ['repoRoot', 'missionId'],
1879
+ required: ['repoRoot'],
810
1880
  additionalProperties: false,
811
1881
  },
812
- output: OUTPUT,
813
- async execute(args) {
1882
+ aliases: {"missionId":["id"],"timeoutMs":["timeout_ms"]},
1883
+ requiresMissionId: true,
1884
+ board: true,
1885
+ output: output(SCHEMAS.merge),
1886
+ async execute(args, exec) {
814
1887
  const tower = await Tower.open(args.repoRoot)
815
- const { activity } = await observeActivity(ctx, tower)
816
- return tower.merge({ missionId: args.missionId, activity, timeoutMs: args.timeoutMs })
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 })
817
1892
  },
818
1893
  })
819
1894
 
@@ -825,14 +1900,20 @@ export function registerTowerTools(ctx) {
825
1900
  type: 'object',
826
1901
  properties: {
827
1902
  repoRoot: { type: 'string', description: 'Absolute path inside the target Git repository.' },
828
- missionId: { type: 'string', description: 'Mission id, e.g. M1.' },
1903
+ missionId: { type: 'string', description: 'Mission id, e.g. M1 (canonical; alias: id).' },
1904
+ id: { type: 'string', description: 'Alias for missionId; both are accepted.' },
829
1905
  },
830
- required: ['repoRoot', 'missionId'],
1906
+ required: ['repoRoot'],
831
1907
  additionalProperties: false,
832
1908
  },
833
- output: OUTPUT,
834
- async execute(args) {
1909
+ aliases: {"missionId":["id"]},
1910
+ requiresMissionId: true,
1911
+ board: true,
1912
+ output: output(SCHEMAS.rebase),
1913
+ async execute(args, exec) {
835
1914
  const tower = await Tower.open(args.repoRoot)
1915
+ const actor = await actorFor(tower, exec)
1916
+ requireTowerActor(actor, 'rebase missions')
836
1917
  const { activity } = await observeActivity(ctx, tower)
837
1918
  return tower.rebase({ missionId: args.missionId, activity })
838
1919
  },
@@ -849,13 +1930,18 @@ export function registerTowerTools(ctx) {
849
1930
  force: { type: 'boolean', description: 'Remove worktrees that contain uncommitted changes (never a live agent).' },
850
1931
  exclude: { type: 'array', items: { type: 'string' }, description: 'Mission ids to keep.' },
851
1932
  dry_run: { type: 'boolean', description: 'Preview remove/keep decisions without changing disk or state.' },
1933
+ dryRun: { type: 'boolean', description: 'Alias for dry_run.' },
852
1934
  },
853
1935
  required: ['repoRoot'],
854
1936
  additionalProperties: false,
855
1937
  },
856
- output: OUTPUT,
857
- async execute(args) {
1938
+ aliases: {"dry_run":["dryRun"]},
1939
+ board: true,
1940
+ output: output(SCHEMAS.teardown),
1941
+ async execute(args, exec) {
858
1942
  const tower = await Tower.open(args.repoRoot)
1943
+ const actor = await actorFor(tower, exec)
1944
+ requireTowerActor(actor, 'tear down worktrees')
859
1945
  const { activity } = await observeActivity(ctx, tower)
860
1946
  return tower.teardown({
861
1947
  force: args.force === true,
@@ -865,4 +1951,300 @@ export function registerTowerTools(ctx) {
865
1951
  })
866
1952
  },
867
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
+ }
868
2250
  }