dsh-tower 1.3.0 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/CHANGELOG.md +77 -0
  2. package/CONTRIBUTING.md +1 -1
  3. package/README.en.md +22 -8
  4. package/README.md +20 -8
  5. package/cordis.patch.yml +10 -0
  6. package/docs/README.md +8 -4
  7. package/docs/architecture/overview.md +13 -9
  8. package/docs/faq.md +33 -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/operations/runbook.md +23 -0
  13. package/docs/operations/security.md +23 -0
  14. package/docs/reference/blockers.md +10 -2
  15. package/docs/reference/config.md +65 -2
  16. package/docs/reference/doctor.md +31 -0
  17. package/docs/reference/errors.md +50 -0
  18. package/docs/reference/events.md +45 -0
  19. package/docs/reference/operations.md +52 -0
  20. package/docs/reference/testing.md +61 -0
  21. package/docs/reference/tools.md +65 -23
  22. package/docs/zh/README.md +58 -0
  23. package/docs/zh/faq.md +72 -0
  24. package/docs/zh/guide/hot-reload.md +178 -0
  25. package/docs/zh/guide/install-upgrade-migration.md +57 -0
  26. package/docs/zh/guide/quickstart.md +96 -0
  27. package/docs/zh/operations/security.md +42 -0
  28. package/docs/zh/reference/blockers.md +62 -0
  29. package/docs/zh/reference/config.md +113 -0
  30. package/docs/zh/reference/doctor.md +30 -0
  31. package/docs/zh/reference/errors.md +52 -0
  32. package/docs/zh/reference/events.md +46 -0
  33. package/docs/zh/reference/operations.md +48 -0
  34. package/docs/zh/reference/testing.md +60 -0
  35. package/docs/zh/reference/tools.md +75 -0
  36. package/index.js +261 -13
  37. package/lib/batch.js +578 -0
  38. package/lib/config.js +270 -8
  39. package/lib/doctor.js +165 -0
  40. package/lib/errors.js +111 -0
  41. package/lib/hmr.js +196 -0
  42. package/lib/leases.js +317 -0
  43. package/lib/legacy.js +413 -0
  44. package/lib/modeStore.js +28 -1
  45. package/lib/store.js +24 -1
  46. package/lib/tests.js +628 -0
  47. package/lib/tools.js +1554 -182
  48. package/lib/tower.js +957 -102
  49. package/lib/workspaceLock.js +117 -0
  50. package/lib/worktreeOwnership.js +166 -0
  51. package/package.json +4 -1
  52. package/reviewer.js +1 -0
  53. package/scripts/check-docs.mjs +135 -0
  54. package/scripts/enable-dev-hmr.mjs +347 -0
  55. package/skills/tower-review.md +4 -2
  56. package/skills/tower-worker-protocol.md +3 -1
  57. 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,273 @@ 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
