dsh-tower 2.0.3 → 2.0.4

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 (52) hide show
  1. package/CHANGELOG.md +40 -0
  2. package/README.en.md +19 -13
  3. package/README.md +19 -13
  4. package/SECURITY.md +4 -1
  5. package/docs/README.md +30 -0
  6. package/docs/architecture/overview.md +3 -2
  7. package/docs/examples/first-mission.md +2 -1
  8. package/docs/faq.md +11 -7
  9. package/docs/guide/install-upgrade-migration.md +14 -5
  10. package/docs/guide/quickstart.md +12 -7
  11. package/docs/guide/scenarios-and-philosophy.md +86 -69
  12. package/docs/operations/security.md +18 -12
  13. package/docs/reference/blockers.md +11 -10
  14. package/docs/reference/config.md +7 -6
  15. package/docs/reference/errors.md +7 -1
  16. package/docs/reference/events.md +7 -4
  17. package/docs/reference/testing.md +2 -1
  18. package/docs/reference/tools.md +7 -6
  19. package/docs/zh/README.md +1 -1
  20. package/docs/zh/faq.md +9 -6
  21. package/docs/zh/guide/install-upgrade-migration.md +11 -4
  22. package/docs/zh/guide/quickstart.md +13 -8
  23. package/docs/zh/guide/scenarios-and-philosophy.md +55 -50
  24. package/docs/zh/operations/security.md +13 -6
  25. package/docs/zh/reference/blockers.md +10 -10
  26. package/docs/zh/reference/config.md +7 -6
  27. package/docs/zh/reference/errors.md +2 -1
  28. package/docs/zh/reference/events.md +6 -3
  29. package/docs/zh/reference/testing.md +3 -2
  30. package/docs/zh/reference/tools.md +7 -6
  31. package/index.js +26 -17
  32. package/lib/config.js +100 -6
  33. package/lib/doctor.js +11 -5
  34. package/lib/errors.js +14 -2
  35. package/lib/git.js +36 -2
  36. package/lib/modeStore.js +54 -20
  37. package/lib/store.js +65 -10
  38. package/lib/tests.js +35 -1
  39. package/lib/tools.js +257 -47
  40. package/lib/tower.js +268 -45
  41. package/lib/workspaceLock.js +44 -20
  42. package/package.json +3 -2
  43. package/scripts/check-docs.mjs +35 -0
  44. package/skills/tower-recovery.md +7 -5
  45. package/ui-bundle/README.md +111 -0
  46. package/ui-bundle/client.js +1980 -0
  47. package/ui-bundle/cordis.patch.yml +3 -0
  48. package/ui-bundle/icon.svg +6 -0
  49. package/ui-bundle/index.js +2 -0
  50. package/ui-bundle/locale/en.json +6 -0
  51. package/ui-bundle/locale/zh.json +6 -0
  52. package/ui-bundle/package.json +35 -0
package/lib/tools.js CHANGED
@@ -4,6 +4,7 @@ import { TowerToolError, classifyError } from './errors.js'
4
4
  import { git, resolveRepo, hasAnyCommit } from './git.js'
5
5
  import { runtimeConfig } from './config.js'
6
6
  import { createHmrBridge } from './hmr.js'
7
+ import { readStateLock } from './store.js'
7
8
  import { readDiscoveryConfig, normalizeDiscovery, runDiscovery } from './tests.js'
8
9
  import { validateCreate as validateBatchCreate, validateClose as validateBatchClose, summarize as summarizeBatch, BatchError } from './batch.js'
9
10
  import { acquireLease, releaseLease, inspectLeases } from './leases.js'
@@ -248,6 +249,10 @@ const SCHEMAS = {
248
249
  ['ok', 'scheduled'],
249
250
  ),
250
251
  doctor: S.obj({ ok: S.bool, checks: S.arr(S.obj()), environmentContract: S.obj() }, ['ok', 'checks']),
