dsh-tower 1.2.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 (61) hide show
  1. package/CHANGELOG.md +96 -0
  2. package/CONTRIBUTING.md +36 -0
  3. package/README.en.md +60 -23
  4. package/README.md +54 -21
  5. package/SECURITY.md +20 -0
  6. package/cordis.patch.yml +32 -0
  7. package/docs/README.md +17 -0
  8. package/docs/architecture/overview.md +70 -0
  9. package/docs/examples/first-mission.md +58 -0
  10. package/docs/faq.md +74 -0
  11. package/docs/guide/hot-reload.md +189 -0
  12. package/docs/guide/install-upgrade-migration.md +59 -0
  13. package/docs/guide/quickstart.md +97 -0
  14. package/docs/operations/runbook.md +80 -0
  15. package/docs/operations/security.md +62 -0
  16. package/docs/reference/blockers.md +58 -0
  17. package/docs/reference/config.md +114 -0
  18. package/docs/reference/doctor.md +31 -0
  19. package/docs/reference/errors.md +50 -0
  20. package/docs/reference/events.md +45 -0
  21. package/docs/reference/operations.md +52 -0
  22. package/docs/reference/testing.md +61 -0
  23. package/docs/reference/tools.md +75 -0
  24. package/docs/zh/README.md +58 -0
  25. package/docs/zh/faq.md +72 -0
  26. package/docs/zh/guide/hot-reload.md +178 -0
  27. package/docs/zh/guide/install-upgrade-migration.md +57 -0
  28. package/docs/zh/guide/quickstart.md +96 -0
  29. package/docs/zh/operations/security.md +42 -0
  30. package/docs/zh/reference/blockers.md +62 -0
  31. package/docs/zh/reference/config.md +113 -0
  32. package/docs/zh/reference/doctor.md +30 -0
  33. package/docs/zh/reference/errors.md +52 -0
  34. package/docs/zh/reference/events.md +46 -0
  35. package/docs/zh/reference/operations.md +48 -0
  36. package/docs/zh/reference/testing.md +60 -0
  37. package/docs/zh/reference/tools.md +75 -0
  38. package/index.js +293 -49
  39. package/lib/batch.js +578 -0
  40. package/lib/config.js +381 -0
  41. package/lib/doctor.js +165 -0
  42. package/lib/errors.js +111 -0
  43. package/lib/hmr.js +196 -0
  44. package/lib/leases.js +317 -0
  45. package/lib/legacy.js +413 -0
  46. package/lib/modeStore.js +47 -16
  47. package/lib/store.js +24 -1
  48. package/lib/tests.js +628 -0
  49. package/lib/tools.js +1594 -160
  50. package/lib/tower.js +1025 -101
  51. package/lib/workspaceLock.js +117 -0
  52. package/lib/worktreeOwnership.js +166 -0
  53. package/package.json +21 -4
  54. package/reviewer.js +40 -0
  55. package/scripts/check-docs.mjs +135 -0
  56. package/scripts/enable-dev-hmr.mjs +347 -0
  57. package/skills/tower-recovery.md +40 -0
  58. package/skills/tower-review.md +16 -0
  59. package/skills/tower-worker-protocol.md +15 -0
  60. package/skills.js +63 -0
  61. package/worker.js +7 -4
package/lib/tools.js CHANGED
@@ -1,21 +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'
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'
3
15
 
4
- const OUTPUT = {
5
- schema: { type: 'object' },
6
- 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 }
7
282
  }
8
283
 
9
284
  const ROLE_ENUM = ['worker', 'reviewer']
10
285
 
11
- function define(ctx, definition) {
12
- 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
13
299
  }
14
300
 
15
301
  function fail(message) {
16
302
  throw new Error(message)
17
303
  }
