dsh-tower 1.3.0 → 2.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/CHANGELOG.md +109 -0
  2. package/CONTRIBUTING.md +1 -1
  3. package/README.en.md +44 -8
  4. package/README.md +42 -8
  5. package/cordis.patch.yml +11 -1
  6. package/docs/README.md +10 -5
  7. package/docs/architecture/overview.md +13 -9
  8. package/docs/faq.md +45 -2
  9. package/docs/guide/hot-reload.md +189 -0
  10. package/docs/guide/install-upgrade-migration.md +11 -0
  11. package/docs/guide/quickstart.md +33 -1
  12. package/docs/guide/scenarios-and-philosophy.md +309 -0
  13. package/docs/operations/runbook.md +23 -0
  14. package/docs/operations/security.md +23 -0
  15. package/docs/reference/blockers.md +10 -2
  16. package/docs/reference/config.md +65 -2
  17. package/docs/reference/doctor.md +33 -0
  18. package/docs/reference/errors.md +50 -0
  19. package/docs/reference/events.md +45 -0
  20. package/docs/reference/operations.md +55 -0
  21. package/docs/reference/testing.md +61 -0
  22. package/docs/reference/tools.md +65 -23
  23. package/docs/zh/README.md +60 -0
  24. package/docs/zh/faq.md +81 -0
  25. package/docs/zh/guide/hot-reload.md +178 -0
  26. package/docs/zh/guide/install-upgrade-migration.md +57 -0
  27. package/docs/zh/guide/quickstart.md +96 -0
  28. package/docs/zh/guide/scenarios-and-philosophy.md +211 -0
  29. package/docs/zh/operations/security.md +42 -0
  30. package/docs/zh/reference/blockers.md +62 -0
  31. package/docs/zh/reference/config.md +113 -0
  32. package/docs/zh/reference/doctor.md +31 -0
  33. package/docs/zh/reference/errors.md +52 -0
  34. package/docs/zh/reference/events.md +46 -0
  35. package/docs/zh/reference/operations.md +50 -0
  36. package/docs/zh/reference/testing.md +60 -0
  37. package/docs/zh/reference/tools.md +75 -0
  38. package/index.js +261 -13
  39. package/lib/batch.js +578 -0
  40. package/lib/config.js +275 -9
  41. package/lib/doctor.js +171 -0
  42. package/lib/errors.js +111 -0
  43. package/lib/hmr.js +196 -0
  44. package/lib/leases.js +317 -0
  45. package/lib/legacy.js +413 -0
  46. package/lib/modeStore.js +28 -1
  47. package/lib/store.js +24 -1
  48. package/lib/tests.js +628 -0
  49. package/lib/tools.js +1566 -184
  50. package/lib/tower.js +986 -103
  51. package/lib/workspaceLock.js +117 -0
  52. package/lib/worktreeOwnership.js +166 -0
  53. package/package.json +4 -1
  54. package/reviewer.js +1 -0
  55. package/scripts/check-docs.mjs +135 -0
  56. package/scripts/enable-dev-hmr.mjs +347 -0
  57. package/skills/tower-review.md +4 -2
  58. package/skills/tower-worker-protocol.md +3 -1
  59. package/worker.js +4 -2
package/lib/tower.js CHANGED
@@ -17,18 +17,21 @@ import {
17
17
  isAncestor,
18
18
  tryGit,
19
19
  } from './git.js'
20
+ import { validateCreate as validateBatchCreate, validateClose as validateBatchClose, summarize as summarizeBatch } from './batch.js'
21
+ import { assertOwnership, canonicalPath } from './worktreeOwnership.js'
20
22
  import {
21
23
  pathsFor,
22
24
  exists,
23
25
  ensureDir,
24
26
  readJson,
25
27
  writeJsonAtomic,
28
+ renameWithRetry,
26
29
  appendJsonl,
27
30
  readActivityTail,
28
31
  withLock,
29
32
  shortHash,
30
33
  } from './store.js'
31
- import { runtimeConfig } from './config.js'
34
+ import { runtimeConfig, resolveRepoConfig } from './config.js'
32
35
 
33
36
  export const SCHEMA_VERSION = 1