- },
299
- }
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
- })
1041
+ const spawned = await enqueueSpawn(tower.root, async () => {
1042
+ const view = await tower.rosterView()
1043
+ const inactiveStates = ROSTER_INACTIVE_STATES
1044
+ // Resolve a unique roster name BEFORE any activation. The default name
1045
+ // is derived per role/mission and gets a numeric suffix on rework rounds.
1046
+ let name
1047
+ if (typeof args.name === 'string' && args.name.trim()) {
1048
+ name = args.name.trim()
1049
+ if (view.roster.some((entry) => entry.name === name)) {
1050
+ throw new TowerToolError('invalid-argument', 'roster name already used: ' + name)
1051
+ }
1052
+ } else {
1053
+ const base = role + '-' + args.missionId.toLowerCase()
1054
+ let candidate = base
1055
+ let suffix = 2
1056
+ while (view.roster.some((entry) => entry.name === candidate)) {
1057
+ candidate = base + '-' + suffix
1058
+ suffix += 1
1059
+ }
1060
+ name = candidate
1061
+ }
1062
+ let takeover = args.takeover === true
1063
+ if (role === 'worker') {
1064
+ // Pre-check the single-owner and live-worker rules BEFORE provisioning
1065
+ // or starting a child: a rejection must not leak an activation/worktree.
1066
+ const liveWorkers = view.roster.filter(
1067
+ (entry) => entry.role === 'worker' && !inactiveStates.includes(entry.state),
1068
+ )
1069
+ const sameMission = liveWorkers.filter((entry) => entry.missionId === args.missionId)
1070
+ if (sameMission.length > 1) {
1071
+ throw new TowerToolError(
1072
+ 'invalid-transition',
1073
+ 'mission ' + args.missionId + ' has ' + sameMission.length + ' live worker entries; inspect tower_roster before spawning',
1074
+ )
1075
+ }
1076
+ if (sameMission.length === 1 && !takeover) {
1077
+ throw new TowerToolError(
1078
+ 'invalid-transition',
1079
+ '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',
1080
+ )
1081
+ }
1082
+ if (takeover) {
1083
+ if (sameMission.length === 1) {
1084
+ const worker = sameMission[0]
1085
+ const observed = await observeActivity(ctx, tower)
1086
+ if (observed.parentStatus[worker.parentSessionId] !== 'ok') {
1087
+ throw new TowerToolError(
1088
+ 'takeover-refused',
1089
+ 'cannot verify that worker ' + worker.name + ' stopped: its parent catalog is unavailable',
1090
+ )
1091
+ }
1092
+ const activity = observed.activity[worker.agentId]
1093
+ if (activity === 'running') {
1094
+ throw new TowerToolError(
1095
+ 'takeover-refused',
1096
+ 'worker ' + worker.name + ' is still running; call tower_interrupt (or wait) before takeover',
1097
+ )
1098
+ }
1099
+ if (activity === undefined) {
1100
+ throw new TowerToolError(
1101
+ 'takeover-refused',
1102
+ 'worker ' + worker.name + ' is not in the live catalog; run tower_adopt before takeover',
1103
+ )
1104
+ }
1105
+ } else {
1106
+ takeover = false
1107
+ }
1108
+ }
1109
+ const replacing = takeover ? sameMission : []
1110
+ const remaining = liveWorkers.filter((entry) => !replacing.includes(entry))
1111
+ const max = Number(config.spawn.maxLiveWorkers) || 4
1112
+ if (remaining.length >= max) {
1113
+ throw new TowerToolError(
1114
+ 'worker-limit',
1115
+ 'live worker limit reached (' + max + '); stop a worker with tower_interrupt or raise spawn.maxLiveWorkers',
1116
+ )
1117
+ }
1118
+ }
319
1119
 
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`)
1120
+ // Service/provider/preset checks happen before provisioning, so a failed
1121
+ // precheck cannot leave a worktree or roster revision behind.
1122
+ const subagents = ctx.get('subagents')
1123
+ if (!subagents) fail('subagents service is unavailable; cannot spawn')
1124
+ const providers = subagents.list()
1125
+ const provider = config.spawn.provider || (providers.includes('spawn') ? 'spawn' : providers[0])
1126
+ if (!provider) fail('no subagent provider is registered')
1127
+ const presets = ctx.get('agentPresets')
1128
+ if (presets && typeof presets.list === 'function') {
1129
+ const available = await presets.list()
1130
+ if (!available.some((entry) => String(entry.id) === preset)) {
1131
+ fail('agent preset "' + preset + '" is not registered; check the Tower bundle preset rows and restart DSH')
1132
+ }
1133
+ }
1134
+ if (typeof config.spawn.requireLease === 'string' && config.spawn.requireLease.trim()) {
1135
+ const requiredLease = config.spawn.requireLease.trim()
1136
+ const info = await inspectLeases(tower.commonDir)
1137
+ const lease = info.leases.find((entry) => entry.name === requiredLease && entry.expired !== true)
1138
+ if (!lease) {
1139
+ throw new TowerToolError(
1140
+ 'lease-busy',
1141
+ `spawn.requireLease="${requiredLease}" is not held; acquire it with tower_resource before spawning`,
1142
+ )
1143
+ }
1144
+ if (lease.holder !== actor.name && lease.holder !== String(exec.agent.id)) {
1145
+ throw new TowerToolError(
1146
+ 'lease-busy',
1147
+ `spawn.requireLease="${requiredLease}" is held by "${lease.holder}", not by this tower`,
1148
+ )
1149
+ }
330
1150
  }
331
- }
332
1151
 
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
- }
1152
+ const provisioned = await tower.provision({
1153
+ missionId: args.missionId,
1154
+ worktreeRoot: args.worktreeRoot,
1155
+ includeDirtyBase: args.includeDirtyBase !== false,
1156
+ })
1157
+ const { mission, worktree } = provisioned
1158
+ const contextWithWorktree = {
1159
+ ...context,
1160
+ mission: {
1161
+ ...context.mission,
1162
+ worktree: { path: worktree.path, branch: worktree.branch, baseCommit: worktree.baseCommit },
1163
+ },
1164
+ }
1165
+ const briefing =
1166
+ role === 'worker'
1167
+ ? workerBriefing({
1168
+ repoRoot: tower.root,
1169
+ base: context.base,
1170
+ mission: contextWithWorktree.mission,
1171
+ deps: context.deps,
1172
+ worktree,
1173
+ instructions: args.instructions,
1174
+ reviews: context.reviews,
1175
+ findings: context.findings,
1176
+ completionReport: context.completionReport,
1177
+ })
1178
+ : reviewerBriefing({
1179
+ repoRoot: tower.root,
1180
+ base: context.base,
1181
+ mission: contextWithWorktree.mission,
1182
+ deps: context.deps,
1183
+ worktree,
1184
+ instructions: args.instructions,
1185
+ reviews: context.reviews,
1186
+ findings: context.findings,
1187
+ completionReport: context.completionReport,
1188
+ })
337
1189
 
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
- })
1190
+ const agentOptions = {}
1191
+ for (const key of ['provider', 'model', 'reasoningEffort', 'maxTokens']) {
1192
+ if (model && model[key] !== undefined && model[key] !== null && model[key] !== '') agentOptions[key] = model[key]
1193
+ }
352
1194
 
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
- })
1195
+ if (role === 'worker' && tower.config.tests?.admission === 'spawn') {
1196
+ const discovery = await runMissionDiscovery(tower, mission)
1197
+ if (discovery.configured && discovery.receipt.ok !== true) {
1198
+ throw new TowerToolError(
1199
+ 'tests-failed',
1200
+ `test admission failed for ${mission.id}: ${discovery.receipt.error}; no child was started`,
1201
+ )
1202
+ }
1203
+ }
363
1204
 
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
1205
+ let activation = null
1206
+ let agentId = null
1207
+ let entry = null
389
1208
  try {
390
- result = await Promise.race([activation.result, timeout])
1209
+ activation = await subagents.startActivation({
1210
+ provider,
1211
+ label: name,
1212
+ request: {
1213
+ cwd: worktree.path,
1214
+ agentPreset: preset,
1215
+ prompt: [{ type: 'text', text: briefing }],
1216
+ parent: exec.agent,
1217
+ maxDepth: config.spawn.maxDepth ?? 1,
1218
+ ...(Object.keys(agentOptions).length > 0 ? { agentOptions } : {}),
1219
+ },
1220
+ signal: exec.signal,
1221
+ delivery: 'parent',
1222
+ })
1223
+ agentId = String(activation.childId)
1224
+ entry = await tower.recordSpawn({
1225
+ missionId: mission.id,
1226
+ name,
1227
+ role,
1228
+ agentId,
1229
+ parentSessionId: String(exec.agent.id),
1230
+ cwd: worktree.path,
1231
+ branch: worktree.branch,
1232
+ takeover,
1233
+ })
391
1234
  } catch (error) {
392
- report.stopReason = 'failed'
393
- report.diagnostic = String(error?.message ?? error)
394
- await recordOutcome('failed', report.diagnostic)
1235
+ // The child may already be running even when recordSpawn failed (for
1236
+ // example a race on a roster name): stop it so it cannot keep writing
1237
+ // unobserved, then surface the real error.
1238
+ if (agentId) {
1239
+ try {
1240
+ subagents.interrupt(agentId, { kind: 'ancestor', agent: exec.agent })
1241
+ } catch {
1242
+ // best effort; the error below is the actionable one
1243
+ }
1244
+ }
395
1245
  throw error
396
- } finally {
397
- clearTimeout(timer)
398
1246
  }
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)
1247
+
1248
+ const recordOutcome = (state, detail) =>
1249
+ tower.recordSpawnOutcome({ missionId: mission.id, name, state, detail }).catch(() => {})
1250
+
1251
+ const report = {
1252
+ name,
1253
+ agentId,
1254
+ role,
1255
+ missionId: mission.id,
1256
+ preset,
1257
+ provider,
1258
+ maxDepth: config.spawn.maxDepth ?? 1,
1259
+ ...(Object.keys(agentOptions).length > 0 ? { agentOptions } : {}),
1260
+ worktree: { path: worktree.path, branch: worktree.branch, baseCommit: worktree.baseCommit },
1261
+ created: provisioned.created,
1262
+ briefingChars: briefing.length,
1263
+ takeover,
1264
+ rosterEntry: entry,
409
1265
  }
410
- } else {
1266
+
1267
+ return { report, activation, recordOutcome }
1268
+ })
1269
+ const { report, activation, recordOutcome } = spawned
1270
+ if (args.wait !== true) {
411
1271
  activation.result
412
1272
  .then((result) => {
413
1273
  const state = result?.stopReason === 'completed' ? 'inactive' : 'failed'
414
1274
  return recordOutcome(state, result?.diagnostic ?? result?.stopReason ?? null)
415
1275
  })
416
1276
  .catch((error) => recordOutcome('failed', String(error?.message ?? error)))
1277
+ return report
1278
+ }
1279
+ const waitMs = Math.max(1000, Math.min(config.spawn.maxWaitMs ?? 90000, args.waitMs ?? 180000))
1280
+ let timer
1281
+ const timeout = new Promise((resolve) => {
1282
+ timer = setTimeout(() => resolve({ __timeout: true }), waitMs)
1283
+ })
1284
+ let result
1285
+ try {
1286
+ result = await Promise.race([activation.result, timeout])
1287
+ } catch (error) {
1288
+ report.stopReason = 'failed'
1289
+ report.diagnostic = String(error?.message ?? error)
1290
+ await recordOutcome('failed', report.diagnostic)
1291
+ throw error
1292
+ } finally {
1293
+ clearTimeout(timer)
1294
+ }
1295
+ if (result && result.__timeout) {
1296
+ report.timedOut = true
1297
+ recordOutcome('unknown', 'spawn wait timed out; check tower_roster')
1298
+ } else {
1299
+ report.stopReason = result?.stopReason ?? null
1300
+ report.output = textOf(result?.output)
1301
+ report.diagnostic = result?.diagnostic ?? null
1302
+ const state = report.stopReason === 'completed' ? 'inactive' : 'failed'
1303
+ if (state === 'failed') report.diagnostic = report.diagnostic ?? report.stopReason
1304
+ await recordOutcome(state, report.diagnostic ?? null)
417
1305
  }
418
1306
  return report
1307
+
419
1308
  },
420
1309
  })
421
1310
 
@@ -431,7 +1320,7 @@ export function registerTowerTools(ctx) {
431
1320
  required: ['repoRoot'],
432
1321
  additionalProperties: false,
433
1322
  },
434
- output: OUTPUT,
1323
+ output: output(SCHEMAS.roster),
435
1324
  async execute(args) {
436
1325
  const tower = await Tower.open(args.repoRoot)
437
1326
  const view = await tower.rosterView()
@@ -451,19 +1340,60 @@ export function registerTowerTools(ctx) {
451
1340
  }
452
1341
  }
453
1342
 
1343
+ const staleMs = Number(tower.config.spawn?.staleMs) || 0
1344
+ const now = Date.now()
454
1345
  const roster = view.roster.map((entry) => {
455
1346
  const observed = activity.get(entry.agentId)
456
1347
  const known = parentStatus[entry.parentSessionId] === 'ok'
1348
+ const stale =
1349
+ staleMs > 0 &&
1350
+ observed === undefined &&
1351
+ known &&
1352
+ !['retired', 'failed', 'interrupted', 'missing', 'stale'].includes(entry.state) &&
1353
+ now - Number(entry.spawnedAt ?? 0) > staleMs
457
1354
  return {
458
1355
  ...entry,
459
1356
  activity: observed ?? (known ? 'missing' : 'unknown'),
460
1357
  observed: observed !== undefined,
1358
+ stale,
461
1359
  }
462
1360
  })
463
1361
  return { revision: view.revision, roster, parentStatus, missions: view.missions }
464
1362
  },
465
1363
  })
466
1364
 
1365
+ define(ctx, {
1366
+ name: 'tower_interrupt',
1367
+ description:
1368
+ '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.',
1369
+ parameters: {
1370
+ type: 'object',
1371
+ properties: {
1372
+ repoRoot: { type: 'string', description: 'Absolute path inside the target Git repository.' },
1373
+ to: { type: 'string', description: 'Roster name to interrupt.' },
1374
+ agentId: { type: 'string', description: 'Agent/session id to interrupt (alternative to to).' },
1375
+ agent_id: { type: 'string', description: 'Alias for agentId.' },
1376
+ reason: { type: 'string', description: 'Why the agent is being interrupted (recorded in the roster).' },
1377
+ },
1378
+ required: ['repoRoot'],
1379
+ additionalProperties: false,
1380
+ },
1381
+ aliases: { agentId: ['agent_id'] },
1382
+ output: output(SCHEMAS.interrupt),
1383
+ async execute(args, exec) {
1384
+ const tower = await Tower.open(args.repoRoot)
1385
+ const actor = await actorFor(tower, exec)
1386
+ requireTowerActor(actor, 'interrupt tower agents')
1387
+ if (!args.to && !args.agentId) throw new TowerToolError('invalid-argument', 'tower_interrupt requires to (roster name) or agentId')
1388
+ const result = await interruptRosterAgent(ctx, tower, exec, {
1389
+ to: args.to,
1390
+ agentId: args.agentId,
1391
+ reason: args.reason,
1392
+ })
1393
+ return { ok: true, ...result }
1394
+ },
1395
+ })
1396
+
467
1397
  define(ctx, {
468
1398
  name: 'tower_diff',
469
1399
  description:
@@ -472,13 +1402,16 @@ export function registerTowerTools(ctx) {
472
1402
  type: 'object',
473
1403
  properties: {
474
1404
  repoRoot: { type: 'string', description: 'Absolute path to the main checkout root.' },
475
- missionId: { type: 'string', description: 'Mission id, e.g. M1.' },
1405
+ missionId: { type: 'string', description: 'Mission id, e.g. M1 (canonical; alias: id).' },
1406
+ id: { type: 'string', description: 'Alias for missionId; both are accepted.' },
476
1407
  maxBytes: { type: 'integer', minimum: 1024, description: 'Patch cap in bytes (default 65536).' },
477
1408
  },
478
- required: ['repoRoot', 'missionId'],
1409
+ required: ['repoRoot'],
479
1410
  additionalProperties: false,
480
1411
  },
481
- output: OUTPUT,
1412
+ aliases: {"missionId":["id"]},
1413
+ requiresMissionId: true,
1414
+ output: output(SCHEMAS.diff),
482
1415
  async execute(args) {
483
1416
  const tower = await Tower.open(args.repoRoot)
484
1417
  return tower.diff({ missionId: args.missionId, maxBytes: args.maxBytes })
@@ -488,18 +1421,22 @@ export function registerTowerTools(ctx) {
488
1421
  define(ctx, {
489
1422
  name: 'tower_adopt',
490
1423
  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.',
1424
+ '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
1425
  parameters: {
493
1426
  type: 'object',
494
1427
  properties: {
495
1428
  repoRoot: { type: 'string', description: 'Absolute path inside the target Git repository.' },
1429
+ retireMissing: { type: 'boolean', description: 'Retire provably gone agents (default true); false marks them missing instead.' },
496
1430
  },
497
1431
  required: ['repoRoot'],
498
1432
  additionalProperties: false,
499
1433
  },
500
- output: OUTPUT,
501
- async execute(args) {
1434
+ aliases: { retire_missing: ['retireMissing'] },
1435
+ output: output(SCHEMAS.adopt),
1436
+ async execute(args, exec) {
502
1437
  const tower = await Tower.open(args.repoRoot)
1438
+ const actor = await actorFor(tower, exec)
1439
+ requireTowerActor(actor, 'adopt the roster')
503
1440
  const view = await tower.rosterView()
504
1441
  const subagents = ctx.get('subagents')
505
1442
  if (!subagents) fail('subagents service is unavailable; cannot adopt')
@@ -523,10 +1460,13 @@ export function registerTowerTools(ctx) {
523
1460
  if (parentStatus[entry.parentSessionId] !== 'ok') continue
524
1461
  if (!activity.has(entry.agentId)) retiredCandidates.push(entry.agentId)
525
1462
  }
526
- const { retired } = await tower.markRetired(retiredCandidates)
1463
+ const retireMissing = args.retireMissing !== false
1464
+ const { retired } = retireMissing ? await tower.markRetired(retiredCandidates) : { retired: [] }
1465
+ const { missing } = retireMissing ? { missing: [] } : await tower.markMissing(retiredCandidates)
527
1466
  const after = await tower.rosterView()
528
1467
  return {
529
1468
  retired,
1469
+ missing,
530
1470
  skippedUnknownParents: Object.entries(parentStatus)
531
1471
  .filter(([, status]) => status !== 'ok')
532
1472
  .map(([parent]) => parent),
@@ -548,16 +1488,24 @@ export function registerTowerTools(ctx) {
548
1488
  subject: { type: 'string', description: 'One-line subject.' },
549
1489
  body: { type: 'string', description: 'Full message body (markdown).' },
550
1490
  scope: { type: 'string', description: 'Optional scope tag, e.g. the mission id.' },
1491
+ interrupt: {
1492
+ type: 'boolean',
1493
+ description: 'Interrupt the recipient\'s current turn before delivering (Kimi-style "message interrupts the running turn"); default false = queue at the next step boundary.',
1494
+ },
1495
+ consentRef: { type: 'string', description: 'Optional audit reference recording who consented to this instruction.' },
1496
+ consent_ref: { type: 'string', description: 'Alias for consentRef.' },
551
1497
  },
552
1498
  required: ['repoRoot', 'to', 'subject', 'body'],
553
1499
  additionalProperties: false,
554
1500
  },
555
- output: OUTPUT,
1501
+ aliases: { consentRef: ['consent_ref'] },
1502
+ output: output(SCHEMAS.send),
556
1503
  async execute(args, exec) {
557
1504
  const tower = await Tower.open(args.repoRoot)
1505
+ checkSendRate(tower.config, args.repoRoot)
1506
+ const actor = await actorFor(tower, exec)
558
1507
  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'
1508
+ const from = actor.role === 'tower' ? 'tower' : actor.name
561
1509
  if (typeof args.subject !== 'string' || args.subject.trim().length === 0) {
562
1510
  fail('tower_send requires a subject')
563
1511
  }
@@ -570,6 +1518,7 @@ export function registerTowerTools(ctx) {
570
1518
  subject: args.subject.trim(),
571
1519
  body: args.body,
572
1520
  scope: args.scope,
1521
+ consentRef: args.consentRef,
573
1522
  })
574
1523
 
575
1524
  const subagents = ctx.get('subagents')
@@ -588,6 +1537,9 @@ export function registerTowerTools(ctx) {
588
1537
  // an unreadable parent catalog only means "do not wake it"
589
1538
  }
590
1539
  }
1540
+ const sendConfig = tower.config.send ?? {}
1541
+ const interrupt =
1542
+ args.interrupt === undefined ? sendConfig.interruptDefault === true : args.interrupt === true
591
1543
  const targets =
592
1544
  args.to === 'all' ? roster.map((entry) => entry.name) : [args.to]
593
1545
  for (const targetName of targets) {
@@ -604,6 +1556,16 @@ export function registerTowerTools(ctx) {
604
1556
  results.push({ target: targetName, pushed: false, reason: 'not running; queued in inbox' })
605
1557
  continue
606
1558
  }
1559
+ let interrupted = false
1560
+ if (interrupt) {
1561
+ try {
1562
+ subagents.interrupt(entry.agentId, { kind: 'ancestor', agent: exec.agent })
1563
+ interrupted = true
1564
+ } catch (error) {
1565
+ results.push({ target: targetName, pushed: false, reason: `interrupt failed: ${String(error?.message ?? error)}` })
1566
+ continue
1567
+ }
1568
+ }
607
1569
  try {
608
1570
  await subagents.sendMessage(
609
1571
  exec.agent,
@@ -616,7 +1578,7 @@ export function registerTowerTools(ctx) {
616
1578
  ],
617
1579
  { signal: exec.signal },
618
1580
  )
619
- results.push({ target: targetName, pushed: true })
1581
+ results.push({ target: targetName, pushed: true, interrupted })
620
1582
  } catch (error) {
621
1583
  results.push({ target: targetName, pushed: false, reason: String(error?.message ?? error) })
622
1584
  }
@@ -640,11 +1602,13 @@ export function registerTowerTools(ctx) {
640
1602
  repoRoot: { type: 'string', description: 'Absolute path inside the target Git repository.' },
641
1603
  limit: { type: 'number', description: 'Max messages to return (default 20), newest first.' },
642
1604
  include_read: { type: 'boolean', description: 'Include previously acknowledged messages for history lookup.' },
1605
+ includeRead: { type: 'boolean', description: 'Alias for include_read.' },
643
1606
  },
644
1607
  required: ['repoRoot'],
645
1608
  additionalProperties: false,
646
1609
  },
647
- output: OUTPUT,
1610
+ aliases: {"include_read":["includeRead"]},
1611
+ output: output(SCHEMAS.inbox),
648
1612
  async execute(args, exec) {
649
1613
  const tower = await Tower.open(args.repoRoot)
650
1614
  const view = await tower.rosterView()
@@ -657,24 +1621,64 @@ export function registerTowerTools(ctx) {
657
1621
  define(ctx, {
658
1622
  name: 'tower_complete',
659
1623
  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.',
1624
+ '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
1625
  parameters: {
662
1626
  type: 'object',
663
1627
  properties: {
664
1628
  repoRoot: { type: 'string', description: 'Absolute path inside the target Git repository.' },
665
- missionId: { type: 'string', description: 'Mission id, e.g. M1.' },
1629
+ missionId: { type: 'string', description: 'Mission id, e.g. M1 (canonical; alias: id).' },
1630
+ id: { type: 'string', description: 'Alias for missionId; both are accepted.' },
666
1631
  report: { type: 'string', description: 'Full completion report for the tower and the next review round.' },
1632
+ checks: {
1633
+ type: 'array',
1634
+ description: 'Optional evidence ledger entries: {name, command?, exitCode?, evidence?}.',
1635
+ items: {
1636
+ type: 'object',
1637
+ properties: {
1638
+ name: { type: 'string', description: 'What was checked.' },
1639
+ command: { type: 'string', description: 'Command or method used.' },
1640
+ exitCode: { type: 'integer', description: 'Observed exit code.' },
1641
+ evidence: { type: 'string', description: 'Short evidence line/log reference.' },
1642
+ },
1643
+ required: ['name'],
1644
+ additionalProperties: false,
1645
+ },
1646
+ },
667
1647
  },
668
- required: ['repoRoot', 'missionId', 'report'],
1648
+ required: ['repoRoot', 'report'],
669
1649
  additionalProperties: false,
670
1650
  },
671
- output: OUTPUT,
1651
+ aliases: {"missionId":["id"]},
1652
+ requiresMissionId: true,
1653
+ board: true,
1654
+ output: output(SCHEMAS.complete),
672
1655
  async execute(args, exec) {
673
1656
  const tower = await Tower.open(args.repoRoot)
1657
+ // Validate authorization/state before executing repository-controlled
1658
+ // test code or writing a test receipt.
1659
+ await tower.checkCompletion({
1660
+ missionId: args.missionId,
1661
+ agentId: String(exec.agent.id),
1662
+ report: args.report,
1663
+ })
1664
+ const context = await tower.missionContext(args.missionId)
1665
+ const checks = [...(Array.isArray(args.checks) ? args.checks : [])]
1666
+ const testsConfig = tower.config.tests ?? {}
1667
+ if (testsConfig.enabled !== false && testsConfig.admission === 'complete') {
1668
+ const discovery = await runMissionDiscovery(tower, { id: args.missionId, worktree: context.mission.worktree })
1669
+ if (discovery.configured && discovery.receipt.ok !== true) {
1670
+ throw new TowerToolError('tests-failed', `test discovery failed for ${args.missionId}: ${discovery.receipt.error}`)
1671
+ }
1672
+ if (discovery.configured && testsConfig.autoChecks !== false && discovery.check) {
1673
+ if (checks.length >= 20) checks[19] = discovery.check
1674
+ else checks.push(discovery.check)
1675
+ }
1676
+ }
674
1677
  return tower.complete({
675
1678
  missionId: args.missionId,
676
1679
  agentId: String(exec.agent.id),
677
1680
  report: args.report,
1681
+ checks,
678
1682
  })
679
1683
  },
680
1684
  })
@@ -682,7 +1686,7 @@ export function registerTowerTools(ctx) {
682
1686
  define(ctx, {
683
1687
  name: 'tower_finding',
684
1688
  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.',
1689
+ '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
1690
  parameters: {
687
1691
  type: 'object',
688
1692
  properties: {
@@ -694,12 +1698,15 @@ export function registerTowerTools(ctx) {
694
1698
  location: { type: 'string', description: 'File/symbol the finding concerns.' },
695
1699
  details: { type: 'string', description: 'Full details: evidence, reproduction, impact.' },
696
1700
  suggested_fix: { type: 'string', description: 'What you would do about it.' },
697
- missionId: { type: 'string', description: 'Optional mission this finding relates to.' },
1701
+ missionId: { type: 'string', description: 'Optional mission this finding relates to (alias: mission_id).' },
1702
+ mission_id: { type: 'string', description: 'Alias for missionId.' },
698
1703
  },
699
1704
  required: ['repoRoot', 'type', 'title', 'summary', 'details', 'suggested_fix'],
700
1705
  additionalProperties: false,
701
1706
  },
702
- output: OUTPUT,
1707
+ aliases: {"missionId":["mission_id"]},
1708
+ board: true,
1709
+ output: output(SCHEMAS.finding),
703
1710
  async execute(args, exec) {
704
1711
  const tower = await Tower.open(args.repoRoot)
705
1712
  const view = await tower.rosterView()
@@ -718,6 +1725,51 @@ export function registerTowerTools(ctx) {
718
1725
  },
719
1726
  })
720
1727
 
1728
+ define(ctx, {
1729
+ name: 'tower_findings',
1730
+ description:
1731
+ '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.',
1732
+ parameters: {
1733
+ type: 'object',
1734
+ properties: {
1735
+ repoRoot: { type: 'string', description: 'Absolute path inside the target Git repository.' },
1736
+ id: { type: 'string', description: 'Finding id, e.g. F1. When present the call reads or updates that finding.' },
1737
+ status: {
1738
+ type: 'string',
1739
+ enum: ['open', 'triaged', 'fixed', 'wontfix', 'all'],
1740
+ description: 'With id: target status. Without id: filter (default all).',
1741
+ },
1742
+ note: { type: 'string', description: 'Optional note recorded together with an update.' },
1743
+ type: { type: 'string', enum: ['bug', 'improve', 'vuln', 'idea'], description: 'Filter by finding type.' },
1744
+ severity: { type: 'string', enum: ['low', 'medium', 'high', 'critical'], description: 'Filter by severity.' },
1745
+ missionId: { type: 'string', description: 'Filter by mission id (alias: mission_id).' },
1746
+ mission_id: { type: 'string', description: 'Alias for missionId.' },
1747
+ open: { type: 'boolean', description: 'Only findings that are not fixed or wontfix.' },
1748
+ limit: { type: 'integer', minimum: 1, maximum: 200, description: 'Max findings to list (default 50).' },
1749
+ },
1750
+ required: ['repoRoot'],
1751
+ additionalProperties: false,
1752
+ },
1753
+ aliases: {"missionId":["mission_id"]},
1754
+ output: output(SCHEMAS.findings),
1755
+ async execute(args, exec) {
1756
+ const tower = await Tower.open(args.repoRoot)
1757
+ const view = await tower.rosterView()
1758
+ const self = view.roster.find((entry) => entry.agentId === String(exec.agent.id))
1759
+ return tower.findings({
1760
+ id: args.id,
1761
+ status: args.status,
1762
+ note: args.note,
1763
+ type: args.type,
1764
+ severity: args.severity,
1765
+ missionId: args.missionId,
1766
+ open: args.open,
1767
+ limit: args.limit,
1768
+ actor: { name: self ? self.name : 'tower', role: self ? self.role : 'tower' },
1769
+ })
1770
+ },
1771
+ })
1772
+
721
1773
  define(ctx, {
722
1774
  name: 'tower_review',
723
1775
  description:
@@ -726,7 +1778,8 @@ export function registerTowerTools(ctx) {
726
1778
  type: 'object',
727
1779
  properties: {
728
1780
  repoRoot: { type: 'string', description: 'Absolute path inside the target Git repository.' },
729
- missionId: { type: 'string', description: 'Mission id, e.g. M1.' },
1781
+ missionId: { type: 'string', description: 'Mission id, e.g. M1 (canonical; alias: id).' },
1782
+ id: { type: 'string', description: 'Alias for missionId; both are accepted.' },
730
1783
  status: { type: 'string', description: 'Verdict: "clean", or "p1-Nitems" / "p2-Nitems" with the number of findings at that priority.' },
731
1784
  merge: { type: 'string', enum: ['merge', 'fix-then-merge', 'hold'], description: 'Merge recommendation for the tower.' },
732
1785
  findings: { type: 'string', description: 'Full findings text (markdown); write "none" when clean.' },
@@ -734,10 +1787,13 @@ export function registerTowerTools(ctx) {
734
1787
  decision: { type: 'string', description: 'The reasoning behind your verdict.' },
735
1788
  breakGlassReason: { type: 'string', description: 'Tower-only exceptional self-review: why no independent reviewer was used.' },
736
1789
  },
737
- required: ['repoRoot', 'missionId', 'status', 'merge', 'findings', 'decision'],
1790
+ required: ['repoRoot', 'status', 'merge', 'findings', 'decision'],
738
1791
  additionalProperties: false,
739
1792
  },
740
- output: OUTPUT,
1793
+ aliases: {"missionId":["id"]},
1794
+ requiresMissionId: true,
1795
+ board: true,
1796
+ output: output(SCHEMAS.review),
741
1797
  async execute(args, exec) {
742
1798
  const tower = await Tower.open(args.repoRoot)
743
1799
  return tower.submitReview({
@@ -761,16 +1817,19 @@ export function registerTowerTools(ctx) {
761
1817
  type: 'object',
762
1818
  properties: {
763
1819
  repoRoot: { type: 'string', description: 'Absolute path inside the target Git repository.' },
764
- missionId: { type: 'string', description: 'Mission id, e.g. M1.' },
1820
+ missionId: { type: 'string', description: 'Mission id, e.g. M1 (canonical; alias: id).' },
1821
+ id: { type: 'string', description: 'Alias for missionId; both are accepted.' },
765
1822
  },
766
- required: ['repoRoot', 'missionId'],
1823
+ required: ['repoRoot'],
767
1824
  additionalProperties: false,
768
1825
  },
769
- output: OUTPUT,
1826
+ aliases: {"missionId":["id"]},
1827
+ requiresMissionId: true,
1828
+ output: output(SCHEMAS.preflight),
770
1829
  async execute(args) {
771
1830
  const tower = await Tower.open(args.repoRoot)
772
- const { activity } = await observeActivity(ctx, tower)
773
- const gate = await tower.evaluateGate({ missionId: args.missionId, activity })
1831
+ const { activity, parentStatus } = await observeActivity(ctx, tower)
1832
+ const gate = await tower.evaluateGate({ missionId: args.missionId, activity, parentStatus })
774
1833
  return {
775
1834
  ok: gate.ok,
776
1835
  blockers: gate.blockers,
@@ -799,21 +1858,32 @@ export function registerTowerTools(ctx) {
799
1858
  type: 'object',
800
1859
  properties: {
801
1860
  repoRoot: { type: 'string', description: 'Absolute path inside the target Git repository.' },
802
- missionId: { type: 'string', description: 'Mission id, e.g. M1.' },
1861
+ missionId: { type: 'string', description: 'Mission id, e.g. M1 (canonical; alias: id).' },
1862
+ id: { type: 'string', description: 'Alias for missionId; both are accepted.' },
803
1863
  timeoutMs: {
804
1864
  type: 'integer',
805
1865
  minimum: 1000,
806
1866
  description: 'Optional timeout for the merge command in milliseconds, including pre-merge hooks (default 15 minutes).',
807
1867
  },
1868
+ timeout_ms: {
1869
+ type: 'integer',
1870
+ minimum: 1000,
1871
+ description: 'Alias for timeoutMs.',
1872
+ },
808
1873
  },
809
- required: ['repoRoot', 'missionId'],
1874
+ required: ['repoRoot'],
810
1875
  additionalProperties: false,
811
1876
  },
812
- output: OUTPUT,
813
- async execute(args) {
1877
+ aliases: {"missionId":["id"],"timeoutMs":["timeout_ms"]},
1878
+ requiresMissionId: true,
1879
+ board: true,
1880
+ output: output(SCHEMAS.merge),
1881
+ async execute(args, exec) {
814
1882
  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 })
1883
+ const actor = await actorFor(tower, exec)
1884
+ requireTowerActor(actor, 'merge missions')
1885
+ const { activity, parentStatus } = await observeActivity(ctx, tower)
1886
+ return tower.merge({ missionId: args.missionId, activity, parentStatus, timeoutMs: args.timeoutMs })
817
1887
  },
818
1888
  })
819
1889
 
@@ -825,14 +1895,20 @@ export function registerTowerTools(ctx) {
825
1895
  type: 'object',
826
1896
  properties: {
827
1897
  repoRoot: { type: 'string', description: 'Absolute path inside the target Git repository.' },
828
- missionId: { type: 'string', description: 'Mission id, e.g. M1.' },
1898
+ missionId: { type: 'string', description: 'Mission id, e.g. M1 (canonical; alias: id).' },
1899
+ id: { type: 'string', description: 'Alias for missionId; both are accepted.' },
829
1900
  },
830
- required: ['repoRoot', 'missionId'],
1901
+ required: ['repoRoot'],
831
1902
  additionalProperties: false,
832
1903
  },
833
- output: OUTPUT,
834
- async execute(args) {
1904
+ aliases: {"missionId":["id"]},
1905
+ requiresMissionId: true,
1906
+ board: true,
1907
+ output: output(SCHEMAS.rebase),
1908
+ async execute(args, exec) {
835
1909
  const tower = await Tower.open(args.repoRoot)
1910
+ const actor = await actorFor(tower, exec)
1911
+ requireTowerActor(actor, 'rebase missions')
836
1912
  const { activity } = await observeActivity(ctx, tower)
837
1913
  return tower.rebase({ missionId: args.missionId, activity })
838
1914
  },
@@ -849,13 +1925,18 @@ export function registerTowerTools(ctx) {
849
1925
  force: { type: 'boolean', description: 'Remove worktrees that contain uncommitted changes (never a live agent).' },
850
1926
  exclude: { type: 'array', items: { type: 'string' }, description: 'Mission ids to keep.' },
851
1927
  dry_run: { type: 'boolean', description: 'Preview remove/keep decisions without changing disk or state.' },
1928
+ dryRun: { type: 'boolean', description: 'Alias for dry_run.' },
852
1929
  },
853
1930
  required: ['repoRoot'],
854
1931
  additionalProperties: false,
855
1932
  },
856
- output: OUTPUT,
857
- async execute(args) {
1933
+ aliases: {"dry_run":["dryRun"]},
1934
+ board: true,
1935
+ output: output(SCHEMAS.teardown),
1936
+ async execute(args, exec) {
858
1937
  const tower = await Tower.open(args.repoRoot)
1938
+ const actor = await actorFor(tower, exec)
1939
+ requireTowerActor(actor, 'tear down worktrees')
859
1940
  const { activity } = await observeActivity(ctx, tower)
860
1941
  return tower.teardown({
861
1942
  force: args.force === true,
@@ -865,4 +1946,295 @@ export function registerTowerTools(ctx) {
865
1946
  })
866
1947
  },
867
1948
  })
1949
+
1950
+ define(ctx, {
1951
+ name: 'tower_doctor',
1952
+ description:
1953
+ '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.',
1954
+ parameters: {
1955
+ type: 'object',
1956
+ properties: { repoRoot: { type: 'string', description: 'Absolute path inside the target Git repository.' } },
1957
+ required: ['repoRoot'],
1958
+ additionalProperties: false,
1959
+ },
1960
+ output: output(SCHEMAS.doctor),
1961
+ async execute(args, exec) {
1962
+ const tower = await Tower.open(args.repoRoot)
1963
+ await actorFor(tower, exec)
1964
+ return runDoctor({ tower, ctx, agent: exec.agent })
1965
+ },
1966
+ })
1967
+
1968
+ define(ctx, {
1969
+ name: 'tower_test',
1970
+ description:
1971
+ '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.',
1972
+ parameters: {
1973
+ type: 'object',
1974
+ properties: {
1975
+ repoRoot: { type: 'string', description: 'Absolute path inside the target Git repository.' },
1976
+ missionId: { type: 'string', description: 'Mission id, e.g. M1 (alias: id).' },
1977
+ id: { type: 'string', description: 'Alias for missionId.' },
1978
+ },
1979
+ required: ['repoRoot', 'missionId'],
1980
+ additionalProperties: false,
1981
+ },
1982
+ aliases: { missionId: ['id'] },
1983
+ requiresMissionId: true,
1984
+ output: output(SCHEMAS.test),
1985
+ async execute(args, exec) {
1986
+ const tower = await Tower.open(args.repoRoot)
1987
+ const actor = await actorFor(tower, exec)
1988
+ const context = await tower.missionContext(args.missionId)
1989
+ if (!context.mission.worktree) fail(`mission ${args.missionId} has no worktree`)
1990
+ if (actor.role === 'reviewer') fail('reviewers may not run test discovery')
1991
+ if (actor.role === 'worker' && context.mission.owner && context.mission.owner !== actor.name) {
1992
+ fail(`worker ${actor.name} does not own mission ${args.missionId}`)
1993
+ }
1994
+ await tower.assertWorktree(context.mission.worktree)
1995
+ const discovery = await runMissionDiscovery(tower, context.mission)
1996
+ if (!discovery.configured) {
1997
+ return {
1998
+ ok: false,
1999
+ configured: false,
2000
+ error: discovery.error ?? 'tower.testDiscovery is not configured in the worktree package.json',
2001
+ tests: [],
2002
+ commands: [],
2003
+ }
2004
+ }
2005
+ const receipt = discovery.receipt
2006
+ return {
2007
+ ok: receipt.ok === true,
2008
+ configured: true,
2009
+ treeTip: receipt.treeTip ?? null,
2010
+ tests: receipt.tests ?? [],
2011
+ commands: receipt.commands ?? [],
2012
+ error: receipt.error ?? undefined,
2013
+ receipt,
2014
+ checks: discovery.check ? [discovery.check] : [],
2015
+ }
2016
+ },
2017
+ })
2018
+
2019
+ define(ctx, {
2020
+ name: 'tower_batch',
2021
+ description:
2022
+ 'Manage finite evidence batches: create freezes missionIds/requiredChecks/receiptPaths, status summarizes members, close verifies every required check and receipt hash. Membership and exclusions are immutable after create; close never removes missions.',
2023
+ parameters: {
2024
+ type: 'object',
2025
+ properties: {
2026
+ repoRoot: { type: 'string', description: 'Absolute path inside the target Git repository.' },
2027
+ action: { type: 'string', enum: ['create', 'status', 'close'], description: 'Batch operation.' },
2028
+ missionIds: { type: 'array', items: { type: 'string' }, description: 'Exact mission ids in this batch (create).' },
2029
+ exclusions: { type: 'array', items: { type: 'string' }, description: 'Paused legacy mission ids excluded from the batch.' },
2030
+ requiredChecks: { type: 'array', items: { type: 'string' }, description: 'Check names every build member must record (default ["check"]).' },
2031
+ receiptPaths: { type: 'array', items: { type: 'string' }, description: 'Exact relative receipt files checked at close.' },
2032
+ batchId: { type: 'string', description: 'Batch id (status/close).' },
2033
+ checks: { type: 'array', items: { type: 'object' }, description: 'Close-time extra check evidence.' },
2034
+ treeTip: { type: 'string', description: 'Expected reviewed/tested commit for close evidence.' },
2035
+ receiptHashes: { type: 'object', description: 'Relative receipt path -> sha256 observed by the caller.' },
2036
+ },
2037
+ required: ['repoRoot', 'action'],
2038
+ additionalProperties: false,
2039
+ },
2040
+ board: true,
2041
+ output: output(SCHEMAS.batch),
2042
+ async execute(args, exec) {
2043
+ const tower = await Tower.open(args.repoRoot)
2044
+ const actor = await actorFor(tower, exec)
2045
+ if (args.action === 'status') {
2046
+ const state = await tower.load()
2047
+ const batch = args.batchId
2048
+ ? (state.batches ?? []).find((entry) => entry.id === args.batchId)
2049
+ : null
2050
+ if (args.batchId && !batch) fail(`unknown batch "${args.batchId}"`)
2051
+ return {
2052
+ ok: true,
2053
+ action: 'status',
2054
+ batches: (state.batches ?? []).map((entry) => summarizeBatch(state, entry)),
2055
+ ...(batch ? { batch: summarizeBatch(state, batch) } : {}),
2056
+ ...(args.batchId ? { summary: summarizeBatch(state, batch) } : {}),
2057
+ }
2058
+ }
2059
+ requireTowerActor(actor, args.action === 'create' ? 'create a batch' : 'close a batch')
2060
+ if (args.action === 'create') {
2061
+ const batch = await tower.createBatch(args, actor.name)
2062
+ return { ok: true, action: 'create', batch, batches: (await tower.batchStatus()).batches }
2063
+ }
2064
+ if (typeof args.batchId !== 'string' || args.batchId.trim().length === 0) {
2065
+ fail('tower_batch close requires batchId')
2066
+ }
2067
+ const result = await tower.closeBatch(args.batchId.trim(), {
2068
+ checks: args.checks,
2069
+ treeTip: args.treeTip,
2070
+ receiptHashes: args.receiptHashes,
2071
+ }, actor.name)
2072
+ return {
2073
+ ok: result.ok,
2074
+ action: 'close',
2075
+ batch: result.batch,
2076
+ blockers: (result.blockers ?? []).map((blocker) => ({
2077
+ code: blocker.code ?? 'batch-invalid',
2078
+ detail: blocker.detail ?? blocker.message ?? String(blocker),
2079
+ ...(blocker.cleanup ? { cleanup: blocker.cleanup } : {}),
2080
+ ...(blocker.timeout ? { timeout: true } : {}),
2081
+ })),
2082
+ checks: result.checks ?? [],
2083
+ }
2084
+ },
2085
+ })
2086
+
2087
+ define(ctx, {
2088
+ name: 'tower_resource',
2089
+ description:
2090
+ '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.',
2091
+ parameters: {
2092
+ type: 'object',
2093
+ properties: {
2094
+ repoRoot: { type: 'string', description: 'Absolute path inside the target Git repository.' },
2095
+ action: { type: 'string', enum: ['acquire', 'release', 'status'], description: 'Lease operation.' },
2096
+ name: { type: 'string', description: 'Lease name, e.g. plugin-load or heavy-ci.' },
2097
+ holder: { type: 'string', description: 'Holder label (defaults to the caller).' },
2098
+ token: { type: 'string', description: 'Release token; generated on acquire when omitted.' },
2099
+ ttlMs: { type: 'integer', minimum: 1000, description: 'Lease lifetime (default leases.ttlMs).' },
2100
+ force: { type: 'boolean', description: 'Explicitly release an expired lease (token still required).' },
2101
+ },
2102
+ required: ['repoRoot', 'action'],
2103
+ additionalProperties: false,
2104
+ },
2105
+ output: output(SCHEMAS.resource),
2106
+ async execute(args, exec) {
2107
+ const tower = await Tower.open(args.repoRoot)
2108
+ const actor = await actorFor(tower, exec)
2109
+ if (args.action === 'status') {
2110
+ const info = await inspectLeases(tower.commonDir)
2111
+ return { ok: true, action: 'status', leases: info.leases, ...info }
2112
+ }
2113
+ requireTowerActor(actor, args.action === 'acquire' ? 'acquire a lease' : 'release a lease')
2114
+ if (typeof args.name !== 'string' || args.name.trim().length === 0) fail('tower_resource requires name')
2115
+ const name = args.name.trim()
2116
+ const ttlMs = Number(args.ttlMs ?? tower.config.leases?.ttlMs ?? 900000)
2117
+ if (args.action === 'acquire') {
2118
+ const lease = await acquireLease(tower.commonDir, {
2119
+ name,
2120
+ holder: args.holder ?? actor.name,
2121
+ token: args.token ?? crypto.randomUUID(),
2122
+ pid: process.pid,
2123
+ ttlMs,
2124
+ })
2125
+ return { ok: true, action: 'acquire', lease }
2126
+ }
2127
+ const token = args.token
2128
+ if (typeof token !== 'string' || token.length === 0) fail('tower_resource release requires token')
2129
+ const released = await releaseLease(tower.commonDir, { name, token, force: args.force === true })
2130
+ return { ok: true, action: 'release', released, leases: (await inspectLeases(tower.commonDir)).leases }
2131
+ },
2132
+ })
2133
+
2134
+ define(ctx, {
2135
+ name: 'tower_import_legacy',
2136
+ description:
2137
+ '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.',
2138
+ parameters: {
2139
+ type: 'object',
2140
+ properties: {
2141
+ repoRoot: { type: 'string', description: 'Absolute path inside the target Git repository.' },
2142
+ sourcePath: { type: 'string', description: 'Absolute path to the legacy JSON state file.' },
2143
+ },
2144
+ required: ['repoRoot', 'sourcePath'],
2145
+ additionalProperties: false,
2146
+ },
2147
+ board: true,
2148
+ output: output(SCHEMAS.importLegacy),
2149
+ async execute(args, exec) {
2150
+ const tower = await Tower.open(args.repoRoot)
2151
+ const actor = await actorFor(tower, exec)
2152
+ requireTowerActor(actor, 'import a legacy snapshot')
2153
+ const imported = await importLegacySnapshot({
2154
+ repoRoot: tower.root,
2155
+ commonDir: tower.commonDir,
2156
+ sourcePath: args.sourcePath,
2157
+ })
2158
+ const recorded = await tower.recordLegacySnapshot(imported.snapshot)
2159
+ return { ok: true, summary: imported.summary, snapshot: recorded.snapshot }
2160
+ },
2161
+ })
2162
+
2163
+ define(ctx, {
2164
+ name: 'tower_legacy_map',
2165
+ description:
2166
+ '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.',
2167
+ parameters: {
2168
+ type: 'object',
2169
+ properties: {
2170
+ repoRoot: { type: 'string', description: 'Absolute path inside the target Git repository.' },
2171
+ action: { type: 'string', enum: ['map', 'status'], description: 'Mapping operation.' },
2172
+ sourceHash: { type: 'string', description: 'Legacy snapshot sha256 (map).' },
2173
+ oldId: { type: 'string', description: 'Legacy mission id (map).' },
2174
+ newMissionId: { type: 'string', description: 'Current mission id; optional audit pointer.' },
2175
+ },
2176
+ required: ['repoRoot', 'action'],
2177
+ additionalProperties: false,
2178
+ },
2179
+ output: output(SCHEMAS.legacyMap),
2180
+ async execute(args, exec) {
2181
+ const tower = await Tower.open(args.repoRoot)
2182
+ const actor = await actorFor(tower, exec)
2183
+ const state = await tower.load()
2184
+ if (args.action === 'status') {
2185
+ return { ok: true, maps: state.legacyMaps ?? [] }
2186
+ }
2187
+ requireTowerActor(actor, 'map a legacy mission')
2188
+ const validation = validateLegacyMap(state, args)
2189
+ if (validation.ok !== true) fail(validation.error ?? 'invalid legacy mapping')
2190
+ const map = { ...validation.map, by: actor.name, at: Date.now() }
2191
+ const recorded = await tower.recordLegacyMap(map)
2192
+ return { ok: true, map: recorded.map, maps: [...(state.legacyMaps ?? []), recorded.map] }
2193
+ },
2194
+ })
2195
+
2196
+ // Dev-only hot reload. The tool is registered whenever dev.hotReload is on;
2197
+ // it reports reload-unavailable when the profile has no HMR service. When the
2198
+ // Host passed a shared bridge, its status() reflects the command's reloads too.
2199
+ const dev = runtimeConfig().dev ?? {}
2200
+ if (dev.hotReload !== false) {
2201
+ const bridge =
2202
+ sharedHmrBridge ??
2203
+ createHmrBridge(ctx, {
2204
+ requiredIds: dev.reloadRequiredIds,
2205
+ delayMs: dev.reloadDelayMs,
2206
+ logger: ctx.logger ?? console,
2207
+ })
2208
+ define(ctx, {
2209
+ name: 'tower_reload',
2210
+ description:
2211
+ '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.',
2212
+ parameters: {
2213
+ type: 'object',
2214
+ properties: {
2215
+ repoRoot: { type: 'string', description: 'Optional repository root; when present the caller must be the tower.' },
2216
+ },
2217
+ required: [],
2218
+ additionalProperties: false,
2219
+ },
2220
+ output: output(SCHEMAS.reload),
2221
+ async execute(args, exec) {
2222
+ if (typeof args.repoRoot === 'string') {
2223
+ const tower = await Tower.open(args.repoRoot)
2224
+ const actor = await actorFor(tower, exec)
2225
+ requireTowerActor(actor, 'reload the Tower plugin')
2226
+ }
2227
+ if (!bridge.available) {
2228
+ throw new TowerToolError('reload-unavailable', 'this DSH profile has no HMR service; restart DSH instead')
2229
+ }
2230
+ const outcome = bridge.schedule()
2231
+ return {
2232
+ ok: true,
2233
+ ...outcome,
2234
+ ...(outcome.lastError ? { error: outcome.lastError } : {}),
2235
+ restartHint: 'If the new code does not appear, restart DSH or check the profile hmr entry.',
2236
+ }
2237
+ },
2238
+ })
2239
+ }
868
2240
  }