18
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
+
19
487
  function textOf(blocks) {
20
488
  if (!Array.isArray(blocks)) return null
21
489
  return blocks
@@ -23,6 +491,52 @@ function textOf(blocks) {
23
491
  .join('\n')
24
492
  }
25
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
+
26
540
  async function observeActivity(ctx, tower) {
27
541
  const view = await tower.rosterView()
28
542
  const subagents = ctx.get('subagents')
@@ -42,7 +556,22 @@ async function observeActivity(ctx, tower) {
42
556
  return { activity, parentStatus }
43
557
  }
44
558
 
45
- 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
+ }) {
46
575
  const lines = []
47
576
  lines.push(`# Tower mission ${mission.id} — ${mission.title}`)
48
577
  lines.push('')
@@ -67,6 +596,36 @@ function workerBriefing({ repoRoot, base, mission, deps, worktree, instructions
67
596
  lines.push('Context (verbatim from the user):')
68
597
  lines.push(mission.context)
69
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
+ }
70
629
  lines.push('')
71
630
  lines.push('Rules:')
72
631
  lines.push(`- Pass workdir="${worktree.path}" on every shell command; never edit the main checkout.`)
@@ -76,7 +635,7 @@ function workerBriefing({ repoRoot, base, mission, deps, worktree, instructions
76
635
  `- Update progress: tower_mission({repoRoot:"${repoRoot}",id:"${mission.id}",task_done:"..."}) or blocker:"...".`,
77
636
  )
78
637
  lines.push(
79
- `- 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.`,
80
639
  )
81
640
  lines.push('- Your final message is the full handoff: files changed, how you verified, what remains.')
82
641
  if (instructions) {
@@ -87,7 +646,17 @@ function workerBriefing({ repoRoot, base, mission, deps, worktree, instructions
87
646
  return lines.join('\n')
88
647
  }
89
648
 
90
- 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
+ }) {
91
660
  const lines = []
92
661
  lines.push(`# Tower review — mission ${mission.id} — ${mission.title}`)
93
662
  lines.push('')
@@ -110,6 +679,36 @@ function reviewerBriefing({ repoRoot, base, mission, deps, worktree, instruction
110
679
  lines.push('Context (verbatim from the user):')
111
680
  lines.push(mission.context)
112
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
+ }
113
712
  lines.push('')
114
713
  lines.push('Rules:')
115
714
  lines.push('- Read-only: do not modify files, commit, or merge.')
@@ -130,24 +729,76 @@ function reviewerBriefing({ repoRoot, base, mission, deps, worktree, instruction
130
729
  return lines.join('\n')
131
730
  }
132
731
 
133
- 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))
134
765
  define(ctx, {
135
766
  name: 'tower_init',
136
767
  description:
137
- '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.',
138
769
  parameters: {
139
770
  type: 'object',
140
771
  properties: {
141
772
  repoRoot: { type: 'string', description: 'Absolute path inside the target Git repository.' },
142
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.' },
143
777
  },
144
778
  required: ['repoRoot'],
145
779
  additionalProperties: false,
146
780
  },
147
- output: OUTPUT,
148
- async execute(args) {
149
- const tower = await Tower.open(args.repoRoot)
150
- 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 })
151
802
  },
152
803
  })
153
804
 
@@ -171,6 +822,21 @@ export function registerTowerTools(ctx) {
171
822
  deps: { type: 'array', items: { type: 'string' }, description: 'Mission ids that must merge before this one.' },
172
823
  kind: { type: 'string', enum: ['build', 'survey'], description: 'survey = read-only investigation that reserves no scope.' },
173
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
+ },
174
840
  },
175
841
  required: ['title'],
176
842
  additionalProperties: false,
@@ -180,9 +846,12 @@ export function registerTowerTools(ctx) {
180
846
  required: ['repoRoot', 'missions'],
181
847
  additionalProperties: false,
182
848
  },
183
- output: OUTPUT,
184
- async execute(args) {
849
+ board: true,
850
+ output: output(SCHEMAS.plan),
851
+ async execute(args, exec) {
185
852
  const tower = await Tower.open(args.repoRoot)
853
+ const actor = await actorFor(tower, exec)
854
+ requireTowerActor(actor, 'plan missions')
186
855
  return tower.plan({ missions: args.missions })
187
856
  },
188
857
  })