34
37
  export const MISSION_STATUSES = [
@@ -41,6 +44,14 @@ export const MISSION_STATUSES = [
41
44
  'abandoned',
42
45
  ]
43
46
  const TERMINAL = new Set(['merged', 'abandoned'])
47
+ /** Roster states that no longer represent a live writer. `inactive` is an idle
48
+ * child whose turn finished: it must not block a new owner or consume a worker
49
+ * slot. `unknown` is deliberately NOT included because it cannot be proven
50
+ * stopped. */
51
+ export const ROSTER_INACTIVE_STATES = ['retired', 'failed', 'interrupted', 'missing', 'stale', 'inactive']
52
+ const ROSTER_INACTIVE = new Set(ROSTER_INACTIVE_STATES)
53
+ const SNAPSHOT_REF_PREFIX = 'refs/tower/snapshot/'
54
+ const PAUSE_CAUSES = ['user', 'failure', 'resource', 'dependency']
44
55
  const TRANSITIONS = {
45
56
  planned: ['active', 'abandoned'],
46
57
  active: ['completed', 'blocked', 'paused', 'abandoned'],
@@ -63,15 +74,22 @@ function slugify(title) {
63
74
 
64
75
  export const FINDING_TYPES = ['bug', 'improve', 'vuln', 'idea']
65
76
  export const FINDING_SEVERITIES = ['low', 'medium', 'high', 'critical']
77
+ export const FINDING_STATUSES = ['open', 'triaged', 'fixed', 'wontfix']
78
+ export const FINDING_TRANSITIONS = {
79
+ open: ['triaged', 'fixed', 'wontfix'],
80
+ triaged: ['open', 'fixed', 'wontfix'],
81
+ fixed: ['open'],
82
+ wontfix: ['open'],
83
+ }
66
84
  export const MERGE_VERDICTS = ['merge', 'fix-then-merge', 'hold']
67
85
  const MAX_REVIEW_ROUNDS = 5
68
86
  const REVIEW_STATUS_RE = /^(clean|p1-\d+items|p2-\d+items)$/
69
87
  const DEFAULT_MERGE_TIMEOUT_MS = 15 * 60 * 1000
70
88
  const MERGE_MAX_BUFFER = 64 * 1024 * 1024
71
89
 
72
- function mergeTimeoutMs(value) {
90
+ function mergeTimeoutMs(value, config) {
73
91
  if (value === undefined || value === null) {
74
- const configured = runtimeConfig().merge.timeoutMs
92
+ const configured = (config ?? runtimeConfig()).merge.timeoutMs
75
93
  return Number.isInteger(configured) && configured >= 1000 ? configured : DEFAULT_MERGE_TIMEOUT_MS
76
94
  }
77
95
  const ms = Number(value)
@@ -284,7 +302,26 @@ function normalizeMissionInput(input, index) {
284
302
  }
285
303
  }
286
304
  const context = typeof input.context === 'string' ? input.context.slice(0, 4096) : ''
287
- return { title, kind, scope, tasks, deps, context }
305
+ const references = []
306
+ if (input.references !== undefined) {
307
+ if (!Array.isArray(input.references)) fail(`missions[${index}].references must be an array of pointers`)
308
+ if (input.references.length > 20) fail(`missions[${index}].references supports at most 20 pointers`)
309
+ for (const reference of input.references) {
310
+ if (typeof reference !== 'string' || reference.trim().length === 0) {
311
+ fail(`missions[${index}].references entries must be non-empty strings`)
312
+ }
313
+ if (!references.includes(reference.trim())) references.push(reference.trim())
314
+ }
315
+ }
316
+ const reviewPolicy = input.reviewPolicy === undefined ? 'required-clean' : input.reviewPolicy
317
+ if (!['required-clean', 'none'].includes(reviewPolicy)) {
318
+ fail(`missions[${index}].reviewPolicy must be "required-clean" or "none"`)
319
+ }
320
+ const reviewExemptionReason = typeof input.reviewExemptionReason === 'string' ? input.reviewExemptionReason.trim() : ''
321
+ if (reviewPolicy === 'none' && reviewExemptionReason.length === 0) {
322
+ fail(`missions[${index}].reviewPolicy "none" requires a non-empty reviewExemptionReason`)
323
+ }
324
+ return { title, kind, scope, tasks, deps, context, references, reviewPolicy, reviewExemptionReason }
288
325
  }
289
326
 
290
327
  function newMission(id, input, now) {
@@ -296,6 +333,9 @@ function newMission(id, input, now) {
296
333
  tasks: input.tasks,
297
334
  deps: input.deps,
298
335
  context: input.context,
336
+ references: input.references ?? [],
337
+ reviewPolicy: input.reviewPolicy ?? 'required-clean',
338
+ reviewExemptionReason: input.reviewExemptionReason ?? '',
299
339
  status: 'planned',
300
340
  owner: null,
301
341
  blockers: [],
@@ -324,6 +364,24 @@ export function validateState(state, statePath) {
324
364
  }
325
365
  if (!Array.isArray(mission.tasks)) fail(`mission ${mission.id} has no tasks array${where}`)
326
366
  if (!Array.isArray(mission.deps)) fail(`mission ${mission.id} has no deps array${where}`)
367
+ if (mission.references !== undefined && !Array.isArray(mission.references)) {
368
+ fail(`mission ${mission.id} has no references array${where}`)
369
+ }
370
+ if (mission.reviewPolicy !== undefined && !['required-clean', 'none'].includes(mission.reviewPolicy)) {
371
+ fail(`mission ${mission.id} has unknown reviewPolicy ${mission.reviewPolicy}${where}`)
372
+ }
373
+ if (mission.reviewPolicy === 'none' && !String(mission.reviewExemptionReason ?? '').trim()) {
374
+ fail(`mission ${mission.id} has reviewPolicy none without a reason${where}`)
375
+ }
376
+ if (mission.testRuns !== undefined && !Array.isArray(mission.testRuns)) {
377
+ fail(`mission ${mission.id} has no testRuns array${where}`)
378
+ }
379
+ }
380
+ for (const key of ['batches', 'legacySnapshots', 'legacyMaps', 'deliveries']) {
381
+ if (state[key] !== undefined && !Array.isArray(state[key])) fail(`tower state ${key} is not an array${where}`)
382
+ }
383
+ if (state.mergeIntent !== undefined && state.mergeIntent !== null && !isPlainObject(state.mergeIntent)) {
384
+ fail(`tower state mergeIntent is not an object${where}`)
327
385
  }
328
386
  if (state.roster !== undefined) {
329
387
  if (!Array.isArray(state.roster)) fail(`tower state roster is not an array${where}`)
@@ -358,6 +416,9 @@ export function validateState(state, statePath) {
358
416
  if (!finding || typeof finding.id !== 'string') fail(`invalid finding entry${where}`)
359
417
  if (ids.has(finding.id)) fail(`duplicate finding id ${finding.id}${where}`)
360
418
  ids.add(finding.id)
419
+ if (finding.status !== undefined && !FINDING_STATUSES.includes(finding.status)) {
420
+ fail(`finding ${finding.id} has unknown status ${finding.status}${where}`)
421
+ }
361
422
  }
362
423
  }
363
424
  if (state.reviews !== undefined) {
@@ -407,6 +468,9 @@ export function missionSummary(mission) {
407
468
  blockers: mission.blockers ?? [],
408
469
  tasks: taskCounts(mission.tasks ?? []),
409
470
  scopeGeneration: mission.scopeGeneration ?? 1,
471
+ ownerGeneration: mission.ownerGeneration ?? 0,
472
+ pauseCause: mission.pauseCause ?? null,
473
+ reviewPolicy: mission.reviewPolicy ?? 'required-clean',
410
474
  mergedCommit: mission.mergedCommit ?? null,
411
475
  mergedAt: mission.mergedAt ?? null,
412
476
  updatedAt: mission.updatedAt,
@@ -418,20 +482,96 @@ function fullMission(mission) {
418
482
  ...missionSummary(mission),
419
483
  tasks: (mission.tasks ?? []).map((task) => ({ ...task })),
420
484
  notes: mission.notes ?? [],
485
+ completionReport: mission.completionReport ?? null,
486
+ authorizationRef: mission.authorizationRef ?? null,
487
+ references: mission.references ?? [],
488
+ reviewPolicy: mission.reviewPolicy ?? 'required-clean',
489
+ reviewExemptionReason: mission.reviewExemptionReason ?? '',
490
+ testRuns: mission.testRuns ?? [],
421
491
  }
422
492
  }
423
493
 
494
+ function findingSummary(finding) {
495
+ return {
496
+ id: finding.id,
497
+ seq: finding.seq ?? null,
498
+ type: finding.type,
499
+ title: finding.title,
500
+ severity: finding.severity ?? null,
501
+ status: finding.status ?? 'open',
502
+ missionId: finding.missionId ?? null,
503
+ branch: finding.branch ?? null,
504
+ reportedBy: finding.reportedBy ?? null,
505
+ at: finding.at ?? null,
506
+ updatedAt: finding.updatedAt ?? null,
507
+ closedAt: finding.closedAt ?? null,
508
+ }
509
+ }
510
+
511
+ function countFindings(findings) {
512
+ const counts = {}
513
+ for (const status of FINDING_STATUSES) counts[status] = 0
514
+ for (const finding of findings) {
515
+ const status = finding.status ?? 'open'
516
+ counts[status] = (counts[status] ?? 0) + 1
517
+ }
518
+ return counts
519
+ }
520
+
521
+ function liveWorkerEntry(state, missionId) {
522
+ return (state.roster ?? []).find(
523
+ (entry) => entry.missionId === missionId && entry.role === 'worker' && !ROSTER_INACTIVE.has(entry.state),
524
+ )
525
+ }
526
+
527
+ function workerEntries(state, missionId) {
528
+ return (state.roster ?? []).filter((entry) => entry.missionId === missionId && entry.role === 'worker')
529
+ }
530
+
531
+ function countLiveWorkers(state) {
532
+ return (state.roster ?? []).filter((entry) => entry.role === 'worker' && !ROSTER_INACTIVE.has(entry.state)).length
533
+ }
534
+
535
+ function normalizePauseCause(value) {
536
+ if (value === undefined || value === null || value === '') return 'user'
537
+ if (!PAUSE_CAUSES.includes(value)) fail(`pause_cause must be one of ${PAUSE_CAUSES.join('|')}`)
538
+ return value
539
+ }
540
+
541
+ function normalizeChecks(checks) {
542
+ if (checks === undefined) return []
543
+ if (!Array.isArray(checks)) fail('checks must be an array')
544
+ if (checks.length > 20) fail('checks may contain at most 20 entries')
545
+ return checks.map((check, index) => {
546
+ if (!isPlainObject(check)) fail(`checks[${index}] must be an object`)
547
+ const name = typeof check.name === 'string' ? check.name.trim() : ''
548
+ if (!name) fail(`checks[${index}].name must be a non-empty string`)
549
+ const entry = { name }
550
+ if (typeof check.command === 'string' && check.command.trim()) entry.command = check.command.trim()
551
+ if (check.exitCode !== undefined && check.exitCode !== null) {
552
+ if (!Number.isInteger(check.exitCode)) fail(`checks[${index}].exitCode must be an integer`)
553
+ entry.exitCode = check.exitCode
554
+ }
555
+ if (typeof check.evidence === 'string' && check.evidence.trim()) entry.evidence = check.evidence.trim().slice(0, 4000)
556
+ return entry
557
+ })
558
+ }
559
+
424
560
  export class Tower {
425
- constructor(repo) {
561
+ constructor(repo, config) {
426
562
  this.root = repo.root
427
563
  this.commonDir = repo.commonDir
428
564
  this.branch = repo.branch
429
565
  this.paths = pathsFor(repo.commonDir)
566
+ // Effective config for this repository: code defaults < profile config <
567
+ // repo config file. Per-call tool arguments are applied by the caller.
568
+ this.config = config ?? { ...runtimeConfig(), configPath: null, repoConfigPresent: false }
430
569
  }
431
570
 
432
571
  static async open(repoRoot) {
433
572
  const repo = await resolveRepo(repoRoot)
434
- return new Tower(repo)
573
+ const config = await resolveRepoConfig(repo)
574
+ return new Tower(repo, config)
435
575
  }
436
576
 
437
577
  async load() {
@@ -445,8 +585,52 @@ export class Tower {
445
585
  async init(input = {}) {
446
586
  await ensureDir(this.paths.dir)
447
587
  return withLock(this.paths, async () => {
588
+ const now = Date.now()
589
+ const dirty = !(await isClean(this.root))
590
+ const committed = await hasAnyCommit(this.root)
591
+ const resolveBase = async () => {
592
+ let base = typeof input.base === 'string' && input.base.trim() ? input.base.trim() : null
593
+ if (base) {
594
+ if (/^refs\//.test(base) || base.startsWith('origin/')) {
595
+ fail(`base must be a local branch name, not ${base}`)
596
+ }
597
+ if (committed && !(await localBranchExists(this.root, base))) {
598
+ fail(`base branch "${base}" does not exist locally`)
599
+ }
600
+ return base
601
+ }
602
+ base = this.branch
603
+ if (!base && !committed) base = 'main'
604
+ if (!base) fail('repository is in detached HEAD; pass an explicit local base branch')
605
+ return base
606
+ }
607
+
448
608
  if (await exists(this.paths.state)) {
449
609
  const state = await this.load()
610
+ let reanchored = false
611
+ if (input.reanchor === true) {
612
+ const active = state.missions.filter((mission) => !TERMINAL.has(mission.status))
613
+ if (active.length > 0) {
614
+ fail(`cannot reanchor base while ${active.length} mission(s) are not merged or abandoned`)
615
+ }
616
+ const base = await resolveBase()
617
+ if (base !== state.base) {
618
+ const refs = (await tryGit(this.root, ['for-each-ref', '--format=%(refname)', SNAPSHOT_REF_PREFIX])) ?? ''
619
+ for (const ref of refs.split('\n').map((line) => line.trim()).filter(Boolean)) {
620
+ await git(this.root, ['update-ref', '-d', ref]).catch(() => {})
621
+ }
622
+ state.base = base
623
+ state.baseChangedAt = now
624
+ delete state.snapshotCommit
625
+ delete state.snapshotRef
626
+ reanchored = true
627
+ state.revision += 1
628
+ state.updatedAt = now
629
+ validateState(state, this.paths.state)
630
+ await writeJsonAtomic(this.paths.state, state)
631
+ await appendJsonl(this.paths.activity, { at: now, type: 'init', text: `base reanchored to ${base}` })
632
+ }
633
+ }
450
634
  return {
451
635
  created: false,
452
636
  repoRoot: state.repoRoot,
@@ -455,23 +639,24 @@ export class Tower {
455
639
  base: state.base,
456
640
  revision: state.revision,
457
641
  missions: state.missions.length,
642
+ reanchored,
643
+ dirty,
644
+ snapshotCommit: state.snapshotCommit ?? null,
458
645
  }
459
646
  }
460
647
 
461
- const now = Date.now()
462
- const committed = await hasAnyCommit(this.root)
463
- let base = typeof input.base === 'string' && input.base.trim() ? input.base.trim() : null
464
- if (base) {
465
- if (/^refs\//.test(base) || base.startsWith('origin/')) {
466
- fail(`base must be a local branch name, not ${base}`)
467
- }
468
- if (committed && !(await localBranchExists(this.root, base))) {
469
- fail(`base branch "${base}" does not exist locally`)
648
+ const base = await resolveBase()
649
+ let snapshotCommit = null
650
+ let snapshotRef = null
651
+ if (input.snapshotDirty === true && dirty) {
652
+ snapshotCommit = ((await tryGit(this.root, ['stash', 'create'])) ?? '').trim() || null
653
+ if (!snapshotCommit) {
654
+ fail('snapshotDirty was requested but no snapshot commit could be created; commit or stash the working tree first')
470
655
  }
471
- } else {
472
- base = this.branch
473
- if (!base && !committed) base = 'main'
474
- if (!base) fail('repository is in detached HEAD; pass an explicit local base branch')
656
+ // A dangling commit can be pruned by git gc; keep a ref so the mission
657
+ // baseline stays reachable for the life of the state.
658
+ snapshotRef = `${SNAPSHOT_REF_PREFIX}${snapshotCommit}`
659
+ await git(this.root, ['update-ref', snapshotRef, snapshotCommit])
475
660
  }
476
661
 
477
662
  const state = {
@@ -482,11 +667,17 @@ export class Tower {
482
667
  revision: 0,
483
668
  createdAt: now,
484
669
  updatedAt: now,
670
+ dirtyBase: dirty,
671
+ ...(snapshotCommit ? { snapshotCommit, snapshotRef } : {}),
485
672
  missions: [],
486
673
  }
487
674
  validateState(state, this.paths.state)
488
675
  await writeJsonAtomic(this.paths.state, state)
489
- await appendJsonl(this.paths.activity, { at: now, type: 'init', text: `base ${base}` })
676
+ await appendJsonl(this.paths.activity, {
677
+ at: now,
678
+ type: 'init',
679
+ text: `base ${base}${snapshotCommit ? ` snapshot ${snapshotCommit.slice(0, 12)}` : ''}${dirty ? ' (dirty base)' : ''}`,
680
+ })
490
681
  return {
491
682
  created: true,
492
683
  repoRoot: this.root,
@@ -495,6 +686,10 @@ export class Tower {
495
686
  base,
496
687
  revision: 0,
497
688
  missions: 0,
689
+ reanchored: false,
690
+ dirty,
691
+ snapshotCommit,
692
+ snapshotRef,
498
693
  }
499
694
  })
500
695
  }
@@ -540,11 +735,36 @@ export class Tower {
540
735
  }
541
736
 
542
737
  async mission(input = {}) {
543
- if (typeof input.id !== 'string' || input.id.trim().length === 0) fail('tower_mission requires id')
738
+ const missionId = typeof input.id === 'string' ? input.id.trim() : input.id
739
+ if (typeof missionId !== 'string' || missionId.length === 0) fail('tower_mission requires id')
544
740
  return withLock(this.paths, async () => {
545
741
  const state = await this.load()
546
- const mission = state.missions.find((entry) => entry.id === input.id)
547
- if (!mission) fail(`unknown mission "${input.id}"`)
742
+ const mission = state.missions.find((entry) => entry.id === missionId)
743
+ if (!mission) fail(`unknown mission "${missionId}"`)
744
+ const actor = input.actor ?? { name: 'tower', role: 'tower' }
745
+ const workerRequest = actor.role === 'worker'
746
+ if (actor.role === 'reviewer') {
747
+ fail('only the tower or a worker may update a mission; reviewers submit tower_review')
748
+ }
749
+ if (workerRequest && mission.owner && mission.owner !== actor.name) {
750
+ fail(`worker ${actor.name} does not own mission ${mission.id} (owner: ${mission.owner})`)
751
+ }
752
+ if (workerRequest) {
753
+ if (input.status !== undefined) {
754
+ fail('only the tower can change mission status directly; workers report completion with tower_complete')
755
+ }
756
+ if (input.scope !== undefined) fail('only the tower can change mission scope')
757
+ if (input.clear_blockers === true) fail('only the tower can clear blockers')
758
+ if (input.pause_cause !== undefined || input.authorization !== undefined) {
759
+ fail('only the tower can record pause/authorization metadata')
760
+ }
761
+ }
762
+ if (input.pause_cause !== undefined && input.status !== 'paused') {
763
+ fail('pause_cause is only valid together with status "paused"')
764
+ }
765
+ if (input.status === 'abandoned' && (typeof input.note !== 'string' || input.note.trim().length === 0)) {
766
+ fail('abandoning a mission requires a non-empty note (reason)')
767
+ }
548
768
  const now = Date.now()
549
769
  const changes = []
550
770
 
@@ -557,6 +777,20 @@ export class Tower {
557
777
  if (!allowed.includes(input.status)) {
558
778
  fail(`mission ${mission.id} cannot move from ${mission.status} to ${input.status}`)
559
779
  }
780
+ if (input.status === 'paused') {
781
+ mission.pauseCause = normalizePauseCause(input.pause_cause)
782
+ }
783
+ if (mission.status === 'paused' && input.status === 'active') {
784
+ const requireAuthorization = this.config.mode.requireResumeAuthorization === true
785
+ if (requireAuthorization && (mission.pauseCause ?? 'user') === 'user') {
786
+ if (typeof input.authorization !== 'string' || input.authorization.trim().length === 0) {
787
+ fail('resuming a user-paused mission requires a non-empty authorization reference')
788
+ }
789
+ }
790
+ if (typeof input.authorization === 'string' && input.authorization.trim()) {
791
+ mission.authorizationRef = input.authorization.trim()
792
+ }
793
+ }
560
794
  mission.status = input.status
561
795
  changes.push(`status=${input.status}`)
562
796
  }
@@ -590,6 +824,9 @@ export class Tower {
590
824
  if (typeof input.blocker !== 'string' || input.blocker.trim().length === 0) {
591
825
  fail('blocker must be a non-empty string')
592
826
  }
827
+ if (TERMINAL.has(mission.status)) {
828
+ fail(`mission ${mission.id} is ${mission.status}; a blocker cannot reopen a terminal mission`)
829
+ }
593
830
  mission.blockers.push(input.blocker.trim())
594
831
  mission.status = 'blocked'
595
832
  changes.push('blocker added')
@@ -661,20 +898,17 @@ export class Tower {
661
898
 
662
899
  async assertWorktree(worktree) {
663
900
  if (!worktree || typeof worktree.path !== 'string') fail('mission has no worktree')
664
- const wtPath = path.resolve(worktree.path)
665
- if (!(await exists(wtPath))) fail(`worktree is missing: ${wtPath}`)
666
- const common = await currentCommonDir(wtPath)
667
- if (!common) fail(`worktree ${wtPath} is not a Git worktree`)
668
- if (path.resolve(common) !== path.resolve(this.commonDir)) {
669
- fail(`worktree ${wtPath} belongs to a different repository`)
670
- }
671
- const branch = await currentBranch(wtPath)
672
- if (branch !== worktree.branch) {
673
- fail(`worktree ${wtPath} is on ${branch ?? 'detached HEAD'}, expected ${worktree.branch}`)
674
- }
901
+ const owned = await assertOwnership({
902
+ storedPath: worktree.path,
903
+ storedRealPath: worktree.realPath,
904
+ branch: worktree.branch,
905
+ commonDir: this.commonDir,
906
+ missionId: worktree.missionId,
907
+ })
908
+ worktree.realPath = owned.realPath
675
909
  const registered = await listWorktrees(this.root)
676
- if (!registered.some((entry) => path.resolve(entry.path) === wtPath)) {
677
- fail(`worktree ${wtPath} is not registered with git`)
910
+ if (!registered.some((entry) => path.resolve(entry.path) === path.resolve(owned.path))) {
911
+ fail(`worktree ${owned.path} is not registered with git`)
678
912
  }
679
913
  return worktree
680
914
  }
@@ -703,6 +937,34 @@ export class Tower {
703
937
  })
704
938
  }
705
939
 
940
+ /** Record that a live agent was interrupted by the tower. */
941
+ async recordInterrupt(input) {
942
+ return withLock(this.paths, async () => {
943
+ const state = await this.load()
944
+ const entry = (state.roster ?? []).find(
945
+ (row) =>
946
+ (input.agentId !== undefined && row.agentId === input.agentId) ||
947
+ (input.name !== undefined && row.name === input.name),
948
+ )
949
+ if (!entry) fail(`unknown roster agent "${input.name ?? input.agentId}"`)
950
+ const now = Date.now()
951
+ entry.state = 'interrupted'
952
+ entry.outcome = String(input.reason ?? 'interrupted by tower').slice(0, 1000)
953
+ entry.outcomeAt = now
954
+ state.revision += 1
955
+ state.updatedAt = now
956
+ validateState(state, this.paths.state)
957
+ await writeJsonAtomic(this.paths.state, state)
958
+ await appendJsonl(this.paths.activity, {
959
+ at: now,
960
+ type: 'spawn',
961
+ missionId: entry.missionId,
962
+ text: `${entry.name} interrupted: ${entry.outcome}`,
963
+ })
964
+ return entry
965
+ })
966
+ }
967
+
706
968
  async provision(input = {}) {
707
969
  const missionId = input.missionId
708
970
  if (typeof missionId !== 'string' || missionId.trim().length === 0) {
@@ -717,12 +979,19 @@ export class Tower {
717
979
  return { mission, worktree: mission.worktree, created: false }
718
980
  }
719
981
 
720
- const baseCommit = await revParse(this.root, state.base)
982
+ const baseCommit = input.baseCommit
983
+ ? String(input.baseCommit)
984
+ : input.includeDirtyBase === false
985
+ ? await revParse(this.root, state.base)
986
+ : state.snapshotCommit && (await tryGit(this.root, ['cat-file', '-e', `${state.snapshotCommit}^{commit}`])) !== null
987
+ ? state.snapshotCommit
988
+ : await revParse(this.root, state.base)
721
989
  const branch = `tower/${mission.id.toLowerCase()}-${slugify(mission.title)}`
990
+ const configuredRoot = this.config.worktrees.root
722
991
  const root = input.worktreeRoot
723
992
  ? path.resolve(input.worktreeRoot)
724
- : runtimeConfig().worktrees.root
725
- ? path.resolve(runtimeConfig().worktrees.root)
993
+ : configuredRoot
994
+ ? path.resolve(configuredRoot)
726
995
  : path.join(path.dirname(this.root), '.dsh-tower-worktrees', shortHash(this.commonDir))
727
996
  if (!path.isAbsolute(root)) fail('worktreeRoot must be an absolute path')
728
997
  this.assertOutside(root)
@@ -734,7 +1003,7 @@ export class Tower {
734
1003
 
735
1004
  await addWorktree(this.root, { path: wtPath, branch, commit: baseCommit })
736
1005
  const now = Date.now()
737
- const worktree = { path: wtPath, branch, baseCommit, createdAt: now }
1006
+ const worktree = { path: wtPath, branch, baseCommit, createdAt: now, realPath: await canonicalPath(wtPath) }
738
1007
  mission.worktree = worktree
739
1008
  mission.baseCommit = baseCommit
740
1009
  mission.updatedAt = now
@@ -758,9 +1027,37 @@ export class Tower {
758
1027
  const mission = state.missions.find((entry) => entry.id === missionId)
759
1028
  if (!mission) fail(`unknown mission "${missionId}"`)
760
1029
  const byId = new Map(state.missions.map((entry) => [entry.id, entry]))
1030
+ const branch = mission.worktree ? mission.worktree.branch : null
1031
+ const reviews = branch
1032
+ ? (state.reviews ?? [])
1033
+ .filter((review) => review.branch === branch)
1034
+ .slice()
1035
+ .sort((a, b) => b.round - a.round)
1036
+ .slice(0, 3)
1037
+ : []
1038
+ const findings = (state.findings ?? [])
1039
+ .filter((finding) => finding.missionId === mission.id)
1040
+ .slice()
1041
+ .sort((a, b) => b.seq - a.seq)
1042
+ .slice(0, 10)
761
1043
  return {
762
1044
  base: state.base,
763
1045
  mission: fullMission(mission),
1046
+ snapshotCommit: state.snapshotCommit ?? null,
1047
+ completionReport: mission.completionReport ?? null,
1048
+ reviews: reviews.map((review) => ({
1049
+ id: review.id,
1050
+ round: review.round,
1051
+ status: review.status,
1052
+ mergeVerdict: review.mergeVerdict,
1053
+ reviewer: review.reviewer,
1054
+ findings: review.findings,
1055
+ checks: review.checks ?? [],
1056
+ decision: review.decision,
1057
+ reviewedCommit: review.reviewedCommit,
1058
+ at: review.at,
1059
+ })),
1060
+ findings: findings.map((finding) => findingSummary(finding)),
764
1061
  deps: (mission.deps ?? []).map((id) => {
765
1062
  const dep = byId.get(id)
766
1063
  return dep ? { id, title: dep.title, status: dep.status } : { id, title: null, status: 'unknown' }
@@ -795,11 +1092,35 @@ export class Tower {
795
1092
  state: 'active',
796
1093
  spawnedAt: now,
797
1094
  }
798
- state.roster.push(entry)
799
1095
  if (input.role === 'worker') {
1096
+ const previous = workerEntries(state, mission.id)
1097
+ const live = previous.find((entry) => !ROSTER_INACTIVE.has(entry.state))
1098
+ if (live && !input.takeover) {
1099
+ fail(
1100
+ `mission ${mission.id} already has a live worker "${live.name}"; stop it with tower_interrupt, or pass takeover:true after it is no longer running`,
1101
+ )
1102
+ }
1103
+ // A new owner always retires the mission's older worker entries, including
1104
+ // idle `inactive` ones; otherwise the next spawn would see several entries
1105
+ // for the same mission and the old idle worker would block forever.
1106
+ for (const old of previous) {
1107
+ if (old.state === 'retired') continue
1108
+ old.state = 'retired'
1109
+ old.outcome = `replaced by ${input.name}${input.takeover ? ' (takeover)' : ''}`
1110
+ old.outcomeAt = now
1111
+ }
1112
+ const liveCount = countLiveWorkers(state)
1113
+ const max = Number(this.config.spawn.maxLiveWorkers) || 4
1114
+ if (liveCount >= max) {
1115
+ fail(`live worker limit reached (${max}); stop a worker or raise spawn.maxLiveWorkers`)
1116
+ }
1117
+ const generation = (mission.ownerGeneration ?? 0) + 1
1118
+ entry.generation = generation
1119
+ mission.ownerGeneration = generation
800
1120
  mission.owner = input.name
801
1121
  if (mission.status === 'planned') mission.status = 'active'
802
1122
  }
1123
+ state.roster.push(entry)
803
1124
  mission.updatedAt = now
804
1125
  state.revision += 1
805
1126
  state.updatedAt = now
@@ -836,6 +1157,149 @@ export class Tower {
836
1157
  }
837
1158
  }
838
1159
 
1160
+ async recordTestRun(input = {}) {
1161
+ return withLock(this.paths, async () => {
1162
+ const state = await this.load()
1163
+ const mission = state.missions.find((entry) => entry.id === input.missionId)
1164
+ if (!mission) fail(`unknown mission "${input.missionId}"`)
1165
+ if (!mission.testRuns) mission.testRuns = []
1166
+ const receipt = { ...input.receipt, recordedAt: Date.now() }
1167
+ mission.testRuns.push(receipt)
1168
+ if (mission.testRuns.length > 50) mission.testRuns = mission.testRuns.slice(-50)
1169
+ state.revision += 1
1170
+ state.updatedAt = Date.now()
1171
+ validateState(state, this.paths.state)
1172
+ await writeJsonAtomic(this.paths.state, state)
1173
+ await appendJsonl(this.paths.activity, {
1174
+ at: Date.now(),
1175
+ type: 'test',
1176
+ missionId: mission.id,
1177
+ text: receipt.ok === true ? `passed ${receipt.tests?.length ?? 0} test(s)` : `failed: ${String(receipt.error ?? 'unknown').slice(0, 200)}`,
1178
+ })
1179
+ return receipt
1180
+ })
1181
+ }
1182
+
1183
+ async recordLegacySnapshot(snapshot) {
1184
+ return withLock(this.paths, async () => {
1185
+ const state = await this.load()
1186
+ if (!Array.isArray(state.legacySnapshots)) state.legacySnapshots = []
1187
+ const existing = state.legacySnapshots.find((entry) => entry.sourceHash === snapshot.sourceHash)
1188
+ if (existing) return { snapshot: existing, duplicate: true }
1189
+ state.legacySnapshots.push(snapshot)
1190
+ state.revision += 1
1191
+ state.updatedAt = Date.now()
1192
+ validateState(state, this.paths.state)
1193
+ await writeJsonAtomic(this.paths.state, state)
1194
+ return { snapshot, duplicate: false }
1195
+ })
1196
+ }
1197
+
1198
+ async recordLegacyMap(map) {
1199
+ return withLock(this.paths, async () => {
1200
+ const state = await this.load()
1201
+ if (!Array.isArray(state.legacyMaps)) state.legacyMaps = []
1202
+ const existing = state.legacyMaps.find(
1203
+ (entry) => entry.sourceHash === map.sourceHash && entry.oldId === map.oldId,
1204
+ )
1205
+ if (existing) return { map: existing, duplicate: true }
1206
+ state.legacyMaps.push(map)
1207
+ state.revision += 1
1208
+ state.updatedAt = Date.now()
1209
+ validateState(state, this.paths.state)
1210
+ await writeJsonAtomic(this.paths.state, state)
1211
+ return { map, duplicate: false }
1212
+ })
1213
+ }
1214
+
1215
+ async createBatch(input = {}, actor = 'tower') {
1216
+ return withLock(this.paths, async () => {
1217
+ const state = await this.load()
1218
+ const normalized = validateBatchCreate(state, input)
1219
+ if (!Array.isArray(state.batches)) state.batches = []
1220
+ const max = state.batches.reduce((value, batch) => {
1221
+ const match = /^B(\d+)$/.exec(String(batch.id ?? ''))
1222
+ return match ? Math.max(value, Number(match[1])) : value
1223
+ }, 0)
1224
+ const batch = {
1225
+ id: `B${max + 1}`,
1226
+ status: 'open',
1227
+ ...normalized,
1228
+ createdBy: actor,
1229
+ createdAt: Date.now(),
1230
+ }
1231
+ state.batches.push(batch)
1232
+ state.revision += 1
1233
+ state.updatedAt = batch.createdAt
1234
+ validateState(state, this.paths.state)
1235
+ await writeJsonAtomic(this.paths.state, state)
1236
+ await appendJsonl(this.paths.activity, {
1237
+ at: batch.createdAt,
1238
+ type: 'batch',
1239
+ text: `created ${batch.id}: ${batch.missionIds.join(', ')}`,
1240
+ })
1241
+ return batch
1242
+ })
1243
+ }
1244
+
1245
+ async closeBatch(batchId, options = {}, actor = 'tower') {
1246
+ return withLock(this.paths, async () => {
1247
+ const state = await this.load()
1248
+ const batch = (state.batches ?? []).find((entry) => entry.id === batchId)
1249
+ if (!batch) fail(`unknown batch "${batchId}"`)
1250
+ const result = validateBatchClose(state, batch, { ...options, state })
1251
+ const blockers = Array.isArray(result.blockers) ? result.blockers : []
1252
+ if (blockers.length > 0) return { ok: false, batch, blockers, checks: result.checks ?? [] }
1253
+ batch.status = 'closed'
1254
+ batch.closedAt = Date.now()
1255
+ batch.closedBy = actor
1256
+ batch.close = {
1257
+ checks: options.checks ?? [],
1258
+ treeTip: options.treeTip ?? null,
1259
+ receiptHashes: options.receiptHashes ?? {},
1260
+ at: batch.closedAt,
1261
+ }
1262
+ state.revision += 1
1263
+ state.updatedAt = batch.closedAt
1264
+ validateState(state, this.paths.state)
1265
+ await writeJsonAtomic(this.paths.state, state)
1266
+ await appendJsonl(this.paths.activity, { at: batch.closedAt, type: 'batch', text: `closed ${batch.id}` })
1267
+ return { ok: true, batch, blockers: [], checks: result.checks ?? [] }
1268
+ })
1269
+ }
1270
+
1271
+ async abandonBatch(batchId, actor = 'tower', reason = null) {
1272
+ return withLock(this.paths, async () => {
1273
+ const state = await this.load()
1274
+ const batch = (state.batches ?? []).find((entry) => entry.id === batchId)
1275
+ if (!batch) fail(`unknown batch "${batchId}"`)
1276
+ if (batch.status === 'closed') fail(`batch ${batchId} is already closed`)
1277
+ if (batch.status === 'abandoned') return { ok: true, batch, alreadyAbandoned: true }
1278
+ batch.status = 'abandoned'
1279
+ batch.abandonedAt = Date.now()
1280
+ batch.abandonedBy = actor
1281
+ batch.abandonReason = typeof reason === 'string' && reason.trim() ? reason.trim() : null
1282
+ state.revision += 1
1283
+ state.updatedAt = batch.abandonedAt
1284
+ validateState(state, this.paths.state)
1285
+ await writeJsonAtomic(this.paths.state, state)
1286
+ await appendJsonl(this.paths.activity, { at: batch.abandonedAt, type: 'batch', text: `abandoned ${batch.id}` })
1287
+ return { ok: true, batch, alreadyAbandoned: false }
1288
+ })
1289
+ }
1290
+
1291
+ async batchStatus(batchId) {
1292
+ const state = await this.load()
1293
+ const batch = batchId
1294
+ ? (state.batches ?? []).find((entry) => entry.id === batchId)
1295
+ : null
1296
+ if (batchId && !batch) fail(`unknown batch "${batchId}"`)
1297
+ return {
1298
+ batches: (state.batches ?? []).map((entry) => summarizeBatch(state, entry)),
1299
+ ...(batch ? { batch: summarizeBatch(state, batch) } : {}),
1300
+ }
1301
+ }
1302
+
839
1303
  async markRetired(agentIds) {
840
1304
  if (!Array.isArray(agentIds) || agentIds.length === 0) return { retired: [] }
841
1305
  return withLock(this.paths, async () => {
@@ -864,6 +1328,77 @@ export class Tower {
864
1328
  })
865
1329
  }
866
1330
 
1331
+ async markMissing(agentIds) {
1332
+ if (!Array.isArray(agentIds) || agentIds.length === 0) return { missing: [] }
1333
+ return withLock(this.paths, async () => {
1334
+ const state = await this.load()
1335
+ const missing = []
1336
+ const now = Date.now()
1337
+ for (const entry of state.roster ?? []) {
1338
+ if (!ROSTER_INACTIVE.has(entry.state) && agentIds.includes(entry.agentId)) {
1339
+ entry.state = 'missing'
1340
+ entry.missingSince = now
1341
+ missing.push(entry.agentId)
1342
+ }
1343
+ }
1344
+ if (missing.length > 0) {
1345
+ state.revision += 1
1346
+ state.updatedAt = now
1347
+ validateState(state, this.paths.state)
1348
+ await writeJsonAtomic(this.paths.state, state)
1349
+ await appendJsonl(this.paths.activity, { at: now, type: 'adopt', text: `missing ${missing.join(', ')}` })
1350
+ }
1351
+ return { missing }
1352
+ })
1353
+ }
1354
+
1355
+ /** Mark live roster entries whose catalog activity disappeared after staleMs.
1356
+ * `authoritative` must be true only when the parent catalog was readable;
1357
+ * unknown state is never treated as stopped. */
1358
+ async sweepRoster(input = {}) {
1359
+ const staleMs = Number(input.staleMs) || 0
1360
+ if (staleMs <= 0 || input.authoritative !== true) return { marked: [], idled: [] }
1361
+ const activity = input.activity ?? {}
1362
+ return withLock(this.paths, async () => {
1363
+ const state = await this.load()
1364
+ const now = Date.now()
1365
+ const marked = []
1366
+ const idled = []
1367
+ for (const entry of state.roster ?? []) {
1368
+ if (ROSTER_INACTIVE.has(entry.state)) continue
1369
+ const observed = activity[entry.agentId]
1370
+ if (observed === 'inactive') {
1371
+ entry.state = 'inactive'
1372
+ entry.outcome = entry.outcome ?? 'idle (catalog)'
1373
+ entry.outcomeAt = now
1374
+ idled.push(entry.name)
1375
+ continue
1376
+ }
1377
+ if (observed === 'running') continue
1378
+ if (observed !== undefined) continue
1379
+ const age = now - Number(entry.spawnedAt ?? now)
1380
+ if (age > staleMs) {
1381
+ entry.state = 'stale'
1382
+ entry.stale = true
1383
+ entry.staleAt = now
1384
+ marked.push(entry.name)
1385
+ }
1386
+ }
1387
+ if (marked.length > 0 || idled.length > 0) {
1388
+ state.revision += 1
1389
+ state.updatedAt = now
1390
+ validateState(state, this.paths.state)
1391
+ await writeJsonAtomic(this.paths.state, state)
1392
+ await appendJsonl(this.paths.activity, {
1393
+ at: now,
1394
+ type: 'watchdog',
1395
+ text: `stale ${marked.join(', ') || '-'}; idle ${idled.join(', ') || '-'}`,
1396
+ })
1397
+ }
1398
+ return { marked, idled }
1399
+ })
1400
+ }
1401
+
867
1402
  unreadFor(state, name) {
868
1403
  return (state.messages ?? []).filter((message) => {
869
1404
  if (message.from === name) return false
@@ -893,6 +1428,7 @@ export class Tower {
893
1428
  subject: input.subject,
894
1429
  body: input.body,
895
1430
  scope: input.scope ?? null,
1431
+ consentRef: input.consentRef ?? null,
896
1432
  at: now,
897
1433
  readBy: [],
898
1434
  delivery: [],
@@ -941,6 +1477,7 @@ export class Tower {
941
1477
  for (const message of page) {
942
1478
  if (!(message.readBy ?? []).includes(reader)) {
943
1479
  message.readBy = [...(message.readBy ?? []), reader]
1480
+ message.readAt = { ...(message.readAt ?? {}), [reader]: Date.now() }
944
1481
  changed = true
945
1482
  }
946
1483
  }
@@ -961,6 +1498,8 @@ export class Tower {
961
1498
  body: message.body,
962
1499
  scope: message.scope ?? null,
963
1500
  at: message.at,
1501
+ delivery: message.delivery ?? [],
1502
+ ackAt: message.readAt?.[reader] ?? null,
964
1503
  })),
965
1504
  pageSize: page.length,
966
1505
  unreadRemaining,
@@ -968,50 +1507,64 @@ export class Tower {
968
1507
  })
969
1508
  }
970
1509
 
1510
+ /** Read-only completion preconditions shared by tower_complete and the tool
1511
+ * layer's test-discovery preflight. Throws on the first failed condition. */
1512
+ async checkCompletion(input) {
1513
+ const state = await this.load()
1514
+ const mission = state.missions.find((entry) => entry.id === input.missionId)
1515
+ if (!mission) fail(`unknown mission "${input.missionId}"`)
1516
+ const worker = (state.roster ?? []).find((entry) => entry.agentId === input.agentId)
1517
+ if (!worker || worker.missionId !== mission.id) {
1518
+ fail(`agent ${input.agentId} is not assigned to mission ${mission.id}`)
1519
+ }
1520
+ if (worker.role !== 'worker') fail(`agent ${worker.name} is a ${worker.role}, not a worker`)
1521
+ if (['retired', 'failed', 'interrupted', 'missing', 'stale'].includes(worker.state)) {
1522
+ fail(`agent ${worker.name} is ${worker.state}; a stopped worker cannot report completion`)
1523
+ }
1524
+ if (mission.owner && mission.owner !== worker.name) {
1525
+ fail(`agent ${worker.name} is not the current owner of mission ${mission.id} (owner: ${mission.owner})`)
1526
+ }
1527
+ if (mission.status === 'completed') fail(`mission ${mission.id} is already completed`)
1528
+ if (!['active', 'planned'].includes(mission.status)) {
1529
+ fail(`mission ${mission.id} is ${mission.status}; it cannot be completed from that state`)
1530
+ }
1531
+ if (typeof input.report !== 'string' || input.report.trim().length === 0) {
1532
+ fail('tower_complete requires a non-empty report')
1533
+ }
1534
+ const openTasks = mission.tasks.filter((task) => task.status === 'open')
1535
+ if (openTasks.length > 0) {
1536
+ fail(
1537
+ `mission ${mission.id} still has ${openTasks.length} open task(s): ${openTasks
1538
+ .map((task) => task.text)
1539
+ .join('; ')}`,
1540
+ )
1541
+ }
1542
+ if ((mission.blockers ?? []).length > 0) {
1543
+ fail(`mission ${mission.id} has unresolved blockers: ${mission.blockers.join('; ')}`)
1544
+ }
1545
+ if (!mission.worktree) fail(`mission ${mission.id} has no worktree`)
1546
+ await this.assertWorktree(mission.worktree)
1547
+ const commits = await countCommits(mission.worktree.path, mission.worktree.baseCommit)
1548
+ if (commits < 1) {
1549
+ fail(`mission branch has no commits since ${String(mission.worktree.baseCommit).slice(0, 12)}`)
1550
+ }
1551
+ const unread = this.unreadFor(state, worker.name)
1552
+ if (unread.length > 0) {
1553
+ fail(
1554
+ `mission ${mission.id} cannot complete with ${unread.length} unread inbox message(s) for ${worker.name}; call tower_inbox until unread is 0, incorporate them, then retry`,
1555
+ )
1556
+ }
1557
+ return { state, mission, worker, commits }
1558
+ }
1559
+
971
1560
  async complete(input) {
972
1561
  return withLock(this.paths, async () => {
973
- const state = await this.load()
974
- const mission = state.missions.find((entry) => entry.id === input.missionId)
975
- if (!mission) fail(`unknown mission "${input.missionId}"`)
976
- const worker = (state.roster ?? []).find((entry) => entry.agentId === input.agentId)
977
- if (!worker || worker.missionId !== mission.id) {
978
- fail(`agent ${input.agentId} is not assigned to mission ${mission.id}`)
979
- }
980
- if (worker.role !== 'worker') fail(`agent ${worker.name} is a ${worker.role}, not a worker`)
981
- if (mission.status === 'completed') fail(`mission ${mission.id} is already completed`)
982
- if (!['active', 'planned'].includes(mission.status)) {
983
- fail(`mission ${mission.id} is ${mission.status}; it cannot be completed from that state`)
984
- }
985
- if (typeof input.report !== 'string' || input.report.trim().length === 0) {
986
- fail('tower_complete requires a non-empty report')
987
- }
988
- const openTasks = mission.tasks.filter((task) => task.status === 'open')
989
- if (openTasks.length > 0) {
990
- fail(
991
- `mission ${mission.id} still has ${openTasks.length} open task(s): ${openTasks
992
- .map((task) => task.text)
993
- .join('; ')}`,
994
- )
995
- }
996
- if ((mission.blockers ?? []).length > 0) {
997
- fail(`mission ${mission.id} has unresolved blockers: ${mission.blockers.join('; ')}`)
998
- }
999
- if (!mission.worktree) fail(`mission ${mission.id} has no worktree`)
1000
- await this.assertWorktree(mission.worktree)
1001
- const commits = await countCommits(mission.worktree.path, mission.worktree.baseCommit)
1002
- if (commits < 1) {
1003
- fail(`mission branch has no commits since ${String(mission.worktree.baseCommit).slice(0, 12)}`)
1004
- }
1005
- const unread = this.unreadFor(state, worker.name)
1006
- if (unread.length > 0) {
1007
- fail(
1008
- `mission ${mission.id} cannot complete with ${unread.length} unread inbox message(s) for ${worker.name}; call tower_inbox until unread is 0, incorporate them, then retry`,
1009
- )
1010
- }
1562
+ const { state, mission, worker, commits } = await this.checkCompletion(input)
1011
1563
 
1564
+ const checks = normalizeChecks(input.checks)
1012
1565
  const now = Date.now()
1013
1566
  mission.status = 'completed'
1014
- mission.completionReport = { report: input.report.trim(), by: worker.name, at: now }
1567
+ mission.completionReport = { report: input.report.trim(), by: worker.name, at: now, checks }
1015
1568
  mission.updatedAt = now
1016
1569
  state.revision += 1
1017
1570
  state.updatedAt = now
@@ -1087,6 +1640,111 @@ export class Tower {
1087
1640
  })
1088
1641
  }
1089
1642
 
1643
+ /** List or read findings. Read-only paths never take the writer lock. */
1644
+ async readFindings(input = {}) {
1645
+ const state = await this.load()
1646
+ const all = state.findings ?? []
1647
+ const counts = countFindings(all)
1648
+ if (input.id !== undefined && input.id !== null && input.id !== '') {
1649
+ const finding = all.find((entry) => entry.id === input.id)
1650
+ if (!finding) fail(`unknown finding "${input.id}"`)
1651
+ return { finding: { ...finding }, counts, total: all.length, filtered: 1, action: 'read' }
1652
+ }
1653
+ if (input.status !== undefined && input.status !== 'all') {
1654
+ if (!FINDING_STATUSES.includes(input.status)) {
1655
+ fail(`finding status must be one of ${FINDING_STATUSES.join('|')}`)
1656
+ }
1657
+ }
1658
+ let visible = all
1659
+ if (input.status !== undefined && input.status !== 'all') {
1660
+ visible = visible.filter((finding) => (finding.status ?? 'open') === input.status)
1661
+ }
1662
+ if (input.type !== undefined) {
1663
+ if (!FINDING_TYPES.includes(input.type)) fail(`finding type must be one of ${FINDING_TYPES.join('|')}`)
1664
+ visible = visible.filter((finding) => finding.type === input.type)
1665
+ }
1666
+ if (input.severity !== undefined) {
1667
+ if (!FINDING_SEVERITIES.includes(input.severity)) {
1668
+ fail(`finding severity must be one of ${FINDING_SEVERITIES.join('|')}`)
1669
+ }
1670
+ visible = visible.filter((finding) => finding.severity === input.severity)
1671
+ }
1672
+ if (input.missionId !== undefined && input.missionId !== null && input.missionId !== '') {
1673
+ visible = visible.filter((finding) => finding.missionId === input.missionId)
1674
+ }
1675
+ if (input.open === true) {
1676
+ visible = visible.filter((finding) => !['fixed', 'wontfix'].includes(finding.status ?? 'open'))
1677
+ }
1678
+ const limit = Math.max(1, Math.min(200, Number.isInteger(input.limit) ? input.limit : 50))
1679
+ const page = visible.slice(-limit).reverse()
1680
+ return {
1681
+ findings: page.map((finding) => findingSummary(finding)),
1682
+ counts,
1683
+ total: all.length,
1684
+ filtered: visible.length,
1685
+ action: 'list',
1686
+ }
1687
+ }
1688
+
1689
+ /**
1690
+ * List, read, or transition findings. Listing and reading are lock-free;
1691
+ * only a transition takes the writer lock. Reviewers may only read.
1692
+ */
1693
+ async findings(input = {}) {
1694
+ const actor = input.actor ?? { name: 'tower', role: 'tower' }
1695
+ if (typeof actor.role !== 'string') fail('finding actor role must be a string')
1696
+ const hasId = input.id !== undefined && input.id !== null && input.id !== ''
1697
+ const wantsUpdate = hasId && (input.status !== undefined || input.note !== undefined)
1698
+ if (!wantsUpdate) return this.readFindings(input)
1699
+
1700
+ return withLock(this.paths, async () => {
1701
+ const state = await this.load()
1702
+ const all = state.findings ?? []
1703
+ const finding = all.find((entry) => entry.id === input.id)
1704
+ if (!finding) fail(`unknown finding "${input.id}"`)
1705
+ if (actor.role === 'reviewer') {
1706
+ fail('reviewers may list and read findings, but only the tower or a worker may change finding status')
1707
+ }
1708
+ const now = Date.now()
1709
+ if (input.status !== undefined) {
1710
+ if (input.status === 'all') {
1711
+ fail('finding status "all" is only a list filter; pass a concrete status when updating')
1712
+ }
1713
+ if (!FINDING_STATUSES.includes(input.status)) {
1714
+ fail(`finding status must be one of ${FINDING_STATUSES.join('|')}`)
1715
+ }
1716
+ const current = finding.status ?? 'open'
1717
+ const allowed = FINDING_TRANSITIONS[current] ?? []
1718
+ if (!allowed.includes(input.status)) {
1719
+ fail(`finding ${finding.id} cannot move from ${current} to ${input.status}`)
1720
+ }
1721
+ finding.status = input.status
1722
+ if (input.status === 'fixed' || input.status === 'wontfix') finding.closedAt = now
1723
+ else delete finding.closedAt
1724
+ }
1725
+ if (input.note !== undefined) {
1726
+ if (typeof input.note !== 'string' || input.note.trim().length === 0) {
1727
+ fail('finding note must be a non-empty string')
1728
+ }
1729
+ if (!Array.isArray(finding.notes)) finding.notes = []
1730
+ finding.notes.push({ by: actor.name, at: now, text: input.note.trim() })
1731
+ }
1732
+ finding.updatedAt = now
1733
+ state.revision += 1
1734
+ state.updatedAt = now
1735
+ validateState(state, this.paths.state)
1736
+ await writeJsonAtomic(this.paths.state, state)
1737
+ await appendJsonl(this.paths.activity, {
1738
+ at: now,
1739
+ type: 'finding',
1740
+ missionId: finding.missionId,
1741
+ text: `${finding.id} ${finding.status}${input.note !== undefined ? ' +note' : ''} (${actor.name})`,
1742
+ })
1743
+ // Counts must reflect the transition just recorded.
1744
+ return { finding: { ...finding }, counts: countFindings(all), total: all.length, filtered: 1, action: 'updated' }
1745
+ })
1746
+ }
1747
+
1090
1748
  async submitReview(input) {
1091
1749
  return withLock(this.paths, async () => {
1092
1750
  const state = await this.load()
@@ -1150,7 +1808,7 @@ export class Tower {
1150
1808
  }
1151
1809
  const prior = (state.reviews ?? []).filter((review) => review.branch === branch)
1152
1810
  const reviewerRounds = prior.filter((review) => review.reviewer === reviewerName).length
1153
- const maxReviewRounds = Number(runtimeConfig().review.maxRoundsPerReviewer) || MAX_REVIEW_ROUNDS
1811
+ const maxReviewRounds = Number(this.config.review.maxRoundsPerReviewer) || MAX_REVIEW_ROUNDS
1154
1812
  if (reviewerRounds >= maxReviewRounds) {
1155
1813
  fail(
1156
1814
  `reviewer ${reviewerName} reached the ${maxReviewRounds}-round cap for ${branch}; continue with a different reviewer`,
@@ -1211,9 +1869,105 @@ export class Tower {
1211
1869
  })
1212
1870
  }
1213
1871
 
1214
- async enrichedMissions(state) {
1872
+ /** Full detail for one mission, including reviews, findings and recent activity. */
1873
+ async missionDetail(missionId) {
1874
+ const state = await this.load()
1875
+ const mission = state.missions.find((entry) => entry.id === missionId)
1876
+ if (!mission) fail(`unknown mission "${missionId}"`)
1877
+ const branch = mission.worktree ? mission.worktree.branch : null
1878
+ const reviews = (state.reviews ?? [])
1879
+ .filter((review) => review.missionId === mission.id || (branch && review.branch === branch))
1880
+ .slice()
1881
+ .sort((a, b) => b.round - a.round)
1882
+ const findings = (state.findings ?? []).filter((finding) => finding.missionId === mission.id)
1883
+ const activity = (await readActivityTail(this.paths.activity, 200)).filter(
1884
+ (entry) => entry.missionId === mission.id,
1885
+ )
1886
+ return {
1887
+ mission: fullMission(mission),
1888
+ reviews: reviews.slice(0, 10),
1889
+ findings: findings.slice(-10).map((finding) => findingSummary(finding)),
1890
+ activity: activity.slice(-20),
1891
+ }
1892
+ }
1893
+
1894
+ /** Human-readable mission board (markdown) for the current state. */
1895
+ boardMarkdown(state) {
1896
+ const lines = []
1897
+ lines.push(`# Tower missions — ${state.repoRoot}`)
1898
+ lines.push('')
1899
+ lines.push(`Base: \`${state.base}\` · revision ${state.revision} · updated ${new Date(state.updatedAt ?? Date.now()).toISOString()}`)
1900
+ const counts = {}
1901
+ for (const status of MISSION_STATUSES) counts[status] = 0
1902
+ for (const mission of state.missions) counts[mission.status] += 1
1903
+ lines.push('')
1904
+ lines.push(
1905
+ `Missions: ${state.missions.length} · ` +
1906
+ MISSION_STATUSES.map((status) => `${status} ${counts[status]}`).join(' · '),
1907
+ )
1908
+ const openFindings = (state.findings ?? []).filter((finding) => !['fixed', 'wontfix'].includes(finding.status ?? 'open'))
1909
+ lines.push(`Findings: ${openFindings.length} open of ${(state.findings ?? []).length}`)
1910
+ lines.push('')
1911
+ lines.push('| Mission | Status | Kind | Title | Owner | Tasks (done/open/dropped) | Latest review | Merged |')
1912
+ lines.push('|---|---|---|---|---|---|---|---|')
1913
+ for (const mission of state.missions) {
1914
+ const tasks = taskCounts(mission.tasks ?? [])
1915
+ const branch = mission.worktree ? mission.worktree.branch : null
1916
+ const reviews = branch ? (state.reviews ?? []).filter((review) => review.branch === branch) : []
1917
+ const latest = reviews.length > 0 ? reviews.reduce((a, b) => (a.round >= b.round ? a : b)) : null
1918
+ const reviewText = latest ? `R${latest.round} ${latest.status}/${latest.mergeVerdict}` : '—'
1919
+ lines.push(
1920
+ `| ${mission.id} | ${mission.status} | ${mission.kind} | ${String(mission.title).replaceAll('|', '\\|')} | ${
1921
+ mission.owner ?? '—'
1922
+ } | ${tasks.done}/${tasks.open}/${tasks.dropped} | ${reviewText} | ${
1923
+ mission.mergedCommit ? mission.mergedCommit.slice(0, 8) : '—'
1924
+ } |`,
1925
+ )
1926
+ }
1927
+ if ((state.roster ?? []).length > 0) {
1928
+ lines.push('')
1929
+ lines.push('## Roster')
1930
+ lines.push('')
1931
+ lines.push('| Name | Role | Mission | State | Agent |')
1932
+ lines.push('|---|---|---|---|---|')
1933
+ for (const entry of state.roster) {
1934
+ lines.push(
1935
+ `| ${entry.name} | ${entry.role} | ${entry.missionId} | ${entry.state} | \`${entry.agentId}\` |`,
1936
+ )
1937
+ }
1938
+ }
1939
+ lines.push('')
1940
+ return lines.join('\n')
1941
+ }
1942
+
1943
+ /** Write the board when docs.board is enabled; failures never break a mutation. */
1944
+ async writeBoard() {
1945
+ if (this.config.docs?.board === false) return null
1946
+ try {
1947
+ const state = await this.load()
1948
+ const file = this.config.docs?.boardLocation === 'repo'
1949
+ ? path.join(this.root, 'MISSIONS.md')
1950
+ : path.join(this.paths.dir, 'MISSIONS.md')
1951
+ const tmp = `${file}.tmp-${process.pid}-${Date.now()}`
1952
+ await fsp.writeFile(tmp, this.boardMarkdown(state), 'utf8')
1953
+ await renameWithRetry(tmp, file)
1954
+ return file
1955
+ } catch {
1956
+ return null
1957
+ }
1958
+ }
1959
+
1960
+ boardFile() {
1961
+ return this.config.docs?.boardLocation === 'repo'
1962
+ ? path.join(this.root, 'MISSIONS.md')
1963
+ : path.join(this.paths.dir, 'MISSIONS.md')
1964
+ }
1965
+
1966
+ async enrichedMissions(state, onlyIds) {
1967
+ const wanted = onlyIds ? new Set(onlyIds) : null
1215
1968
  const out = []
1216
1969
  for (const mission of state.missions) {
1970
+ if (wanted && !wanted.has(mission.id)) continue
1217
1971
  const summary = missionSummary(mission)
1218
1972
  const branch = mission.worktree ? mission.worktree.branch : null
1219
1973
  const reviews = branch ? (state.reviews ?? []).filter((review) => review.branch === branch) : []
@@ -1257,7 +2011,15 @@ export class Tower {
1257
2011
  const state = await this.load()
1258
2012
  const mission = state.missions.find((entry) => entry.id === input.missionId)
1259
2013
  if (!mission) {
1260
- return { ok: false, blockers: [{ code: 'mission-missing', detail: `unknown mission "${input.missionId}"` }] }
2014
+ return {
2015
+ ok: false,
2016
+ blockers: [{ code: 'mission-missing', detail: `unknown mission "${input.missionId}"` }],
2017
+ missionId: input.missionId,
2018
+ branchTip: null,
2019
+ baseTip: null,
2020
+ changedFiles: [],
2021
+ review: null,
2022
+ }
1261
2023
  }
1262
2024
  const activity = input.activity ?? {}
1263
2025
  const blockers = []
@@ -1275,6 +2037,18 @@ export class Tower {
1275
2037
  const openTasks = mission.tasks.filter((task) => task.status === 'open')
1276
2038
  if (openTasks.length > 0) push('tasks-open', `${openTasks.length} open task(s): ${openTasks.map((task) => task.text).join('; ')}`)
1277
2039
  if ((mission.blockers ?? []).length > 0) push('blockers-present', mission.blockers.join('; '))
2040
+ if (this.config.gates.requireChecks === true) {
2041
+ const checks = mission.completionReport?.checks ?? []
2042
+ if (checks.length === 0) {
2043
+ push('no-check-evidence', `mission ${mission.id} has no completion checks; gates.requireChecks is enabled`)
2044
+ }
2045
+ }
2046
+ if (state.mergeIntent && state.mergeIntent.missionId === mission.id) {
2047
+ push(
2048
+ 'merge-intent-pending',
2049
+ `a merge intent for ${state.mergeIntent.missionId} was recorded at ${new Date(Number(state.mergeIntent.startedAt) || 0).toISOString()}; run tower_merge to reconcile it`,
2050
+ )
2051
+ }
1278
2052
 
1279
2053
  const worktree = mission.worktree ?? null
1280
2054
  let branchTip = null
@@ -1329,9 +2103,16 @@ export class Tower {
1329
2103
  const branch = worktree ? worktree.branch : null
1330
2104
  const reviews = branch ? (state.reviews ?? []).filter((review) => review.branch === branch) : []
1331
2105
  const latest = reviews.length > 0 ? reviews.reduce((a, b) => (a.round >= b.round ? a : b)) : null
1332
- if (!latest) {
2106
+ const exempt =
2107
+ mission.reviewPolicy === 'none' &&
2108
+ this.config.gates.allowReviewExemption === true &&
2109
+ String(mission.reviewExemptionReason ?? '').trim().length > 0
2110
+ if (mission.reviewPolicy === 'none' && this.config.gates.allowReviewExemption !== true) {
2111
+ push('review-exemption-invalid', `mission ${mission.id} requests no review but gates.allowReviewExemption is disabled`)
2112
+ }
2113
+ if (!exempt && !latest) {
1333
2114
  push('no-review', `branch ${branch ?? '(none)'} has no review; assign a reviewer first`)
1334
- } else {
2115
+ } else if (!exempt) {
1335
2116
  const waived = this.rebaseWaived(mission, latest)
1336
2117
  if (latest.missionId !== mission.id) {
1337
2118
  push('review-mission-mismatch', `latest review belongs to mission ${latest.missionId}`)
@@ -1347,8 +2128,26 @@ export class Tower {
1347
2128
  }
1348
2129
  }
1349
2130
 
1350
- for (const entry of (state.roster ?? []).filter((row) => row.missionId === mission.id && row.state !== 'retired')) {
2131
+ for (const entry of (state.roster ?? []).filter((row) => row.missionId === mission.id && !ROSTER_INACTIVE.has(row.state))) {
1351
2132
  if (activity[entry.agentId] === 'running') push('worker-busy', `agent ${entry.name} is still running`)
2133
+ else if (input.parentStatus && entry.parentSessionId && input.parentStatus[entry.parentSessionId] !== 'ok') {
2134
+ push(
2135
+ 'worker-status-unknown',
2136
+ `cannot prove agent ${entry.name} is stopped: its parent catalog is unavailable`,
2137
+ )
2138
+ }
2139
+ }
2140
+ const staleWorkers = (state.roster ?? []).filter(
2141
+ (row) => row.missionId === mission.id && (row.state === 'stale' || row.stale === true),
2142
+ )
2143
+ if (staleWorkers.length > 0) {
2144
+ push('worker-stale', `stale worker(s) for ${mission.id}: ${staleWorkers.map((row) => row.name).join(', ')}; adopt or interrupt before merge`)
2145
+ }
2146
+ if (this.config.gates.requireTests === true) {
2147
+ const runs = (mission.testRuns ?? []).filter((run) => run.ok === true && run.treeTip && branchTip && run.treeTip === branchTip)
2148
+ if (runs.length === 0) {
2149
+ push('tests-missing', `mission ${mission.id} has no passing test discovery for tip ${branchTip ? branchTip.slice(0, 7) : '(none)'}`)
2150
+ }
1352
2151
  }
1353
2152
 
1354
2153
  const checkoutBranch = await currentBranch(this.root)
@@ -1406,14 +2205,60 @@ export class Tower {
1406
2205
  * exists and either the branch tip or the reviewed commit is already an
1407
2206
  * ancestor of the base.
1408
2207
  */
2208
+ /** Clear a stale merge intent only when the crash left no merge in progress
2209
+ * or no merge commit. Returns true when the intent was consumed. */
2210
+ async reconcileMergeIntent(state, mission) {
2211
+ const intent = state.mergeIntent
2212
+ if (!intent || intent.missionId !== mission.id || !mission.worktree) return false
2213
+ let head = null
2214
+ try {
2215
+ head = await revParse(this.root, 'HEAD')
2216
+ } catch {
2217
+ return false
2218
+ }
2219
+ const mergeHeadPath = path.join(this.commonDir, 'MERGE_HEAD')
2220
+ const inProgress = await exists(mergeHeadPath)
2221
+ if (inProgress) return false
2222
+ let landed = null
2223
+ if (head === intent.baseTip) {
2224
+ landed = null // no merge commit was created; the intent is safe to drop
2225
+ } else {
2226
+ landed = await findLandedMerge(
2227
+ this.root,
2228
+ head,
2229
+ new Set([intent.branchTip, intent.baseTip].filter(Boolean)),
2230
+ ).catch(() => null)
2231
+ if (!landed) return false
2232
+ }
2233
+ const now = Date.now()
2234
+ delete state.mergeIntent
2235
+ state.revision += 1
2236
+ state.updatedAt = now
2237
+ await writeJsonAtomic(this.paths.state, state)
2238
+ await appendJsonl(this.paths.activity, {
2239
+ at: now,
2240
+ type: 'merge',
2241
+ missionId: mission.id,
2242
+ text: landed
2243
+ ? `reconciled merge intent; found landed commit ${String(landed.commit).slice(0, 7)}`
2244
+ : 'cleared a merge intent that created no commit',
2245
+ })
2246
+ return true
2247
+ }
2248
+
1409
2249
  async reconcileLandedMerge(state, mission) {
1410
2250
  if (!['completed', 'blocked'].includes(mission.status)) return null
1411
2251
  if (!mission.worktree) return null
1412
2252
  const branch = mission.worktree.branch
1413
2253
  const reviews = (state.reviews ?? []).filter((review) => review.branch === branch)
1414
- if (reviews.length === 0) return null
1415
- const latest = reviews.reduce((a, b) => (a.round >= b.round ? a : b))
1416
- if (latest.status !== 'clean' || latest.mergeVerdict !== 'merge') return null
2254
+ const exempt =
2255
+ mission.reviewPolicy === 'none' &&
2256
+ this.config.gates.allowReviewExemption === true &&
2257
+ String(mission.reviewExemptionReason ?? '').trim().length > 0
2258
+ if (reviews.length === 0 && !exempt) return null
2259
+ const latest = reviews.length > 0 ? reviews.reduce((a, b) => (a.round >= b.round ? a : b)) : null
2260
+ if (latest && !exempt && (latest.status !== 'clean' || latest.mergeVerdict !== 'merge')) return null
2261
+ if (!latest && !exempt) return null
1417
2262
  let baseTip
1418
2263
  let branchTip
1419
2264
  try {
@@ -1422,7 +2267,7 @@ export class Tower {
1422
2267
  } catch {
1423
2268
  return null
1424
2269
  }
1425
- const reviewedTip = latest.reviewedCommit
2270
+ const reviewedTip = latest?.reviewedCommit ?? null
1426
2271
  const branchLanded = await isAncestor(this.root, branchTip, baseTip)
1427
2272
  const reviewedLanded = reviewedTip ? await isAncestor(this.root, reviewedTip, baseTip) : false
1428
2273
  if (!branchLanded && !reviewedLanded) return null
@@ -1430,6 +2275,7 @@ export class Tower {
1430
2275
  const found = await findLandedMerge(this.root, baseTip, tips)
1431
2276
  const mergedCommit = found ? found.commit : reviewedLanded ? reviewedTip : branchTip
1432
2277
  const now = Date.now()
2278
+ if (state.mergeIntent?.missionId === mission.id) delete state.mergeIntent
1433
2279
  mission.status = 'merged'
1434
2280
  mission.mergedAt = now
1435
2281
  mission.mergedCommit = mergedCommit
@@ -1458,13 +2304,15 @@ export class Tower {
1458
2304
  baseTip,
1459
2305
  branchTip,
1460
2306
  parents: found ? found.parents : null,
1461
- review: {
1462
- round: latest.round,
1463
- status: latest.status,
1464
- mergeVerdict: latest.mergeVerdict,
1465
- reviewer: latest.reviewer,
1466
- reviewedCommit: reviewedTip,
1467
- },
2307
+ review: latest
2308
+ ? {
2309
+ round: latest.round,
2310
+ status: latest.status,
2311
+ mergeVerdict: latest.mergeVerdict,
2312
+ reviewer: latest.reviewer,
2313
+ reviewedCommit: reviewedTip,
2314
+ }
2315
+ : null,
1468
2316
  }
1469
2317
  }
1470
2318
 
@@ -1475,6 +2323,7 @@ export class Tower {
1475
2323
  if (!mission) {
1476
2324
  return { ok: false, blockers: [{ code: 'mission-missing', detail: `unknown mission "${input.missionId}"` }] }
1477
2325
  }
2326
+ await this.reconcileMergeIntent(state, mission)
1478
2327
  const baseTip = await revParse(this.root, state.base)
1479
2328
  if (mission.status === 'merged') {
1480
2329
  return { ok: true, alreadyMerged: true, mergedCommit: mission.mergedCommit ?? null, baseTip }
@@ -1487,13 +2336,25 @@ export class Tower {
1487
2336
  const worktree = mission.worktree
1488
2337
  const baseTipBefore = await revParse(this.root, 'HEAD')
1489
2338
  const branchTip = await revParse(worktree.path, 'HEAD')
1490
- const timeoutMs = mergeTimeoutMs(input.timeoutMs)
2339
+ // Record the intent before touching Git; a crash between merge and state
2340
+ // write can be reconciled by the next tower_merge/preflight instead of
2341
+ // leaving an unexplained base tip.
2342
+ state.mergeIntent = {
2343
+ missionId: mission.id,
2344
+ baseTip: baseTipBefore,
2345
+ branchTip,
2346
+ startedAt: Date.now(),
2347
+ }
2348
+ state.revision += 1
2349
+ state.updatedAt = state.mergeIntent.startedAt
2350
+ await writeJsonAtomic(this.paths.state, state)
2351
+ const timeoutMs = mergeTimeoutMs(input.timeoutMs, this.config)
1491
2352
  let failure = null
1492
2353
  try {
1493
2354
  await git(
1494
2355
  this.root,
1495
2356
  ['merge', '--no-ff', worktree.branch, '-m', `tower: merge ${mission.id} — ${mission.title}`],
1496
- { timeoutMs, maxBuffer: runtimeConfig().merge.maxBufferBytes || MERGE_MAX_BUFFER },
2357
+ { timeoutMs, maxBuffer: this.config.merge.maxBufferBytes || MERGE_MAX_BUFFER },
1497
2358
  )
1498
2359
  } catch (error) {
1499
2360
  failure = error
@@ -1563,6 +2424,7 @@ export class Tower {
1563
2424
  }
1564
2425
  const detail = describeGitFailure(failure).slice(0, 2000)
1565
2426
  const now = Date.now()
2427
+ delete state.mergeIntent
1566
2428
  if (conflict) {
1567
2429
  mission.status = 'blocked'
1568
2430
  mission.blockers.push(`merge conflict: ${detail.slice(0, 240)}`)
@@ -1590,6 +2452,10 @@ export class Tower {
1590
2452
  }
1591
2453
 
1592
2454
  if (!expectedShape) {
2455
+ delete state.mergeIntent
2456
+ state.revision += 1
2457
+ state.updatedAt = Date.now()
2458
+ await writeJsonAtomic(this.paths.state, state).catch(() => {})
1593
2459
  return {
1594
2460
  ok: false,
1595
2461
  blockers: [
@@ -1602,6 +2468,7 @@ export class Tower {
1602
2468
  }
1603
2469
 
1604
2470
  const now = Date.now()
2471
+ delete state.mergeIntent
1605
2472
  mission.status = 'merged'
1606
2473
  mission.mergedAt = now
1607
2474
  mission.mergedCommit = newTip
@@ -1635,7 +2502,7 @@ export class Tower {
1635
2502
  if (!mission) fail(`unknown mission "${input.missionId}"`)
1636
2503
  if (!mission.worktree) fail(`mission ${mission.id} has no worktree to rebase`)
1637
2504
  const activity = input.activity ?? {}
1638
- for (const entry of (state.roster ?? []).filter((row) => row.missionId === mission.id && row.state !== 'retired')) {
2505
+ for (const entry of (state.roster ?? []).filter((row) => row.missionId === mission.id && !ROSTER_INACTIVE.has(row.state))) {
1639
2506
  if (activity[entry.agentId] === 'running') {
1640
2507
  return { ok: false, blockers: [{ code: 'worker-busy', detail: `agent ${entry.name} is running; rebase refused` }] }
1641
2508
  }
@@ -1714,7 +2581,7 @@ export class Tower {
1714
2581
  continue
1715
2582
  }
1716
2583
  const live = (state.roster ?? []).some(
1717
- (entry) => entry.missionId === mission.id && entry.state !== 'retired' && activity[entry.agentId] === 'running',
2584
+ (entry) => entry.missionId === mission.id && !ROSTER_INACTIVE.has(entry.state) && activity[entry.agentId] === 'running',
1718
2585
  )
1719
2586
  if (live) {
1720
2587
  decisions.push({ missionId: mission.id, action: 'keep', reason: 'live agent' })
@@ -1767,13 +2634,19 @@ export class Tower {
1767
2634
  })
1768
2635
  }
1769
2636
 
1770
- async status() {
2637
+ async status(input = {}) {
1771
2638
  const state = await this.load()
1772
2639
  const activity = await readActivityTail(this.paths.activity, 12)
1773
2640
  const counts = {}
1774
2641
  for (const status of MISSION_STATUSES) counts[status] = 0
1775
2642
  for (const mission of state.missions) counts[mission.status] = (counts[mission.status] ?? 0) + 1
1776
2643
  const openTasks = state.missions.reduce((sum, mission) => sum + taskCounts(mission.tasks).open, 0)
2644
+ const findingsByStatus = {}
2645
+ for (const status of FINDING_STATUSES) findingsByStatus[status] = 0
2646
+ for (const finding of state.findings ?? []) {
2647
+ const status = finding.status ?? 'open'
2648
+ findingsByStatus[status] = (findingsByStatus[status] ?? 0) + 1
2649
+ }
1777
2650
  return {
1778
2651
  repoRoot: state.repoRoot,
1779
2652
  commonDir: state.commonDir,
@@ -1786,17 +2659,27 @@ export class Tower {
1786
2659
  messages: (state.messages ?? []).length,
1787
2660
  unreadTower: this.unreadFor(state, 'tower').length,
1788
2661
  findings: (state.findings ?? []).length,
2662
+ findingsByStatus,
1789
2663
  reviews: (state.reviews ?? []).length,
2664
+ config: {
2665
+ path: this.config.configPath ?? null,
2666
+ repoConfigPresent: this.config.repoConfigPresent === true,
2667
+ spawn: {
2668
+ workerPreset: this.config.spawn?.workerPreset ?? null,
2669
+ reviewerPreset: this.config.spawn?.reviewerPreset ?? null,
2670
+ },
2671
+ },
1790
2672
  recentFindings: (state.findings ?? []).slice(-5).map((finding) => ({
1791
2673
  id: finding.id,
1792
2674
  type: finding.type,
1793
2675
  severity: finding.severity,
2676
+ status: finding.status ?? 'open',
1794
2677
  title: finding.title,
1795
2678
  missionId: finding.missionId,
1796
2679
  reportedBy: finding.reportedBy,
1797
2680
  at: finding.at,
1798
2681
  })),
1799
- missions: await this.enrichedMissions(state),
2682
+ missions: await this.enrichedMissions(state, input.missionIds),
1800
2683
  activity,
1801
2684
  }
1802
2685
  }