252
+ recover: S.obj(
253
+ { ok: S.bool, action: S.str, stateLock: S.any, cleared: S.bool, backup: S.any, error: S.str },
254
+ ['ok', 'action'],
255
+ ),
251
256
  test: S.obj(
252
257
  {
253
258
  ok: S.bool,
@@ -298,26 +303,40 @@ function normalizeToolArgs(args, aliases = {}) {
298
303
  return out
299
304
  }
300
305
 
301
- function fail(message) {
302
- throw new Error(message)
303
- }
304
-
305
- /** Resolve the calling agent to a roster role; unknown callers are the tower. */
306
+ /** Resolve the calling agent to a roster role; unknown callers are rejected. */
306
307
  async function actorFor(tower, exec) {
307
308
  const agentId = exec?.agent?.id !== undefined ? String(exec.agent.id) : null
308
- if (!agentId) return { name: 'tower', role: 'tower', agentId: null }
309
+ const unknown = { name: 'unknown', role: 'unknown', agentId }
310
+ if (!agentId) return unknown
311
+ let state
312
+ try {
313
+ state = await tower.load()
314
+ } catch (error) {
315
+ if (!/not initialized/i.test(String(error?.message ?? error))) throw error
316
+ // No readable state: only tower_init may claim ownership (handled in that tool).
317
+ return unknown
318
+ }
319
+ if (state.towerAgentId && String(state.towerAgentId) === agentId) {
320
+ return { name: 'tower', role: 'tower', agentId }
321
+ }
309
322
  let view
310
323
  try {
311
324
  view = await tower.rosterView()
312
325
  } catch {
313
- // Before tower_init there is no state; the caller is the tower.
314
- return { name: 'tower', role: 'tower', agentId }
326
+ return unknown
315
327
  }
316
328
  const self = view.roster.find((entry) => entry.agentId === agentId)
317
- if (!self) return { name: 'tower', role: 'tower', agentId }
329
+ if (!self) return unknown
330
+ if (self.role === 'tower') return { name: self.name, role: 'tower', agentId }
318
331
  return { name: self.name, role: self.role, agentId }
319
332
  }
320
333
 
334
+ function requireKnownActor(actor, action) {
335
+ if (!actor || actor.role === 'unknown') {
336
+ throw new TowerToolError('not-authorized', `${action} requires a registered roster agent or the tower`)
337
+ }
338
+ }
339
+
321
340
  function requireTowerActor(actor, action) {
322
341
  if (actor.role !== 'tower') {
323
342
  throw new TowerToolError('not-authorized', `only the tower can ${action}; ${actor.name} is a ${actor.role}`)
@@ -366,6 +385,37 @@ function checkSendRate(config, repoRoot) {
366
385
  bucket.count += 1
367
386
  }
368
387
 
388
+ async function verifyBatchReceiptFiles(root, batch, args) {
389
+ const problems = []
390
+ const frozen = new Set(batch.receiptPaths ?? [])
391
+ const declared = new Map()
392
+ for (const [rawPath, rawHash] of Object.entries(args.receiptHashes ?? {})) {
393
+ declared.set(String(rawPath), String(rawHash).trim().toLowerCase())
394
+ }
395
+ for (const entry of args.receipts ?? []) {
396
+ if (entry && typeof entry.path === 'string' && typeof entry.hash === 'string') {
397
+ declared.set(entry.path, entry.hash.trim().toLowerCase())
398
+ }
399
+ }
400
+ for (const [rel, reported] of declared) {
401
+ if (frozen.size > 0 && !frozen.has(rel)) continue
402
+ let actual
403
+ try {
404
+ const abs = path.resolve(root, rel)
405
+ const relative = path.relative(root, abs)
406
+ if (relative.startsWith('..') || path.isAbsolute(relative)) throw new Error('path escapes the repository')
407
+ actual = crypto.createHash('sha256').update(await fsp.readFile(abs)).digest('hex')
408
+ } catch (error) {
409
+ problems.push({ code: 'batch-invalid', detail: `receipt "${rel}" cannot be read: ${String(error?.message ?? error)}` })
410
+ continue
411
+ }
412
+ if (reported && reported !== actual) {
413
+ problems.push({ code: 'batch-invalid', detail: `receipt "${rel}" hash ${reported.slice(0, 12)} does not match the file (${actual.slice(0, 12)})` })
414
+ }
415
+ }
416
+ return problems
417
+ }
418
+
369
419
  async function interruptRosterAgent(ctx, tower, exec, input) {
370
420
  const subagents = ctx.get('subagents')
371
421
  if (!subagents || typeof subagents.interrupt !== 'function') {
@@ -459,7 +509,7 @@ function define(ctx, definition) {
459
509
  }
460
510
  if (typeof normalized.repoRoot === 'string') watchRepo(normalized.repoRoot)
461
511
  const event = rest.name.startsWith('tower_') ? rest.name.slice('tower_'.length) : null
462
- if (event && !['mode', 'control', 'reload'].includes(event)) {
512
+ if (event) {
463
513
  try {
464
514
  ctx.emit?.(`tower/${event}`, {
465
515
  tool: rest.name,
@@ -795,10 +845,29 @@ export function registerTowerTools(ctx, options = {}) {
795
845
  const initConfig = tower.config.init ?? {}
796
846
  const snapshotDirty =
797
847
  args.snapshotDirty === true || (args.snapshotDirty === undefined && initConfig.snapshotDirty === true)
798
- const actor = await actorFor(tower, exec)
799
- requireTowerActor(actor, 'initialize Tower state')
848
+ const agentId = exec?.agent?.id !== undefined ? String(exec.agent.id) : null
849
+ if (!agentId) {
850
+ throw new TowerToolError('not-authorized', 'tower_init requires a caller agent id; refusing to treat an unidentified caller as the tower')
851
+ }
852
+ let state = null
853
+ try {
854
+ state = await tower.load()
855
+ } catch {
856
+ state = null
857
+ }
858
+ if (state?.towerAgentId) {
859
+ const actor = await actorFor(tower, exec)
860
+ requireTowerActor(actor, 'initialize Tower state')
861
+ } else if (state) {
862
+ // Legacy state predating owner binding: an explicit tower_init may
863
+ // claim ownership once. Other tools remain fail-closed until then.
864
+ const actor = await actorFor(tower, exec)
865
+ if (actor.role !== 'tower' && actor.role !== 'unknown') {
866
+ requireTowerActor(actor, 'claim Tower ownership')
867
+ }
868
+ }
800
869
  const base = args.base ?? workspaceHook?.basePreference?.(exec) ?? undefined
801
- return tower.init({ base, snapshotDirty, reanchor: args.reanchor })
870
+ return tower.init({ base, snapshotDirty, reanchor: args.reanchor, towerAgentId: agentId })
802
871
  },
803
872
  })
804
873
 
@@ -831,7 +900,7 @@ export function registerTowerTools(ctx, options = {}) {
831
900
  reviewPolicy: {
832
901
  type: 'string',
833
902
  enum: ['required-clean', 'none'],
834
- description: 'required-clean (default) requires a clean review before merge; none needs reviewExemptionReason and gates.allowReviewExemption.',
903
+ description: 'required-clean (mission default) means a clean review is required when review.requireReview is on; none needs reviewExemptionReason, and with the review gate on it also needs gates.allowReviewExemption.',
835
904
  },
836
905
  reviewExemptionReason: {
837
906
  type: 'string',
@@ -898,6 +967,7 @@ export function registerTowerTools(ctx, options = {}) {
898
967
  async execute(args, exec) {
899
968
  const tower = await Tower.open(args.repoRoot)
900
969
  const actor = await actorFor(tower, exec)
970
+ requireKnownActor(actor, 'tower_mission')
901
971
  return tower.mission({
902
972
  actor,
903
973
  id: args.id,
@@ -1024,7 +1094,7 @@ export function registerTowerTools(ctx, options = {}) {
1024
1094
  const actor = await actorFor(tower, exec)
1025
1095
  requireTowerActor(actor, 'spawn tower agents')
1026
1096
  const role = args.role ?? 'worker'
1027
- if (!ROLE_ENUM.includes(role)) fail(`role must be one of ${ROLE_ENUM.join('|')}`)
1097
+ if (!ROLE_ENUM.includes(role)) throw new TowerToolError('invalid-argument', `role must be one of ${ROLE_ENUM.join('|')}`)
1028
1098
  // Repository config (if any) already overrides the profile config here.
1029
1099
  const config = tower.config
1030
1100
  const preset = role === 'reviewer' ? config.spawn.reviewerPreset : config.spawn.workerPreset
@@ -1032,14 +1102,15 @@ export function registerTowerTools(ctx, options = {}) {
1032
1102
  const context = await tower.missionContext(args.missionId)
1033
1103
  if (role === 'reviewer') {
1034
1104
  if (!context.mission.worktree) {
1035
- fail('reviewer needs an existing mission worktree; spawn a worker first')
1105
+ throw new TowerToolError('reviewer-prerequisite', 'reviewer needs an existing mission worktree; spawn a worker first')
1036
1106
  }
1037
1107
  if (context.mission.status !== 'completed') {
1038
- fail(`reviewer requires a completed mission awaiting review; ${context.mission.id} is ${context.mission.status}`)
1108
+ throw new TowerToolError('reviewer-prerequisite', `reviewer requires a completed mission; ${context.mission.id} is ${context.mission.status}`)
1039
1109
  }
1040
1110
  }
1041
1111
  if (role === 'worker' && ['merged', 'abandoned'].includes(context.mission.status)) {
1042
- fail(
1112
+ throw new TowerToolError(
1113
+ 'invalid-transition',
1043
1114
  `mission ${context.mission.id} is ${context.mission.status}; a worker can only be spawned for a planned or active mission`,
1044
1115
  )
1045
1116
  }
@@ -1125,15 +1196,15 @@ export function registerTowerTools(ctx, options = {}) {
1125
1196
  // Service/provider/preset checks happen before provisioning, so a failed
1126
1197
  // precheck cannot leave a worktree or roster revision behind.
1127
1198
  const subagents = ctx.get('subagents')
1128
- if (!subagents) fail('subagents service is unavailable; cannot spawn')
1199
+ if (!subagents) throw new TowerToolError('spawn-unavailable', 'subagents service is unavailable; cannot spawn')
1129
1200
  const providers = subagents.list()
1130
1201
  const provider = config.spawn.provider || (providers.includes('spawn') ? 'spawn' : providers[0])
1131
- if (!provider) fail('no subagent provider is registered')
1202
+ if (!provider) throw new TowerToolError('provider-missing', 'no subagent provider is registered')
1132
1203
  const presets = ctx.get('agentPresets')
1133
1204
  if (presets && typeof presets.list === 'function') {
1134
1205
  const available = await presets.list()
1135
1206
  if (!available.some((entry) => String(entry.id) === preset)) {
1136
- fail('agent preset "' + preset + '" is not registered; check the Tower bundle preset rows and restart DSH')
1207
+ throw new TowerToolError('preset-missing', 'agent preset "' + preset + '" is not registered; check the Tower bundle preset rows and restart DSH')
1137
1208
  }
1138
1209
  }
1139
1210
  if (typeof config.spawn.requireLease === 'string' && config.spawn.requireLease.trim()) {
@@ -1210,6 +1281,14 @@ export function registerTowerTools(ctx, options = {}) {
1210
1281
  let activation = null
1211
1282
  let agentId = null
1212
1283
  let entry = null
1284
+ await tower.reserveRoster({
1285
+ missionId: mission.id,
1286
+ name,
1287
+ role,
1288
+ parentSessionId: String(exec.agent.id),
1289
+ cwd: worktree.path,
1290
+ branch: worktree.branch,
1291
+ })
1213
1292
  try {
1214
1293
  activation = await subagents.startActivation({
1215
1294
  provider,
@@ -1237,16 +1316,24 @@ export function registerTowerTools(ctx, options = {}) {
1237
1316
  takeover,
1238
1317
  })
1239
1318
  } catch (error) {
1240
- // The child may already be running even when recordSpawn failed (for
1241
- // example a race on a roster name): stop it so it cannot keep writing
1242
- // unobserved, then surface the real error.
1319
+ // The child may already be running even when recordSpawn failed: stop
1320
+ // it and mark the reserved entry failed so it cannot keep writing
1321
+ // unobserved.
1243
1322
  if (agentId) {
1244
1323
  try {
1245
- subagents.interrupt(agentId, { kind: 'ancestor', agent: exec.agent })
1324
+ await Promise.resolve(subagents.interrupt(agentId, { kind: 'ancestor', agent: exec.agent }))
1246
1325
  } catch {
1247
1326
  // best effort; the error below is the actionable one
1248
1327
  }
1249
1328
  }
1329
+ await tower
1330
+ .recordSpawnOutcome({
1331
+ missionId: mission.id,
1332
+ name,
1333
+ state: 'failed',
1334
+ detail: `spawn failed${agentId ? ` after activation ${agentId}` : ' before activation'}: ${String(error?.message ?? error).slice(0, 300)}`,
1335
+ })
1336
+ .catch(() => {})
1250
1337
  throw error
1251
1338
  }
1252
1339
 
@@ -1330,7 +1417,7 @@ export function registerTowerTools(ctx, options = {}) {
1330
1417
  const tower = await Tower.open(args.repoRoot)
1331
1418
  const view = await tower.rosterView()
1332
1419
  const subagents = ctx.get('subagents')
1333
- if (!subagents) fail('subagents service is unavailable; cannot observe roster')
1420
+ if (!subagents) throw new TowerToolError('spawn-unavailable', 'subagents service is unavailable; cannot observe roster')
1334
1421
 
1335
1422
  const parents = [...new Set(view.roster.map((entry) => entry.parentSessionId).filter(Boolean))]
1336
1423
  const activity = new Map()
@@ -1444,7 +1531,7 @@ export function registerTowerTools(ctx, options = {}) {
1444
1531
  requireTowerActor(actor, 'adopt the roster')
1445
1532
  const view = await tower.rosterView()
1446
1533
  const subagents = ctx.get('subagents')
1447
- if (!subagents) fail('subagents service is unavailable; cannot adopt')
1534
+ if (!subagents) throw new TowerToolError('spawn-unavailable', 'subagents service is unavailable; cannot adopt')
1448
1535
 
1449
1536
  const parents = [...new Set(view.roster.map((entry) => entry.parentSessionId).filter(Boolean))]
1450
1537
  const activity = new Map()
@@ -1460,11 +1547,19 @@ export function registerTowerTools(ctx, options = {}) {
1460
1547
  }
1461
1548
 
1462
1549
  const retiredCandidates = []
1550
+ const presentIds = []
1551
+ const absentIds = []
1463
1552
  for (const entry of view.roster) {
1464
1553
  if (entry.state === 'retired') continue
1465
1554
  if (parentStatus[entry.parentSessionId] !== 'ok') continue
1466
- if (!activity.has(entry.agentId)) retiredCandidates.push(entry.agentId)
1555
+ if (activity.has(entry.agentId)) {
1556
+ presentIds.push(entry.agentId)
1557
+ } else {
1558
+ absentIds.push(entry.agentId)
1559
+ }
1467
1560
  }
1561
+ const observations = await tower.recordRosterObservations({ present: presentIds, absent: absentIds, confirmations: 2 })
1562
+ retiredCandidates.push(...observations.candidates)
1468
1563
  const retireMissing = args.retireMissing !== false
1469
1564
  const { retired } = retireMissing ? await tower.markRetired(retiredCandidates) : { retired: [] }
1470
1565
  const { missing } = retireMissing ? { missing: [] } : await tower.markMissing(retiredCandidates)
@@ -1472,6 +1567,7 @@ export function registerTowerTools(ctx, options = {}) {
1472
1567
  return {
1473
1568
  retired,
1474
1569
  missing,
1570
+ unconfirmed: absentIds.filter((agentId) => !retiredCandidates.includes(agentId)),
1475
1571
  skippedUnknownParents: Object.entries(parentStatus)
1476
1572
  .filter(([, status]) => status !== 'ok')
1477
1573
  .map(([parent]) => parent),
@@ -1511,11 +1607,33 @@ export function registerTowerTools(ctx, options = {}) {
1511
1607
  const actor = await actorFor(tower, exec)
1512
1608
  const view = await tower.rosterView()
1513
1609
  const from = actor.role === 'tower' ? 'tower' : actor.name
1610
+ if (actor.role === 'unknown') {
1611
+ throw new TowerToolError('not-authorized', 'tower_send requires a registered roster agent or the tower')
1612
+ }
1613
+ if (args.to === 'all' && actor.role !== 'tower') {
1614
+ throw new TowerToolError('not-authorized', 'only the tower may broadcast to "all"')
1615
+ }
1616
+ const sendConfig = tower.config.send ?? {}
1617
+ const interrupt =
1618
+ args.interrupt === undefined ? sendConfig.interruptDefault === true : args.interrupt === true
1619
+ if (interrupt && actor.role !== 'tower') {
1620
+ throw new TowerToolError('not-authorized', 'only the tower may interrupt a recipient with tower_send')
1621
+ }
1622
+ if (actor.role !== 'tower') {
1623
+ const self = view.roster.find((entry) => entry.agentId === actor.agentId)
1624
+ const target = view.roster.find((entry) => entry.name === args.to)
1625
+ const allowed =
1626
+ args.to === 'tower' ||
1627
+ (self && target && target.agentId !== self.agentId && target.missionId === self.missionId)
1628
+ if (!allowed) {
1629
+ throw new TowerToolError('not-authorized', `${actor.name} may only message the tower or agents on the same mission`)
1630
+ }
1631
+ }
1514
1632
  if (typeof args.subject !== 'string' || args.subject.trim().length === 0) {
1515
- fail('tower_send requires a subject')
1633
+ throw new TowerToolError('invalid-argument', 'tower_send requires a subject')
1516
1634
  }
1517
1635
  if (typeof args.body !== 'string' || args.body.trim().length === 0) {
1518
- fail('tower_send requires a body')
1636
+ throw new TowerToolError('invalid-argument', 'tower_send requires a body')
1519
1637
  }
1520
1638
  const message = await tower.recordMessage({
1521
1639
  from,
@@ -1542,9 +1660,6 @@ export function registerTowerTools(ctx, options = {}) {
1542
1660
  // an unreadable parent catalog only means "do not wake it"
1543
1661
  }
1544
1662
  }
1545
- const sendConfig = tower.config.send ?? {}
1546
- const interrupt =
1547
- args.interrupt === undefined ? sendConfig.interruptDefault === true : args.interrupt === true
1548
1663
  const targets =
1549
1664
  args.to === 'all' ? roster.map((entry) => entry.name) : [args.to]
1550
1665
  for (const targetName of targets) {
@@ -1566,6 +1681,10 @@ export function registerTowerTools(ctx, options = {}) {
1566
1681
  try {
1567
1682
  subagents.interrupt(entry.agentId, { kind: 'ancestor', agent: exec.agent })
1568
1683
  interrupted = true
1684
+ await tower.recordInterrupt({
1685
+ agentId: entry.agentId,
1686
+ reason: `tower_send${message?.id ? ` ${message.id}` : ''}: ${args.subject.trim().slice(0, 120)}`,
1687
+ })
1569
1688
  } catch (error) {
1570
1689
  results.push({ target: targetName, pushed: false, reason: `interrupt failed: ${String(error?.message ?? error)}` })
1571
1690
  continue
@@ -1626,7 +1745,7 @@ export function registerTowerTools(ctx, options = {}) {
1626
1745
  define(ctx, {
1627
1746
  name: 'tower_complete',
1628
1747
  description:
1629
- 'Worker-only: report mission completion. Enforced by the store: every task done or dropped, no blockers, the current owner, the mission branch has at least one commit since its spawn base, and no unread inbox messages. checks records verifiable evidence (name/command/exitCode/evidence); gates.requireChecks can require at least one before merge. Stores the full report; completion means awaiting review, not merged.',
1748
+ 'Worker-only: report mission completion. Enforced by the store: every task done or dropped, no blockers, the current owner, the mission branch has at least one commit since its spawn base, and no unread inbox messages. checks records verifiable evidence (name/command/exitCode/evidence); gates.requireChecks can require at least one before merge. Stores the full report; completion means ready for preflight and optional review, not merged.',
1630
1749
  parameters: {
1631
1750
  type: 'object',
1632
1751
  properties: {
@@ -1714,6 +1833,8 @@ export function registerTowerTools(ctx, options = {}) {
1714
1833
  output: output(SCHEMAS.finding),
1715
1834
  async execute(args, exec) {
1716
1835
  const tower = await Tower.open(args.repoRoot)
1836
+ const actor = await actorFor(tower, exec)
1837
+ requireKnownActor(actor, 'tower_finding')
1717
1838
  const view = await tower.rosterView()
1718
1839
  const self = view.roster.find((entry) => entry.agentId === String(exec.agent.id))
1719
1840
  return tower.addFinding({
@@ -1759,6 +1880,8 @@ export function registerTowerTools(ctx, options = {}) {
1759
1880
  output: output(SCHEMAS.findings),
1760
1881
  async execute(args, exec) {
1761
1882
  const tower = await Tower.open(args.repoRoot)
1883
+ const actor = await actorFor(tower, exec)
1884
+ requireKnownActor(actor, 'tower_findings')
1762
1885
  const view = await tower.rosterView()
1763
1886
  const self = view.roster.find((entry) => entry.agentId === String(exec.agent.id))
1764
1887
  return tower.findings({
@@ -1801,9 +1924,17 @@ export function registerTowerTools(ctx, options = {}) {
1801
1924
  output: output(SCHEMAS.review),
1802
1925
  async execute(args, exec) {
1803
1926
  const tower = await Tower.open(args.repoRoot)
1927
+ const actor = await actorFor(tower, exec)
1928
+ if (!['reviewer', 'tower'].includes(actor.role)) {
1929
+ throw new TowerToolError('not-authorized', `only an assigned reviewer or the tower may review; ${actor.name} is ${actor.role}`)
1930
+ }
1931
+ if (actor.role === 'tower' && (typeof args.breakGlassReason !== 'string' || args.breakGlassReason.trim().length === 0)) {
1932
+ throw new TowerToolError('not-authorized', 'a tower-submitted review is break-glass and requires a non-empty breakGlassReason')
1933
+ }
1804
1934
  return tower.submitReview({
1805
1935
  missionId: args.missionId,
1806
1936
  agentId: String(exec.agent.id),
1937
+ actor,
1807
1938
  status: args.status,
1808
1939
  merge: args.merge,
1809
1940
  findings: args.findings,
@@ -1817,7 +1948,7 @@ export function registerTowerTools(ctx, options = {}) {
1817
1948
  define(ctx, {
1818
1949
  name: 'tower_preflight',
1819
1950
  description:
1820
- 'Read-only merge gate for one mission: returns ok plus stable blocker codes (branch-missing, deps-unmerged, read-only-survey, stale-base, no-review, review-hold, not-clean, review-not-approved, tip-moved, review-mission-mismatch, out-of-scope, base-dirty, branch-owned-by-closed-missions, and mission/worker guards). Never changes anything.',
1951
+ 'Read-only merge gate for one mission: returns ok plus stable blocker codes (branch-missing, deps-unmerged, read-only-survey, stale-base, out-of-scope, base-dirty, branch-owned-by-closed-missions, and mission/worker guards). Review blockers (no-review, review-hold, not-clean, review-not-approved, tip-moved, review-mission-mismatch) apply only when review.requireReview is on. Never changes anything.',
1821
1952
  parameters: {
1822
1953
  type: 'object',
1823
1954
  properties: {
@@ -1963,10 +2094,72 @@ export function registerTowerTools(ctx, options = {}) {
1963
2094
  additionalProperties: false,
1964
2095
  },
1965
2096
  output: output(SCHEMAS.doctor),
2097
+ async execute(args, exec) {
2098
+ let tower
2099
+ let configError = null
2100
+ try {
2101
+ tower = await Tower.open(args.repoRoot)
2102
+ } catch (error) {
2103
+ const message = String(error?.message ?? error)
2104
+ if (!/config-(invalid|unknown-key)/.test(message) && error?.code !== 'config-invalid' && error?.code !== 'config-unknown-key') throw error
2105
+ configError = message
2106
+ const repo = await resolveRepo(args.repoRoot)
2107
+ tower = new Tower(repo, { ...runtimeConfig(), configPath: null, repoConfigPresent: true })
2108
+ }
2109
+ const report = await runDoctor({ tower, ctx, agent: exec.agent })
2110
+ if (!configError) return report
2111
+ return {
2112
+ ...report,
2113
+ ok: false,
2114
+ checks: [
2115
+ {
2116
+ id: 'config',
2117
+ status: 'fail',
2118
+ detail: configError,
2119
+ fix: 'repair <git-common-dir>/dsh-tower/config.json and re-run tower_doctor',
2120
+ },
2121
+ ...(report.checks ?? []),
2122
+ ],
2123
+ }
2124
+ },
2125
+ })
2126
+
2127
+ define(ctx, {
2128
+ name: 'tower_recover',
2129
+ description:
2130
+ 'Read-only recovery diagnostics and an explicit stale-state-lock repair. status reports the state lock owner; clear-stale-lock requires confirm:"CLEAR_STALE_LOCK" and only moves a lock whose PID is provably dead to a backup file. It never resets Tower state or worktrees.',
2131
+ parameters: {
2132
+ type: 'object',
2133
+ properties: {
2134
+ repoRoot: { type: 'string', description: 'Absolute path inside the target Git repository.' },
2135
+ action: { type: 'string', enum: ['status', 'clear-stale-lock'], description: 'Recovery action.' },
2136
+ confirm: { type: 'string', description: 'Required literal CLEAR_STALE_LOCK for clear-stale-lock.' },
2137
+ },
2138
+ required: ['repoRoot', 'action'],
2139
+ additionalProperties: false,
2140
+ },
2141
+ output: output(SCHEMAS.recover),
1966
2142
  async execute(args, exec) {
1967
2143
  const tower = await Tower.open(args.repoRoot)
1968
- await actorFor(tower, exec)
1969
- return runDoctor({ tower, ctx, agent: exec.agent })
2144
+ const actor = await actorFor(tower, exec)
2145
+ requireTowerActor(actor, 'recover Tower state')
2146
+ const lock = await readStateLock(tower.paths)
2147
+ const publicLock = lock.record
2148
+ ? { pid: lock.record.pid ?? null, startedAt: lock.record.startedAt ?? null, at: lock.record.at ?? null, alive: lock.alive }
2149
+ : null
2150
+ if (args.action === 'status') {
2151
+ return { ok: true, action: 'status', stateLock: publicLock, cleared: false }
2152
+ }
2153
+ if (args.confirm !== 'CLEAR_STALE_LOCK') {
2154
+ throw new TowerToolError('invalid-argument', 'clear-stale-lock requires confirm:"CLEAR_STALE_LOCK"')
2155
+ }
2156
+ if (!lock.record) return { ok: true, action: 'clear-stale-lock', stateLock: null, cleared: false }
2157
+ if (lock.alive !== false) {
2158
+ throw new TowerToolError('state-locked', `state lock pid ${lock.record.pid ?? 'unknown'} is still alive; refusing to clear it`)
2159
+ }
2160
+ const backup = `${tower.paths.lock}.recovered-${Date.now()}`
2161
+ await fsp.rename(tower.paths.lock, backup)
2162
+ return { ok: true, action: 'clear-stale-lock', stateLock: publicLock, cleared: true, backup }
1970
2163
  },
1971
2164
  })
1972
2165
 
@@ -1990,11 +2183,12 @@ export function registerTowerTools(ctx, options = {}) {
1990
2183
  async execute(args, exec) {
1991
2184
  const tower = await Tower.open(args.repoRoot)
1992
2185
  const actor = await actorFor(tower, exec)
2186
+ requireKnownActor(actor, 'tower_test')
1993
2187
  const context = await tower.missionContext(args.missionId)
1994
- if (!context.mission.worktree) fail(`mission ${args.missionId} has no worktree`)
1995
- if (actor.role === 'reviewer') fail('reviewers may not run test discovery')
2188
+ if (!context.mission.worktree) throw new TowerToolError('invalid-transition', `mission ${args.missionId} has no worktree`)
2189
+ if (actor.role === 'reviewer') throw new TowerToolError('not-authorized', 'reviewers may not run test discovery')
1996
2190
  if (actor.role === 'worker' && context.mission.owner && context.mission.owner !== actor.name) {
1997
- fail(`worker ${actor.name} does not own mission ${args.missionId}`)
2191
+ throw new TowerToolError('not-authorized', `worker ${actor.name} does not own mission ${args.missionId}`)
1998
2192
  }
1999
2193
  await tower.assertWorktree(context.mission.worktree)
2000
2194
  const discovery = await runMissionDiscovery(tower, context.mission)
@@ -2053,7 +2247,7 @@ export function registerTowerTools(ctx, options = {}) {
2053
2247
  const batch = args.batchId
2054
2248
  ? (state.batches ?? []).find((entry) => entry.id === args.batchId)
2055
2249
  : null
2056
- if (args.batchId && !batch) fail(`unknown batch "${args.batchId}"`)
2250
+ if (args.batchId && !batch) throw new TowerToolError('invalid-argument', `unknown batch "${args.batchId}"`)
2057
2251
  return {
2058
2252
  ok: true,
2059
2253
  action: 'status',
@@ -2068,12 +2262,20 @@ export function registerTowerTools(ctx, options = {}) {
2068
2262
  return { ok: true, action: 'create', batch, batches: (await tower.batchStatus()).batches }
2069
2263
  }
2070
2264
  if (typeof args.batchId !== 'string' || args.batchId.trim().length === 0) {
2071
- fail(`tower_batch ${args.action} requires batchId`)
2265
+ throw new TowerToolError('invalid-argument', `tower_batch ${args.action} requires batchId`)
2072
2266
  }
2073
2267
  if (args.action === 'abandon') {
2074
2268
  const result = await tower.abandonBatch(args.batchId.trim(), actor.name, args.reason)
2075
2269
  return { ok: true, action: 'abandon', batch: result.batch, blockers: [] }
2076
2270
  }
2271
+ const closeState = await tower.load()
2272
+ const closeBatch = (closeState.batches ?? []).find((entry) => entry.id === args.batchId.trim())
2273
+ if (closeBatch) {
2274
+ const receiptProblems = await verifyBatchReceiptFiles(tower.root, closeBatch, args)
2275
+ if (receiptProblems.length > 0) {
2276
+ return { ok: false, action: 'close', batch: closeBatch, blockers: receiptProblems, checks: [] }
2277
+ }
2278
+ }
2077
2279
  const result = await tower.closeBatch(args.batchId.trim(), {
2078
2280
  checks: args.checks,
2079
2281
  treeTip: args.treeTip,
@@ -2116,12 +2318,16 @@ export function registerTowerTools(ctx, options = {}) {
2116
2318
  async execute(args, exec) {
2117
2319
  const tower = await Tower.open(args.repoRoot)
2118
2320
  const actor = await actorFor(tower, exec)
2321
+ const redact = (leases) => leases.map(({ token, ...lease }) => lease)
2119
2322
  if (args.action === 'status') {
2120
2323
  const info = await inspectLeases(tower.commonDir)
2121
- return { ok: true, action: 'status', leases: info.leases, ...info }
2324
+ const leases = redact(info.leases ?? [])
2325
+ return { ok: true, action: 'status', ...info, leases }
2122
2326
  }
2123
2327
  requireTowerActor(actor, args.action === 'acquire' ? 'acquire a lease' : 'release a lease')
2124
- if (typeof args.name !== 'string' || args.name.trim().length === 0) fail('tower_resource requires name')
2328
+ if (typeof args.name !== 'string' || args.name.trim().length === 0) {
2329
+ throw new TowerToolError('invalid-argument', 'tower_resource requires name')
2330
+ }
2125
2331
  const name = args.name.trim()
2126
2332
  const ttlMs = Number(args.ttlMs ?? tower.config.leases?.ttlMs ?? 900000)
2127
2333
  if (args.action === 'acquire') {
@@ -2135,9 +2341,11 @@ export function registerTowerTools(ctx, options = {}) {
2135
2341
  return { ok: true, action: 'acquire', lease }
2136
2342
  }
2137
2343
  const token = args.token
2138
- if (typeof token !== 'string' || token.length === 0) fail('tower_resource release requires token')
2344
+ if (typeof token !== 'string' || token.length === 0) {
2345
+ throw new TowerToolError('invalid-argument', 'tower_resource release requires token')
2346
+ }
2139
2347
  const released = await releaseLease(tower.commonDir, { name, token, force: args.force === true })
2140
- return { ok: true, action: 'release', released, leases: (await inspectLeases(tower.commonDir)).leases }
2348
+ return { ok: true, action: 'release', released, leases: redact((await inspectLeases(tower.commonDir)).leases) }
2141
2349
  },
2142
2350
  })
2143
2351
 
@@ -2196,7 +2404,9 @@ export function registerTowerTools(ctx, options = {}) {
2196
2404
  }
2197
2405
  requireTowerActor(actor, 'map a legacy mission')
2198
2406
  const validation = validateLegacyMap(state, args)
2199
- if (validation.ok !== true) fail(validation.error ?? 'invalid legacy mapping')
2407
+ if (validation.ok !== true) {
2408
+ throw new TowerToolError('invalid-argument', validation.error ?? 'invalid legacy mapping')
2409
+ }
2200
2410
  const map = { ...validation.map, by: actor.name, at: Date.now() }
2201
2411
  const recorded = await tower.recordLegacyMap(map)
2202
2412
  return { ok: true, map: recorded.map, maps: [...(state.legacyMaps ?? []), recorded.map] }