@@ -195,7 +864,8 @@ export function registerTowerTools(ctx) {
195
864
  type: 'object',
196
865
  properties: {
197
866
  repoRoot: { type: 'string', description: 'Absolute path inside the target Git repository.' },
198
- 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.' },
199
869
  status: {
200
870
  type: 'string',
201
871
  enum: ['planned', 'active', 'completed', 'blocked', 'paused', 'abandoned'],
@@ -204,18 +874,32 @@ export function registerTowerTools(ctx) {
204
874
  note: { type: 'string', description: 'Append a decision-log note.' },
205
875
  blocker: { type: 'string', description: 'Report a blocker (also sets status to blocked).' },
206
876
  clear_blockers: { type: 'boolean', description: 'Clear all recorded blockers.' },
877
+ clearBlockers: { type: 'boolean', description: 'Alias for clear_blockers.' },
207
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.' },
208
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.' },
209
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.' },
210
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.' },
211
891
  },
212
- required: ['repoRoot', 'id'],
892
+ required: ['repoRoot'],
213
893
  additionalProperties: false,
214
894
  },
215
- output: OUTPUT,
216
- 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) {
217
899
  const tower = await Tower.open(args.repoRoot)
900
+ const actor = await actorFor(tower, exec)
218
901
  return tower.mission({
902
+ actor,
219
903
  id: args.id,
220
904
  status: args.status,
221
905
  note: args.note,
@@ -225,6 +909,8 @@ export function registerTowerTools(ctx) {
225
909
  task_drop: args.task_drop,
226
910
  task_drop_reason: args.task_drop_reason,
227
911
  scope: args.scope,
912
+ pause_cause: args.pause_cause,
913
+ authorization: args.authorization,
228
914
  })
229
915
  },
230
916
  })
@@ -232,149 +918,393 @@ export function registerTowerTools(ctx) {
232
918
  define(ctx, {
233
919
  name: 'tower_status',
234
920
  description:
235
- '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.',
236
922
  parameters: {
237
923
  type: 'object',
238
924
  properties: {
239
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.' },
240
931
  },
241
932
  required: ['repoRoot'],
242
933
  additionalProperties: false,
243
934
  },
244
- output: OUTPUT,
935
+ aliases: { mission_id: ['missionId'], include_gate: ['includeGate'] },
936
+ output: output(SCHEMAS.status),
245
937
  async execute(args) {
246
938
  const tower = await Tower.open(args.repoRoot)
247
- 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
248
992
  },
249
993
  })
250
994
 
251
995
  define(ctx, {
252
996
  name: 'tower_spawn',
253
997
  description:
254
- 'Spawn a worker or reviewer for a mission. Provisions (or reuses) the mission git worktree and branch, generates a self-contained briefing (mission tasks, scope, deps, context, rules), and starts a background tower-worker subagent in that worktree. Records the agent in the roster and marks the mission active. Returns immediately unless wait=true.',
998
+ 'Spawn a worker or reviewer for a mission. Workers use the tower-worker preset; reviewers require a completed mission and use the read-only tower-reviewer preset. Provisions (or reuses) the mission git worktree and branch, generates a self-contained briefing, and starts a background subagent in that worktree. The child final report is delivered back to this session; tower_roster shows authoritative activity. Records the agent in the roster; reviewer spawns record the branch tip so a reviewer that modifies the branch cannot submit a review. Returns immediately unless wait=true.',
255
999
  parameters: {
256
1000
  type: 'object',
257
1001
  properties: {
258
1002
  repoRoot: { type: 'string', description: 'Absolute path inside the target Git repository.' },
259
- 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.' },
260
1005
  role: { type: 'string', enum: ROLE_ENUM, description: 'worker executes; reviewer audits the mission branch.' },
261
1006
  name: { type: 'string', description: 'Unique roster name; defaults to <role>-<missionId>.' },
262
1007
  instructions: { type: 'string', description: 'Extra tower instructions appended to the briefing.' },
263
1008
  worktreeRoot: { type: 'string', description: 'Optional absolute parent directory for the worktree.' },
264
1009
  wait: { type: 'boolean', description: 'Wait for the child result instead of returning immediately (testing/debug).' },
265
- 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).' },
266
1014
  },
267
- required: ['repoRoot', 'missionId'],
1015
+ required: ['repoRoot'],
268
1016
  additionalProperties: false,
269
1017
  },
270
- output: OUTPUT,
1018
+ aliases: {"missionId":["id"],"waitMs":["wait_ms"]},
1019
+ requiresMissionId: true,
1020
+ board: true,
1021
+ output: output(SCHEMAS.spawn),
271
1022
  async execute(args, exec) {
272
1023
  const tower = await Tower.open(args.repoRoot)
1024
+ const actor = await actorFor(tower, exec)
1025
+ requireTowerActor(actor, 'spawn tower agents')
273
1026
  const role = args.role ?? 'worker'
274
1027
  if (!ROLE_ENUM.includes(role)) fail(`role must be one of ${ROLE_ENUM.join('|')}`)
1028
+ // Repository config (if any) already overrides the profile config here.
1029
+ const config = tower.config
1030
+ const preset = role === 'reviewer' ? config.spawn.reviewerPreset : config.spawn.workerPreset
1031
+ const model = role === 'reviewer' ? config.spawn.reviewerModel : config.spawn.workerModel
275
1032
  const context = await tower.missionContext(args.missionId)
276
- if (role === 'reviewer' && !context.mission.worktree) {
277
- fail('reviewer needs an existing mission worktree; spawn a worker first')
278
- }
279
- const provisioned = await tower.provision({
280
- missionId: args.missionId,
281
- worktreeRoot: args.worktreeRoot,
282
- })
283
- const { mission, worktree } = provisioned
284
- const contextWithWorktree = {
285
- ...context,
286
- mission: {
287
- ...context.mission,
288
- worktree: { path: worktree.path, branch: worktree.branch, baseCommit: worktree.baseCommit },
289
- },
1033
+ if (role === 'reviewer') {
1034
+ if (!context.mission.worktree) {
1035
+ fail('reviewer needs an existing mission worktree; spawn a worker first')
1036
+ }
1037
+ if (context.mission.status !== 'completed') {
1038
+ fail(`reviewer requires a completed mission awaiting review; ${context.mission.id} is ${context.mission.status}`)
1039
+ }
290
1040
  }
291
- const name = (args.name ?? `${role}-${mission.id.toLowerCase()}`).trim()
292
- const briefing =
293
- role === 'worker'
294
- ? workerBriefing({
295
- repoRoot: tower.root,
296
- base: context.base,
297
- mission: contextWithWorktree.mission,
298
- deps: context.deps,
299
- worktree,
300
- instructions: args.instructions,
301
- })
302
- : reviewerBriefing({
303
- repoRoot: tower.root,
304
- base: context.base,
305
- mission: contextWithWorktree.mission,
306
- deps: context.deps,
307
- worktree,
308
- instructions: args.instructions,
309
- })
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
+ }
310
1119
 
311
- const subagents = ctx.get('subagents')
312
- if (!subagents) fail('subagents service is unavailable; cannot spawn')
313
- const providers = subagents.list()
314
- const provider = providers.includes('spawn') ? 'spawn' : providers[0]
315
- if (!provider) fail('no subagent provider is registered')
316
-
317
- const activation = await subagents.startActivation({
318
- provider,
319
- label: name,
320
- request: {
321
- cwd: worktree.path,
322
- agentPreset: 'tower-worker',
323
- prompt: [{ type: 'text', text: briefing }],
324
- parent: exec.agent,
325
- maxDepth: 1,
326
- },
327
- signal: exec.signal,
328
- delivery: 'parent',
329
- })
330
- if (activation.result && typeof activation.result.catch === 'function') {
331
- activation.result.catch(() => {})
332
- }
333
-
334
- const agentId = String(activation.childId)
335
- const entry = await tower.recordSpawn({
336
- missionId: mission.id,
337
- name,
338
- role,
339
- agentId,
340
- parentSessionId: String(exec.agent.id),
341
- cwd: worktree.path,
342
- branch: worktree.branch,
343
- })
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
+ }
1150
+ }
344
1151
 
345
- const report = {
346
- name,
347
- agentId,
348
- role,
349
- missionId: mission.id,
350
- worktree: { path: worktree.path, branch: worktree.branch, baseCommit: worktree.baseCommit },
351
- created: provisioned.created,
352
- briefingChars: briefing.length,
353
- rosterEntry: entry,
354
- provider,
355
- }
356
-
357
- if (args.wait === true) {
358
- const waitMs = Math.max(1000, Math.min(600000, args.waitMs ?? 180000))
359
- let timer
360
- const timeout = new Promise((resolve) => {
361
- timer = setTimeout(() => resolve({ __timeout: true }), waitMs)
1152
+ const provisioned = await tower.provision({
1153
+ missionId: args.missionId,
1154
+ worktreeRoot: args.worktreeRoot,
1155
+ includeDirtyBase: args.includeDirtyBase !== false,
362
1156
  })
363
- let result
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
+ })
1189
+
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
+ }
1194
+
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
+ }
1204
+
1205
+ let activation = null
1206
+ let agentId = null
1207
+ let entry = null
364
1208
  try {
365
- result = await Promise.race([activation.result, timeout])
366
- } finally {
367
- clearTimeout(timer)
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
+ })
1234
+ } catch (error) {
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
+ }
1245
+ throw error
368
1246
  }
369
- if (result && result.__timeout) {
370
- report.timedOut = true
371
- } else {
372
- report.stopReason = result?.stopReason ?? null
373
- report.output = textOf(result?.output)
374
- report.diagnostic = result?.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,
375
1265
  }
1266
+
1267
+ return { report, activation, recordOutcome }
1268
+ })
1269
+ const { report, activation, recordOutcome } = spawned
1270
+ if (args.wait !== true) {
1271
+ activation.result
1272
+ .then((result) => {
1273
+ const state = result?.stopReason === 'completed' ? 'inactive' : 'failed'
1274
+ return recordOutcome(state, result?.diagnostic ?? result?.stopReason ?? null)
1275
+ })
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)
376
1305
  }
377
1306
  return report
1307
+
378
1308
  },
379
1309
  })
380
1310
 
@@ -390,7 +1320,7 @@ export function registerTowerTools(ctx) {
390
1320
  required: ['repoRoot'],
391
1321
  additionalProperties: false,
392
1322
  },
393
- output: OUTPUT,
1323
+ output: output(SCHEMAS.roster),
394
1324
  async execute(args) {
395
1325
  const tower = await Tower.open(args.repoRoot)
396
1326
  const view = await tower.rosterView()
@@ -410,13 +1340,22 @@ export function registerTowerTools(ctx) {
410
1340
  }
411
1341
  }
412
1342
 
1343
+ const staleMs = Number(tower.config.spawn?.staleMs) || 0
1344
+ const now = Date.now()
413
1345
  const roster = view.roster.map((entry) => {
414
1346
  const observed = activity.get(entry.agentId)
415
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
416
1354
  return {
417
1355
  ...entry,
418
1356
  activity: observed ?? (known ? 'missing' : 'unknown'),
419
1357
  observed: observed !== undefined,
1358
+ stale,
420
1359
  }
421
1360
  })
422
1361
  return { revision: view.revision, roster, parentStatus, missions: view.missions }
@@ -424,20 +1363,80 @@ export function registerTowerTools(ctx) {
424
1363
  })
425
1364
 
426
1365
  define(ctx, {
427
- name: 'tower_adopt',
1366
+ name: 'tower_interrupt',
428
1367
  description:
429
- '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.',
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.',
430
1369
  parameters: {
431
1370
  type: 'object',
432
1371
  properties: {
433
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
+
1397
+ define(ctx, {
1398
+ name: 'tower_diff',
1399
+ description:
1400
+ 'Read-only view of a mission branch for review: merge base, changed files, commits, and a capped patch against the recorded base. Safe for reviewers; it never modifies the repository.',
1401
+ parameters: {
1402
+ type: 'object',
1403
+ properties: {
1404
+ repoRoot: { type: 'string', description: 'Absolute path to the main checkout root.' },
1405
+ missionId: { type: 'string', description: 'Mission id, e.g. M1 (canonical; alias: id).' },
1406
+ id: { type: 'string', description: 'Alias for missionId; both are accepted.' },
1407
+ maxBytes: { type: 'integer', minimum: 1024, description: 'Patch cap in bytes (default 65536).' },
434
1408
  },
435
1409
  required: ['repoRoot'],
436
1410
  additionalProperties: false,
437
1411
  },
438
- output: OUTPUT,
1412
+ aliases: {"missionId":["id"]},
1413
+ requiresMissionId: true,
1414
+ output: output(SCHEMAS.diff),
439
1415
  async execute(args) {
440
1416
  const tower = await Tower.open(args.repoRoot)
1417
+ return tower.diff({ missionId: args.missionId, maxBytes: args.maxBytes })
1418
+ },
1419
+ })
1420
+
1421
+ define(ctx, {
1422
+ name: 'tower_adopt',
1423
+ description:
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.',
1425
+ parameters: {
1426
+ type: 'object',
1427
+ properties: {
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.' },
1430
+ },
1431
+ required: ['repoRoot'],
1432
+ additionalProperties: false,
1433
+ },
1434
+ aliases: { retire_missing: ['retireMissing'] },
1435
+ output: output(SCHEMAS.adopt),
1436
+ async execute(args, exec) {
1437
+ const tower = await Tower.open(args.repoRoot)
1438
+ const actor = await actorFor(tower, exec)
1439
+ requireTowerActor(actor, 'adopt the roster')
441
1440
  const view = await tower.rosterView()
442
1441
  const subagents = ctx.get('subagents')
443
1442
  if (!subagents) fail('subagents service is unavailable; cannot adopt')
@@ -461,10 +1460,13 @@ export function registerTowerTools(ctx) {
461
1460
  if (parentStatus[entry.parentSessionId] !== 'ok') continue
462
1461
  if (!activity.has(entry.agentId)) retiredCandidates.push(entry.agentId)
463
1462
  }
464
- 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)
465
1466
  const after = await tower.rosterView()
466
1467
  return {
467
1468
  retired,
1469
+ missing,
468
1470
  skippedUnknownParents: Object.entries(parentStatus)
469
1471
  .filter(([, status]) => status !== 'ok')
470
1472
  .map(([parent]) => parent),
@@ -486,16 +1488,24 @@ export function registerTowerTools(ctx) {
486
1488
  subject: { type: 'string', description: 'One-line subject.' },
487
1489
  body: { type: 'string', description: 'Full message body (markdown).' },
488
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.' },
489
1497
  },
490
1498
  required: ['repoRoot', 'to', 'subject', 'body'],
491
1499
  additionalProperties: false,
492
1500
  },
493
- output: OUTPUT,
1501
+ aliases: { consentRef: ['consent_ref'] },
1502
+ output: output(SCHEMAS.send),
494
1503
  async execute(args, exec) {
495
1504
  const tower = await Tower.open(args.repoRoot)
1505
+ checkSendRate(tower.config, args.repoRoot)
1506
+ const actor = await actorFor(tower, exec)
496
1507
  const view = await tower.rosterView()
497
- const self = view.roster.find((entry) => entry.agentId === String(exec.agent.id))
498
- const from = self ? self.name : 'tower'
1508
+ const from = actor.role === 'tower' ? 'tower' : actor.name
499
1509
  if (typeof args.subject !== 'string' || args.subject.trim().length === 0) {
500
1510
  fail('tower_send requires a subject')
501
1511
  }
@@ -508,6 +1518,7 @@ export function registerTowerTools(ctx) {
508
1518
  subject: args.subject.trim(),
509
1519
  body: args.body,
510
1520
  scope: args.scope,
1521
+ consentRef: args.consentRef,
511
1522
  })
512
1523
 
513
1524
  const subagents = ctx.get('subagents')
@@ -526,6 +1537,9 @@ export function registerTowerTools(ctx) {
526
1537
  // an unreadable parent catalog only means "do not wake it"
527
1538
  }
528
1539
  }
1540
+ const sendConfig = tower.config.send ?? {}
1541
+ const interrupt =
1542
+ args.interrupt === undefined ? sendConfig.interruptDefault === true : args.interrupt === true
529
1543
  const targets =
530
1544
  args.to === 'all' ? roster.map((entry) => entry.name) : [args.to]
531
1545
  for (const targetName of targets) {
@@ -542,6 +1556,16 @@ export function registerTowerTools(ctx) {
542
1556
  results.push({ target: targetName, pushed: false, reason: 'not running; queued in inbox' })
543
1557
  continue
544
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
+ }
545
1569
  try {
546
1570
  await subagents.sendMessage(
547
1571
  exec.agent,
@@ -554,7 +1578,7 @@ export function registerTowerTools(ctx) {
554
1578
  ],
555
1579
  { signal: exec.signal },
556
1580
  )
557
- results.push({ target: targetName, pushed: true })
1581
+ results.push({ target: targetName, pushed: true, interrupted })
558
1582
  } catch (error) {
559
1583
  results.push({ target: targetName, pushed: false, reason: String(error?.message ?? error) })
560
1584
  }
@@ -578,11 +1602,13 @@ export function registerTowerTools(ctx) {
578
1602
  repoRoot: { type: 'string', description: 'Absolute path inside the target Git repository.' },
579
1603
  limit: { type: 'number', description: 'Max messages to return (default 20), newest first.' },
580
1604
  include_read: { type: 'boolean', description: 'Include previously acknowledged messages for history lookup.' },
1605
+ includeRead: { type: 'boolean', description: 'Alias for include_read.' },
581
1606
  },
582
1607
  required: ['repoRoot'],
583
1608
  additionalProperties: false,
584
1609
  },
585
- output: OUTPUT,
1610
+ aliases: {"include_read":["includeRead"]},
1611
+ output: output(SCHEMAS.inbox),
586
1612
  async execute(args, exec) {
587
1613
  const tower = await Tower.open(args.repoRoot)
588
1614
  const view = await tower.rosterView()
@@ -595,24 +1621,64 @@ export function registerTowerTools(ctx) {
595
1621
  define(ctx, {
596
1622
  name: 'tower_complete',
597
1623
  description:
598
- '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.',
599
1625
  parameters: {
600
1626
  type: 'object',
601
1627
  properties: {
602
1628
  repoRoot: { type: 'string', description: 'Absolute path inside the target Git repository.' },
603
- 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.' },
604
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
+ },
605
1647
  },
606
- required: ['repoRoot', 'missionId', 'report'],
1648
+ required: ['repoRoot', 'report'],
607
1649
  additionalProperties: false,
608
1650
  },
609
- output: OUTPUT,
1651
+ aliases: {"missionId":["id"]},
1652
+ requiresMissionId: true,
1653
+ board: true,
1654
+ output: output(SCHEMAS.complete),
610
1655
  async execute(args, exec) {
611
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
+ }
612
1677
  return tower.complete({
613
1678
  missionId: args.missionId,
614
1679
  agentId: String(exec.agent.id),
615
1680
  report: args.report,
1681
+ checks,
616
1682
  })
617
1683
  },
618
1684
  })
@@ -620,7 +1686,7 @@ export function registerTowerTools(ctx) {
620
1686
  define(ctx, {
621
1687
  name: 'tower_finding',
622
1688
  description:
623
- '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.',
624
1690
  parameters: {
625
1691
  type: 'object',
626
1692
  properties: {
@@ -632,12 +1698,15 @@ export function registerTowerTools(ctx) {
632
1698
  location: { type: 'string', description: 'File/symbol the finding concerns.' },
633
1699
  details: { type: 'string', description: 'Full details: evidence, reproduction, impact.' },
634
1700
  suggested_fix: { type: 'string', description: 'What you would do about it.' },
635
- 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.' },
636
1703
  },
637
1704
  required: ['repoRoot', 'type', 'title', 'summary', 'details', 'suggested_fix'],
638
1705
  additionalProperties: false,
639
1706
  },
640
- output: OUTPUT,
1707
+ aliases: {"missionId":["mission_id"]},
1708
+ board: true,
1709
+ output: output(SCHEMAS.finding),
641
1710
  async execute(args, exec) {
642
1711
  const tower = await Tower.open(args.repoRoot)
643
1712
  const view = await tower.rosterView()
@@ -656,6 +1725,51 @@ export function registerTowerTools(ctx) {
656
1725
  },
657
1726
  })
658
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
+
659
1773
  define(ctx, {
660
1774
  name: 'tower_review',
661
1775
  description:
@@ -664,7 +1778,8 @@ export function registerTowerTools(ctx) {
664
1778
  type: 'object',
665
1779
  properties: {
666
1780
  repoRoot: { type: 'string', description: 'Absolute path inside the target Git repository.' },
667
- 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.' },
668
1783
  status: { type: 'string', description: 'Verdict: "clean", or "p1-Nitems" / "p2-Nitems" with the number of findings at that priority.' },
669
1784
  merge: { type: 'string', enum: ['merge', 'fix-then-merge', 'hold'], description: 'Merge recommendation for the tower.' },
670
1785
  findings: { type: 'string', description: 'Full findings text (markdown); write "none" when clean.' },
@@ -672,10 +1787,13 @@ export function registerTowerTools(ctx) {
672
1787
  decision: { type: 'string', description: 'The reasoning behind your verdict.' },
673
1788
  breakGlassReason: { type: 'string', description: 'Tower-only exceptional self-review: why no independent reviewer was used.' },
674
1789
  },
675
- required: ['repoRoot', 'missionId', 'status', 'merge', 'findings', 'decision'],
1790
+ required: ['repoRoot', 'status', 'merge', 'findings', 'decision'],
676
1791
  additionalProperties: false,
677
1792
  },
678
- output: OUTPUT,
1793
+ aliases: {"missionId":["id"]},
1794
+ requiresMissionId: true,
1795
+ board: true,
1796
+ output: output(SCHEMAS.review),
679
1797
  async execute(args, exec) {
680
1798
  const tower = await Tower.open(args.repoRoot)
681
1799
  return tower.submitReview({
@@ -699,16 +1817,19 @@ export function registerTowerTools(ctx) {
699
1817
  type: 'object',
700
1818
  properties: {
701
1819
  repoRoot: { type: 'string', description: 'Absolute path inside the target Git repository.' },
702
- 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.' },
703
1822
  },
704
- required: ['repoRoot', 'missionId'],
1823
+ required: ['repoRoot'],
705
1824
  additionalProperties: false,
706
1825
  },
707
- output: OUTPUT,
1826
+ aliases: {"missionId":["id"]},
1827
+ requiresMissionId: true,
1828
+ output: output(SCHEMAS.preflight),
708
1829
  async execute(args) {
709
1830
  const tower = await Tower.open(args.repoRoot)
710
- const { activity } = await observeActivity(ctx, tower)
711
- 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 })
712
1833
  return {
713
1834
  ok: gate.ok,
714
1835
  blockers: gate.blockers,
@@ -737,21 +1858,32 @@ export function registerTowerTools(ctx) {
737
1858
  type: 'object',
738
1859
  properties: {
739
1860
  repoRoot: { type: 'string', description: 'Absolute path inside the target Git repository.' },
740
- 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.' },
741
1863
  timeoutMs: {
742
1864
  type: 'integer',
743
1865
  minimum: 1000,
744
1866
  description: 'Optional timeout for the merge command in milliseconds, including pre-merge hooks (default 15 minutes).',
745
1867
  },
1868
+ timeout_ms: {
1869
+ type: 'integer',
1870
+ minimum: 1000,
1871
+ description: 'Alias for timeoutMs.',
1872
+ },
746
1873
  },
747
- required: ['repoRoot', 'missionId'],
1874
+ required: ['repoRoot'],
748
1875
  additionalProperties: false,
749
1876
  },
750
- output: OUTPUT,
751
- 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) {
752
1882
  const tower = await Tower.open(args.repoRoot)
753
- const { activity } = await observeActivity(ctx, tower)
754
- 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 })
755
1887
  },
756
1888
  })
757
1889
 
@@ -763,14 +1895,20 @@ export function registerTowerTools(ctx) {
763
1895
  type: 'object',
764
1896
  properties: {
765
1897
  repoRoot: { type: 'string', description: 'Absolute path inside the target Git repository.' },
766
- 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.' },
767
1900
  },
768
- required: ['repoRoot', 'missionId'],
1901
+ required: ['repoRoot'],
769
1902
  additionalProperties: false,
770
1903
  },
771
- output: OUTPUT,
772
- async execute(args) {
1904
+ aliases: {"missionId":["id"]},
1905
+ requiresMissionId: true,
1906
+ board: true,
1907
+ output: output(SCHEMAS.rebase),
1908
+ async execute(args, exec) {
773
1909
  const tower = await Tower.open(args.repoRoot)
1910
+ const actor = await actorFor(tower, exec)
1911
+ requireTowerActor(actor, 'rebase missions')
774
1912
  const { activity } = await observeActivity(ctx, tower)
775
1913
  return tower.rebase({ missionId: args.missionId, activity })
776
1914
  },
@@ -787,13 +1925,18 @@ export function registerTowerTools(ctx) {
787
1925
  force: { type: 'boolean', description: 'Remove worktrees that contain uncommitted changes (never a live agent).' },
788
1926
  exclude: { type: 'array', items: { type: 'string' }, description: 'Mission ids to keep.' },
789
1927
  dry_run: { type: 'boolean', description: 'Preview remove/keep decisions without changing disk or state.' },
1928
+ dryRun: { type: 'boolean', description: 'Alias for dry_run.' },
790
1929
  },
791
1930
  required: ['repoRoot'],
792
1931
  additionalProperties: false,
793
1932
  },
794
- output: OUTPUT,
795
- async execute(args) {
1933
+ aliases: {"dry_run":["dryRun"]},
1934
+ board: true,
1935
+ output: output(SCHEMAS.teardown),
1936
+ async execute(args, exec) {
796
1937
  const tower = await Tower.open(args.repoRoot)
1938
+ const actor = await actorFor(tower, exec)
1939
+ requireTowerActor(actor, 'tear down worktrees')
797
1940
  const { activity } = await observeActivity(ctx, tower)
798
1941
  return tower.teardown({
799
1942
  force: args.force === true,
@@ -803,4 +1946,295 @@ export function registerTowerTools(ctx) {
803
1946
  })
804
1947
  },
805
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
+ }
806
2240
  }