dsh-tower 1.3.0 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/CHANGELOG.md +77 -0
  2. package/CONTRIBUTING.md +1 -1
  3. package/README.en.md +22 -8
  4. package/README.md +20 -8
  5. package/cordis.patch.yml +10 -0
  6. package/docs/README.md +8 -4
  7. package/docs/architecture/overview.md +13 -9
  8. package/docs/faq.md +33 -2
  9. package/docs/guide/hot-reload.md +189 -0
  10. package/docs/guide/install-upgrade-migration.md +11 -0
  11. package/docs/guide/quickstart.md +33 -1
  12. package/docs/operations/runbook.md +23 -0
  13. package/docs/operations/security.md +23 -0
  14. package/docs/reference/blockers.md +10 -2
  15. package/docs/reference/config.md +65 -2
  16. package/docs/reference/doctor.md +31 -0
  17. package/docs/reference/errors.md +50 -0
  18. package/docs/reference/events.md +45 -0
  19. package/docs/reference/operations.md +52 -0
  20. package/docs/reference/testing.md +61 -0
  21. package/docs/reference/tools.md +65 -23
  22. package/docs/zh/README.md +58 -0
  23. package/docs/zh/faq.md +72 -0
  24. package/docs/zh/guide/hot-reload.md +178 -0
  25. package/docs/zh/guide/install-upgrade-migration.md +57 -0
  26. package/docs/zh/guide/quickstart.md +96 -0
  27. package/docs/zh/operations/security.md +42 -0
  28. package/docs/zh/reference/blockers.md +62 -0
  29. package/docs/zh/reference/config.md +113 -0
  30. package/docs/zh/reference/doctor.md +30 -0
  31. package/docs/zh/reference/errors.md +52 -0
  32. package/docs/zh/reference/events.md +46 -0
  33. package/docs/zh/reference/operations.md +48 -0
  34. package/docs/zh/reference/testing.md +60 -0
  35. package/docs/zh/reference/tools.md +75 -0
  36. package/index.js +261 -13
  37. package/lib/batch.js +578 -0
  38. package/lib/config.js +270 -8
  39. package/lib/doctor.js +165 -0
  40. package/lib/errors.js +111 -0
  41. package/lib/hmr.js +196 -0
  42. package/lib/leases.js +317 -0
  43. package/lib/legacy.js +413 -0
  44. package/lib/modeStore.js +28 -1
  45. package/lib/store.js +24 -1
  46. package/lib/tests.js +628 -0
  47. package/lib/tools.js +1554 -182
  48. package/lib/tower.js +957 -102
  49. package/lib/workspaceLock.js +117 -0
  50. package/lib/worktreeOwnership.js +166 -0
  51. package/package.json +4 -1
  52. package/reviewer.js +1 -0
  53. package/scripts/check-docs.mjs +135 -0
  54. package/scripts/enable-dev-hmr.mjs +347 -0
  55. package/skills/tower-review.md +4 -2
  56. package/skills/tower-worker-protocol.md +3 -1
  57. package/worker.js +4 -2
package/lib/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 ?? [],
491
+ }
492
+ }
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,
421
508
  }
422
509
  }
423
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,129 @@ 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 batchStatus(batchId) {
1272
+ const state = await this.load()
1273
+ const batch = batchId
1274
+ ? (state.batches ?? []).find((entry) => entry.id === batchId)
1275
+ : null
1276
+ if (batchId && !batch) fail(`unknown batch "${batchId}"`)
1277
+ return {
1278
+ batches: (state.batches ?? []).map((entry) => summarizeBatch(state, entry)),
1279
+ ...(batch ? { batch: summarizeBatch(state, batch) } : {}),
1280
+ }
1281
+ }
1282
+
839
1283
  async markRetired(agentIds) {
840
1284
  if (!Array.isArray(agentIds) || agentIds.length === 0) return { retired: [] }
841
1285
  return withLock(this.paths, async () => {
@@ -864,6 +1308,77 @@ export class Tower {
864
1308
  })
865
1309
  }
866
1310
 
1311
+ async markMissing(agentIds) {
1312
+ if (!Array.isArray(agentIds) || agentIds.length === 0) return { missing: [] }
1313
+ return withLock(this.paths, async () => {
1314
+ const state = await this.load()
1315
+ const missing = []
1316
+ const now = Date.now()
1317
+ for (const entry of state.roster ?? []) {
1318
+ if (!ROSTER_INACTIVE.has(entry.state) && agentIds.includes(entry.agentId)) {
1319
+ entry.state = 'missing'
1320
+ entry.missingSince = now
1321
+ missing.push(entry.agentId)
1322
+ }
1323
+ }
1324
+ if (missing.length > 0) {
1325
+ state.revision += 1
1326
+ state.updatedAt = now
1327
+ validateState(state, this.paths.state)
1328
+ await writeJsonAtomic(this.paths.state, state)
1329
+ await appendJsonl(this.paths.activity, { at: now, type: 'adopt', text: `missing ${missing.join(', ')}` })
1330
+ }
1331
+ return { missing }
1332
+ })
1333
+ }
1334
+
1335
+ /** Mark live roster entries whose catalog activity disappeared after staleMs.
1336
+ * `authoritative` must be true only when the parent catalog was readable;
1337
+ * unknown state is never treated as stopped. */
1338
+ async sweepRoster(input = {}) {
1339
+ const staleMs = Number(input.staleMs) || 0
1340
+ if (staleMs <= 0 || input.authoritative !== true) return { marked: [], idled: [] }
1341
+ const activity = input.activity ?? {}
1342
+ return withLock(this.paths, async () => {
1343
+ const state = await this.load()
1344
+ const now = Date.now()
1345
+ const marked = []
1346
+ const idled = []
1347
+ for (const entry of state.roster ?? []) {
1348
+ if (ROSTER_INACTIVE.has(entry.state)) continue
1349
+ const observed = activity[entry.agentId]
1350
+ if (observed === 'inactive') {
1351
+ entry.state = 'inactive'
1352
+ entry.outcome = entry.outcome ?? 'idle (catalog)'
1353
+ entry.outcomeAt = now
1354
+ idled.push(entry.name)
1355
+ continue
1356
+ }
1357
+ if (observed === 'running') continue
1358
+ if (observed !== undefined) continue
1359
+ const age = now - Number(entry.spawnedAt ?? now)
1360
+ if (age > staleMs) {
1361
+ entry.state = 'stale'
1362
+ entry.stale = true
1363
+ entry.staleAt = now
1364
+ marked.push(entry.name)
1365
+ }
1366
+ }
1367
+ if (marked.length > 0 || idled.length > 0) {
1368
+ state.revision += 1
1369
+ state.updatedAt = now
1370
+ validateState(state, this.paths.state)
1371
+ await writeJsonAtomic(this.paths.state, state)
1372
+ await appendJsonl(this.paths.activity, {
1373
+ at: now,
1374
+ type: 'watchdog',
1375
+ text: `stale ${marked.join(', ') || '-'}; idle ${idled.join(', ') || '-'}`,
1376
+ })
1377
+ }
1378
+ return { marked, idled }
1379
+ })
1380
+ }
1381
+
867
1382
  unreadFor(state, name) {
868
1383
  return (state.messages ?? []).filter((message) => {
869
1384
  if (message.from === name) return false
@@ -893,6 +1408,7 @@ export class Tower {
893
1408
  subject: input.subject,
894
1409
  body: input.body,
895
1410
  scope: input.scope ?? null,
1411
+ consentRef: input.consentRef ?? null,
896
1412
  at: now,
897
1413
  readBy: [],
898
1414
  delivery: [],
@@ -941,6 +1457,7 @@ export class Tower {
941
1457
  for (const message of page) {
942
1458
  if (!(message.readBy ?? []).includes(reader)) {
943
1459
  message.readBy = [...(message.readBy ?? []), reader]
1460
+ message.readAt = { ...(message.readAt ?? {}), [reader]: Date.now() }
944
1461
  changed = true
945
1462
  }
946
1463
  }
@@ -961,6 +1478,8 @@ export class Tower {
961
1478
  body: message.body,
962
1479
  scope: message.scope ?? null,
963
1480
  at: message.at,
1481
+ delivery: message.delivery ?? [],
1482
+ ackAt: message.readAt?.[reader] ?? null,
964
1483
  })),
965
1484
  pageSize: page.length,
966
1485
  unreadRemaining,
@@ -968,50 +1487,64 @@ export class Tower {
968
1487
  })
969
1488
  }
970
1489
 
1490
+ /** Read-only completion preconditions shared by tower_complete and the tool
1491
+ * layer's test-discovery preflight. Throws on the first failed condition. */
1492
+ async checkCompletion(input) {
1493
+ const state = await this.load()
1494
+ const mission = state.missions.find((entry) => entry.id === input.missionId)
1495
+ if (!mission) fail(`unknown mission "${input.missionId}"`)
1496
+ const worker = (state.roster ?? []).find((entry) => entry.agentId === input.agentId)
1497
+ if (!worker || worker.missionId !== mission.id) {
1498
+ fail(`agent ${input.agentId} is not assigned to mission ${mission.id}`)
1499
+ }
1500
+ if (worker.role !== 'worker') fail(`agent ${worker.name} is a ${worker.role}, not a worker`)
1501
+ if (['retired', 'failed', 'interrupted', 'missing', 'stale'].includes(worker.state)) {
1502
+ fail(`agent ${worker.name} is ${worker.state}; a stopped worker cannot report completion`)
1503
+ }
1504
+ if (mission.owner && mission.owner !== worker.name) {
1505
+ fail(`agent ${worker.name} is not the current owner of mission ${mission.id} (owner: ${mission.owner})`)
1506
+ }
1507
+ if (mission.status === 'completed') fail(`mission ${mission.id} is already completed`)
1508
+ if (!['active', 'planned'].includes(mission.status)) {
1509
+ fail(`mission ${mission.id} is ${mission.status}; it cannot be completed from that state`)
1510
+ }
1511
+ if (typeof input.report !== 'string' || input.report.trim().length === 0) {
1512
+ fail('tower_complete requires a non-empty report')
1513
+ }
1514
+ const openTasks = mission.tasks.filter((task) => task.status === 'open')
1515
+ if (openTasks.length > 0) {
1516
+ fail(
1517
+ `mission ${mission.id} still has ${openTasks.length} open task(s): ${openTasks
1518
+ .map((task) => task.text)
1519
+ .join('; ')}`,
1520
+ )
1521
+ }
1522
+ if ((mission.blockers ?? []).length > 0) {
1523
+ fail(`mission ${mission.id} has unresolved blockers: ${mission.blockers.join('; ')}`)
1524
+ }
1525
+ if (!mission.worktree) fail(`mission ${mission.id} has no worktree`)
1526
+ await this.assertWorktree(mission.worktree)
1527
+ const commits = await countCommits(mission.worktree.path, mission.worktree.baseCommit)
1528
+ if (commits < 1) {
1529
+ fail(`mission branch has no commits since ${String(mission.worktree.baseCommit).slice(0, 12)}`)
1530
+ }
1531
+ const unread = this.unreadFor(state, worker.name)
1532
+ if (unread.length > 0) {
1533
+ fail(
1534
+ `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`,
1535
+ )
1536
+ }
1537
+ return { state, mission, worker, commits }
1538
+ }
1539
+
971
1540
  async complete(input) {
972
1541
  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
- }
1542
+ const { state, mission, worker, commits } = await this.checkCompletion(input)
1011
1543
 
1544
+ const checks = normalizeChecks(input.checks)
1012
1545
  const now = Date.now()
1013
1546
  mission.status = 'completed'
1014
- mission.completionReport = { report: input.report.trim(), by: worker.name, at: now }
1547
+ mission.completionReport = { report: input.report.trim(), by: worker.name, at: now, checks }
1015
1548
  mission.updatedAt = now
1016
1549
  state.revision += 1
1017
1550
  state.updatedAt = now
@@ -1087,6 +1620,111 @@ export class Tower {
1087
1620
  })
1088
1621
  }
1089
1622
 
1623
+ /** List or read findings. Read-only paths never take the writer lock. */
1624
+ async readFindings(input = {}) {
1625
+ const state = await this.load()
1626
+ const all = state.findings ?? []
1627
+ const counts = countFindings(all)
1628
+ if (input.id !== undefined && input.id !== null && input.id !== '') {
1629
+ const finding = all.find((entry) => entry.id === input.id)
1630
+ if (!finding) fail(`unknown finding "${input.id}"`)
1631
+ return { finding: { ...finding }, counts, total: all.length, filtered: 1, action: 'read' }
1632
+ }
1633
+ if (input.status !== undefined && input.status !== 'all') {
1634
+ if (!FINDING_STATUSES.includes(input.status)) {
1635
+ fail(`finding status must be one of ${FINDING_STATUSES.join('|')}`)
1636
+ }
1637
+ }
1638
+ let visible = all
1639
+ if (input.status !== undefined && input.status !== 'all') {
1640
+ visible = visible.filter((finding) => (finding.status ?? 'open') === input.status)
1641
+ }
1642
+ if (input.type !== undefined) {
1643
+ if (!FINDING_TYPES.includes(input.type)) fail(`finding type must be one of ${FINDING_TYPES.join('|')}`)
1644
+ visible = visible.filter((finding) => finding.type === input.type)
1645
+ }
1646
+ if (input.severity !== undefined) {
1647
+ if (!FINDING_SEVERITIES.includes(input.severity)) {
1648
+ fail(`finding severity must be one of ${FINDING_SEVERITIES.join('|')}`)
1649
+ }
1650
+ visible = visible.filter((finding) => finding.severity === input.severity)
1651
+ }
1652
+ if (input.missionId !== undefined && input.missionId !== null && input.missionId !== '') {
1653
+ visible = visible.filter((finding) => finding.missionId === input.missionId)
1654
+ }
1655
+ if (input.open === true) {
1656
+ visible = visible.filter((finding) => !['fixed', 'wontfix'].includes(finding.status ?? 'open'))
1657
+ }
1658
+ const limit = Math.max(1, Math.min(200, Number.isInteger(input.limit) ? input.limit : 50))
1659
+ const page = visible.slice(-limit).reverse()
1660
+ return {
1661
+ findings: page.map((finding) => findingSummary(finding)),
1662
+ counts,
1663
+ total: all.length,
1664
+ filtered: visible.length,
1665
+ action: 'list',
1666
+ }
1667
+ }
1668
+
1669
+ /**
1670
+ * List, read, or transition findings. Listing and reading are lock-free;
1671
+ * only a transition takes the writer lock. Reviewers may only read.
1672
+ */
1673
+ async findings(input = {}) {
1674
+ const actor = input.actor ?? { name: 'tower', role: 'tower' }
1675
+ if (typeof actor.role !== 'string') fail('finding actor role must be a string')
1676
+ const hasId = input.id !== undefined && input.id !== null && input.id !== ''
1677
+ const wantsUpdate = hasId && (input.status !== undefined || input.note !== undefined)
1678
+ if (!wantsUpdate) return this.readFindings(input)
1679
+
1680
+ return withLock(this.paths, async () => {
1681
+ const state = await this.load()
1682
+ const all = state.findings ?? []
1683
+ const finding = all.find((entry) => entry.id === input.id)
1684
+ if (!finding) fail(`unknown finding "${input.id}"`)
1685
+ if (actor.role === 'reviewer') {
1686
+ fail('reviewers may list and read findings, but only the tower or a worker may change finding status')
1687
+ }
1688
+ const now = Date.now()
1689
+ if (input.status !== undefined) {
1690
+ if (input.status === 'all') {
1691
+ fail('finding status "all" is only a list filter; pass a concrete status when updating')
1692
+ }
1693
+ if (!FINDING_STATUSES.includes(input.status)) {
1694
+ fail(`finding status must be one of ${FINDING_STATUSES.join('|')}`)
1695
+ }
1696
+ const current = finding.status ?? 'open'
1697
+ const allowed = FINDING_TRANSITIONS[current] ?? []
1698
+ if (!allowed.includes(input.status)) {
1699
+ fail(`finding ${finding.id} cannot move from ${current} to ${input.status}`)
1700
+ }
1701
+ finding.status = input.status
1702
+ if (input.status === 'fixed' || input.status === 'wontfix') finding.closedAt = now
1703
+ else delete finding.closedAt
1704
+ }
1705
+ if (input.note !== undefined) {
1706
+ if (typeof input.note !== 'string' || input.note.trim().length === 0) {
1707
+ fail('finding note must be a non-empty string')
1708
+ }
1709
+ if (!Array.isArray(finding.notes)) finding.notes = []
1710
+ finding.notes.push({ by: actor.name, at: now, text: input.note.trim() })
1711
+ }
1712
+ finding.updatedAt = now
1713
+ state.revision += 1
1714
+ state.updatedAt = now
1715
+ validateState(state, this.paths.state)
1716
+ await writeJsonAtomic(this.paths.state, state)
1717
+ await appendJsonl(this.paths.activity, {
1718
+ at: now,
1719
+ type: 'finding',
1720
+ missionId: finding.missionId,
1721
+ text: `${finding.id} ${finding.status}${input.note !== undefined ? ' +note' : ''} (${actor.name})`,
1722
+ })
1723
+ // Counts must reflect the transition just recorded.
1724
+ return { finding: { ...finding }, counts: countFindings(all), total: all.length, filtered: 1, action: 'updated' }
1725
+ })
1726
+ }
1727
+
1090
1728
  async submitReview(input) {
1091
1729
  return withLock(this.paths, async () => {
1092
1730
  const state = await this.load()
@@ -1150,7 +1788,7 @@ export class Tower {
1150
1788
  }
1151
1789
  const prior = (state.reviews ?? []).filter((review) => review.branch === branch)
1152
1790
  const reviewerRounds = prior.filter((review) => review.reviewer === reviewerName).length
1153
- const maxReviewRounds = Number(runtimeConfig().review.maxRoundsPerReviewer) || MAX_REVIEW_ROUNDS
1791
+ const maxReviewRounds = Number(this.config.review.maxRoundsPerReviewer) || MAX_REVIEW_ROUNDS
1154
1792
  if (reviewerRounds >= maxReviewRounds) {
1155
1793
  fail(
1156
1794
  `reviewer ${reviewerName} reached the ${maxReviewRounds}-round cap for ${branch}; continue with a different reviewer`,
@@ -1211,9 +1849,105 @@ export class Tower {
1211
1849
  })
1212
1850
  }
1213
1851
 
1214
- async enrichedMissions(state) {
1852
+ /** Full detail for one mission, including reviews, findings and recent activity. */
1853
+ async missionDetail(missionId) {
1854
+ const state = await this.load()
1855
+ const mission = state.missions.find((entry) => entry.id === missionId)
1856
+ if (!mission) fail(`unknown mission "${missionId}"`)
1857
+ const branch = mission.worktree ? mission.worktree.branch : null
1858
+ const reviews = (state.reviews ?? [])
1859
+ .filter((review) => review.missionId === mission.id || (branch && review.branch === branch))
1860
+ .slice()
1861
+ .sort((a, b) => b.round - a.round)
1862
+ const findings = (state.findings ?? []).filter((finding) => finding.missionId === mission.id)
1863
+ const activity = (await readActivityTail(this.paths.activity, 200)).filter(
1864
+ (entry) => entry.missionId === mission.id,
1865
+ )
1866
+ return {
1867
+ mission: fullMission(mission),
1868
+ reviews: reviews.slice(0, 10),
1869
+ findings: findings.slice(-10).map((finding) => findingSummary(finding)),
1870
+ activity: activity.slice(-20),
1871
+ }
1872
+ }
1873
+
1874
+ /** Human-readable mission board (markdown) for the current state. */
1875
+ boardMarkdown(state) {
1876
+ const lines = []
1877
+ lines.push(`# Tower missions — ${state.repoRoot}`)
1878
+ lines.push('')
1879
+ lines.push(`Base: \`${state.base}\` · revision ${state.revision} · updated ${new Date(state.updatedAt ?? Date.now()).toISOString()}`)
1880
+ const counts = {}
1881
+ for (const status of MISSION_STATUSES) counts[status] = 0
1882
+ for (const mission of state.missions) counts[mission.status] += 1
1883
+ lines.push('')
1884
+ lines.push(
1885
+ `Missions: ${state.missions.length} · ` +
1886
+ MISSION_STATUSES.map((status) => `${status} ${counts[status]}`).join(' · '),
1887
+ )
1888
+ const openFindings = (state.findings ?? []).filter((finding) => !['fixed', 'wontfix'].includes(finding.status ?? 'open'))
1889
+ lines.push(`Findings: ${openFindings.length} open of ${(state.findings ?? []).length}`)
1890
+ lines.push('')
1891
+ lines.push('| Mission | Status | Kind | Title | Owner | Tasks (done/open/dropped) | Latest review | Merged |')
1892
+ lines.push('|---|---|---|---|---|---|---|---|')
1893
+ for (const mission of state.missions) {
1894
+ const tasks = taskCounts(mission.tasks ?? [])
1895
+ const branch = mission.worktree ? mission.worktree.branch : null
1896
+ const reviews = branch ? (state.reviews ?? []).filter((review) => review.branch === branch) : []
1897
+ const latest = reviews.length > 0 ? reviews.reduce((a, b) => (a.round >= b.round ? a : b)) : null
1898
+ const reviewText = latest ? `R${latest.round} ${latest.status}/${latest.mergeVerdict}` : '—'
1899
+ lines.push(
1900
+ `| ${mission.id} | ${mission.status} | ${mission.kind} | ${String(mission.title).replaceAll('|', '\\|')} | ${
1901
+ mission.owner ?? '—'
1902
+ } | ${tasks.done}/${tasks.open}/${tasks.dropped} | ${reviewText} | ${
1903
+ mission.mergedCommit ? mission.mergedCommit.slice(0, 8) : '—'
1904
+ } |`,
1905
+ )
1906
+ }
1907
+ if ((state.roster ?? []).length > 0) {
1908
+ lines.push('')
1909
+ lines.push('## Roster')
1910
+ lines.push('')
1911
+ lines.push('| Name | Role | Mission | State | Agent |')
1912
+ lines.push('|---|---|---|---|---|')
1913
+ for (const entry of state.roster) {
1914
+ lines.push(
1915
+ `| ${entry.name} | ${entry.role} | ${entry.missionId} | ${entry.state} | \`${entry.agentId}\` |`,
1916
+ )
1917
+ }
1918
+ }
1919
+ lines.push('')
1920
+ return lines.join('\n')
1921
+ }
1922
+
1923
+ /** Write the board when docs.board is enabled; failures never break a mutation. */
1924
+ async writeBoard() {
1925
+ if (this.config.docs?.board === false) return null
1926
+ try {
1927
+ const state = await this.load()
1928
+ const file = this.config.docs?.boardLocation === 'repo'
1929
+ ? path.join(this.root, 'MISSIONS.md')
1930
+ : path.join(this.paths.dir, 'MISSIONS.md')
1931
+ const tmp = `${file}.tmp-${process.pid}-${Date.now()}`
1932
+ await fsp.writeFile(tmp, this.boardMarkdown(state), 'utf8')
1933
+ await renameWithRetry(tmp, file)
1934
+ return file
1935
+ } catch {
1936
+ return null
1937
+ }
1938
+ }
1939
+
1940
+ boardFile() {
1941
+ return this.config.docs?.boardLocation === 'repo'
1942
+ ? path.join(this.root, 'MISSIONS.md')
1943
+ : path.join(this.paths.dir, 'MISSIONS.md')
1944
+ }
1945
+
1946
+ async enrichedMissions(state, onlyIds) {
1947
+ const wanted = onlyIds ? new Set(onlyIds) : null
1215
1948
  const out = []
1216
1949
  for (const mission of state.missions) {
1950
+ if (wanted && !wanted.has(mission.id)) continue
1217
1951
  const summary = missionSummary(mission)
1218
1952
  const branch = mission.worktree ? mission.worktree.branch : null
1219
1953
  const reviews = branch ? (state.reviews ?? []).filter((review) => review.branch === branch) : []
@@ -1275,6 +2009,18 @@ export class Tower {
1275
2009
  const openTasks = mission.tasks.filter((task) => task.status === 'open')
1276
2010
  if (openTasks.length > 0) push('tasks-open', `${openTasks.length} open task(s): ${openTasks.map((task) => task.text).join('; ')}`)
1277
2011
  if ((mission.blockers ?? []).length > 0) push('blockers-present', mission.blockers.join('; '))
2012
+ if (this.config.gates.requireChecks === true) {
2013
+ const checks = mission.completionReport?.checks ?? []
2014
+ if (checks.length === 0) {
2015
+ push('no-check-evidence', `mission ${mission.id} has no completion checks; gates.requireChecks is enabled`)
2016
+ }
2017
+ }
2018
+ if (state.mergeIntent && state.mergeIntent.missionId === mission.id) {
2019
+ push(
2020
+ 'merge-intent-pending',
2021
+ `a merge intent for ${state.mergeIntent.missionId} was recorded at ${new Date(Number(state.mergeIntent.startedAt) || 0).toISOString()}; run tower_merge to reconcile it`,
2022
+ )
2023
+ }
1278
2024
 
1279
2025
  const worktree = mission.worktree ?? null
1280
2026
  let branchTip = null
@@ -1329,9 +2075,16 @@ export class Tower {
1329
2075
  const branch = worktree ? worktree.branch : null
1330
2076
  const reviews = branch ? (state.reviews ?? []).filter((review) => review.branch === branch) : []
1331
2077
  const latest = reviews.length > 0 ? reviews.reduce((a, b) => (a.round >= b.round ? a : b)) : null
1332
- if (!latest) {
2078
+ const exempt =
2079
+ mission.reviewPolicy === 'none' &&
2080
+ this.config.gates.allowReviewExemption === true &&
2081
+ String(mission.reviewExemptionReason ?? '').trim().length > 0
2082
+ if (mission.reviewPolicy === 'none' && this.config.gates.allowReviewExemption !== true) {
2083
+ push('review-exemption-invalid', `mission ${mission.id} requests no review but gates.allowReviewExemption is disabled`)
2084
+ }
2085
+ if (!exempt && !latest) {
1333
2086
  push('no-review', `branch ${branch ?? '(none)'} has no review; assign a reviewer first`)
1334
- } else {
2087
+ } else if (!exempt) {
1335
2088
  const waived = this.rebaseWaived(mission, latest)
1336
2089
  if (latest.missionId !== mission.id) {
1337
2090
  push('review-mission-mismatch', `latest review belongs to mission ${latest.missionId}`)
@@ -1347,8 +2100,26 @@ export class Tower {
1347
2100
  }
1348
2101
  }
1349
2102
 
1350
- for (const entry of (state.roster ?? []).filter((row) => row.missionId === mission.id && row.state !== 'retired')) {
2103
+ for (const entry of (state.roster ?? []).filter((row) => row.missionId === mission.id && !ROSTER_INACTIVE.has(row.state))) {
1351
2104
  if (activity[entry.agentId] === 'running') push('worker-busy', `agent ${entry.name} is still running`)
2105
+ else if (input.parentStatus && entry.parentSessionId && input.parentStatus[entry.parentSessionId] !== 'ok') {
2106
+ push(
2107
+ 'worker-status-unknown',
2108
+ `cannot prove agent ${entry.name} is stopped: its parent catalog is unavailable`,
2109
+ )
2110
+ }
2111
+ }
2112
+ const staleWorkers = (state.roster ?? []).filter(
2113
+ (row) => row.missionId === mission.id && (row.state === 'stale' || row.stale === true),
2114
+ )
2115
+ if (staleWorkers.length > 0) {
2116
+ push('worker-stale', `stale worker(s) for ${mission.id}: ${staleWorkers.map((row) => row.name).join(', ')}; adopt or interrupt before merge`)
2117
+ }
2118
+ if (this.config.gates.requireTests === true) {
2119
+ const runs = (mission.testRuns ?? []).filter((run) => run.ok === true && run.treeTip && branchTip && run.treeTip === branchTip)
2120
+ if (runs.length === 0) {
2121
+ push('tests-missing', `mission ${mission.id} has no passing test discovery for tip ${branchTip ? branchTip.slice(0, 7) : '(none)'}`)
2122
+ }
1352
2123
  }
1353
2124
 
1354
2125
  const checkoutBranch = await currentBranch(this.root)
@@ -1406,14 +2177,60 @@ export class Tower {
1406
2177
  * exists and either the branch tip or the reviewed commit is already an
1407
2178
  * ancestor of the base.
1408
2179
  */
2180
+ /** Clear a stale merge intent only when the crash left no merge in progress
2181
+ * or no merge commit. Returns true when the intent was consumed. */
2182
+ async reconcileMergeIntent(state, mission) {
2183
+ const intent = state.mergeIntent
2184
+ if (!intent || intent.missionId !== mission.id || !mission.worktree) return false
2185
+ let head = null
2186
+ try {
2187
+ head = await revParse(this.root, 'HEAD')
2188
+ } catch {
2189
+ return false
2190
+ }
2191
+ const mergeHeadPath = path.join(this.commonDir, 'MERGE_HEAD')
2192
+ const inProgress = await exists(mergeHeadPath)
2193
+ if (inProgress) return false
2194
+ let landed = null
2195
+ if (head === intent.baseTip) {
2196
+ landed = null // no merge commit was created; the intent is safe to drop
2197
+ } else {
2198
+ landed = await findLandedMerge(
2199
+ this.root,
2200
+ head,
2201
+ new Set([intent.branchTip, intent.baseTip].filter(Boolean)),
2202
+ ).catch(() => null)
2203
+ if (!landed) return false
2204
+ }
2205
+ const now = Date.now()
2206
+ delete state.mergeIntent
2207
+ state.revision += 1
2208
+ state.updatedAt = now
2209
+ await writeJsonAtomic(this.paths.state, state)
2210
+ await appendJsonl(this.paths.activity, {
2211
+ at: now,
2212
+ type: 'merge',
2213
+ missionId: mission.id,
2214
+ text: landed
2215
+ ? `reconciled merge intent; found landed commit ${String(landed.commit).slice(0, 7)}`
2216
+ : 'cleared a merge intent that created no commit',
2217
+ })
2218
+ return true
2219
+ }
2220
+
1409
2221
  async reconcileLandedMerge(state, mission) {
1410
2222
  if (!['completed', 'blocked'].includes(mission.status)) return null
1411
2223
  if (!mission.worktree) return null
1412
2224
  const branch = mission.worktree.branch
1413
2225
  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
2226
+ const exempt =
2227
+ mission.reviewPolicy === 'none' &&
2228
+ this.config.gates.allowReviewExemption === true &&
2229
+ String(mission.reviewExemptionReason ?? '').trim().length > 0
2230
+ if (reviews.length === 0 && !exempt) return null
2231
+ const latest = reviews.length > 0 ? reviews.reduce((a, b) => (a.round >= b.round ? a : b)) : null
2232
+ if (latest && !exempt && (latest.status !== 'clean' || latest.mergeVerdict !== 'merge')) return null
2233
+ if (!latest && !exempt) return null
1417
2234
  let baseTip
1418
2235
  let branchTip
1419
2236
  try {
@@ -1422,7 +2239,7 @@ export class Tower {
1422
2239
  } catch {
1423
2240
  return null
1424
2241
  }
1425
- const reviewedTip = latest.reviewedCommit
2242
+ const reviewedTip = latest?.reviewedCommit ?? null
1426
2243
  const branchLanded = await isAncestor(this.root, branchTip, baseTip)
1427
2244
  const reviewedLanded = reviewedTip ? await isAncestor(this.root, reviewedTip, baseTip) : false
1428
2245
  if (!branchLanded && !reviewedLanded) return null
@@ -1430,6 +2247,7 @@ export class Tower {
1430
2247
  const found = await findLandedMerge(this.root, baseTip, tips)
1431
2248
  const mergedCommit = found ? found.commit : reviewedLanded ? reviewedTip : branchTip
1432
2249
  const now = Date.now()
2250
+ if (state.mergeIntent?.missionId === mission.id) delete state.mergeIntent
1433
2251
  mission.status = 'merged'
1434
2252
  mission.mergedAt = now
1435
2253
  mission.mergedCommit = mergedCommit
@@ -1458,13 +2276,15 @@ export class Tower {
1458
2276
  baseTip,
1459
2277
  branchTip,
1460
2278
  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
- },
2279
+ review: latest
2280
+ ? {
2281
+ round: latest.round,
2282
+ status: latest.status,
2283
+ mergeVerdict: latest.mergeVerdict,
2284
+ reviewer: latest.reviewer,
2285
+ reviewedCommit: reviewedTip,
2286
+ }
2287
+ : null,
1468
2288
  }
1469
2289
  }
1470
2290
 
@@ -1475,6 +2295,7 @@ export class Tower {
1475
2295
  if (!mission) {
1476
2296
  return { ok: false, blockers: [{ code: 'mission-missing', detail: `unknown mission "${input.missionId}"` }] }
1477
2297
  }
2298
+ await this.reconcileMergeIntent(state, mission)
1478
2299
  const baseTip = await revParse(this.root, state.base)
1479
2300
  if (mission.status === 'merged') {
1480
2301
  return { ok: true, alreadyMerged: true, mergedCommit: mission.mergedCommit ?? null, baseTip }
@@ -1487,13 +2308,25 @@ export class Tower {
1487
2308
  const worktree = mission.worktree
1488
2309
  const baseTipBefore = await revParse(this.root, 'HEAD')
1489
2310
  const branchTip = await revParse(worktree.path, 'HEAD')
1490
- const timeoutMs = mergeTimeoutMs(input.timeoutMs)
2311
+ // Record the intent before touching Git; a crash between merge and state
2312
+ // write can be reconciled by the next tower_merge/preflight instead of
2313
+ // leaving an unexplained base tip.
2314
+ state.mergeIntent = {
2315
+ missionId: mission.id,
2316
+ baseTip: baseTipBefore,
2317
+ branchTip,
2318
+ startedAt: Date.now(),
2319
+ }
2320
+ state.revision += 1
2321
+ state.updatedAt = state.mergeIntent.startedAt
2322
+ await writeJsonAtomic(this.paths.state, state)
2323
+ const timeoutMs = mergeTimeoutMs(input.timeoutMs, this.config)
1491
2324
  let failure = null
1492
2325
  try {
1493
2326
  await git(
1494
2327
  this.root,
1495
2328
  ['merge', '--no-ff', worktree.branch, '-m', `tower: merge ${mission.id} — ${mission.title}`],
1496
- { timeoutMs, maxBuffer: runtimeConfig().merge.maxBufferBytes || MERGE_MAX_BUFFER },
2329
+ { timeoutMs, maxBuffer: this.config.merge.maxBufferBytes || MERGE_MAX_BUFFER },
1497
2330
  )
1498
2331
  } catch (error) {
1499
2332
  failure = error
@@ -1563,6 +2396,7 @@ export class Tower {
1563
2396
  }
1564
2397
  const detail = describeGitFailure(failure).slice(0, 2000)
1565
2398
  const now = Date.now()
2399
+ delete state.mergeIntent
1566
2400
  if (conflict) {
1567
2401
  mission.status = 'blocked'
1568
2402
  mission.blockers.push(`merge conflict: ${detail.slice(0, 240)}`)
@@ -1590,6 +2424,10 @@ export class Tower {
1590
2424
  }
1591
2425
 
1592
2426
  if (!expectedShape) {
2427
+ delete state.mergeIntent
2428
+ state.revision += 1
2429
+ state.updatedAt = Date.now()
2430
+ await writeJsonAtomic(this.paths.state, state).catch(() => {})
1593
2431
  return {
1594
2432
  ok: false,
1595
2433
  blockers: [
@@ -1602,6 +2440,7 @@ export class Tower {
1602
2440
  }
1603
2441
 
1604
2442
  const now = Date.now()
2443
+ delete state.mergeIntent
1605
2444
  mission.status = 'merged'
1606
2445
  mission.mergedAt = now
1607
2446
  mission.mergedCommit = newTip
@@ -1635,7 +2474,7 @@ export class Tower {
1635
2474
  if (!mission) fail(`unknown mission "${input.missionId}"`)
1636
2475
  if (!mission.worktree) fail(`mission ${mission.id} has no worktree to rebase`)
1637
2476
  const activity = input.activity ?? {}
1638
- for (const entry of (state.roster ?? []).filter((row) => row.missionId === mission.id && row.state !== 'retired')) {
2477
+ for (const entry of (state.roster ?? []).filter((row) => row.missionId === mission.id && !ROSTER_INACTIVE.has(row.state))) {
1639
2478
  if (activity[entry.agentId] === 'running') {
1640
2479
  return { ok: false, blockers: [{ code: 'worker-busy', detail: `agent ${entry.name} is running; rebase refused` }] }
1641
2480
  }
@@ -1714,7 +2553,7 @@ export class Tower {
1714
2553
  continue
1715
2554
  }
1716
2555
  const live = (state.roster ?? []).some(
1717
- (entry) => entry.missionId === mission.id && entry.state !== 'retired' && activity[entry.agentId] === 'running',
2556
+ (entry) => entry.missionId === mission.id && !ROSTER_INACTIVE.has(entry.state) && activity[entry.agentId] === 'running',
1718
2557
  )
1719
2558
  if (live) {
1720
2559
  decisions.push({ missionId: mission.id, action: 'keep', reason: 'live agent' })
@@ -1767,13 +2606,19 @@ export class Tower {
1767
2606
  })
1768
2607
  }
1769
2608
 
1770
- async status() {
2609
+ async status(input = {}) {
1771
2610
  const state = await this.load()
1772
2611
  const activity = await readActivityTail(this.paths.activity, 12)
1773
2612
  const counts = {}
1774
2613
  for (const status of MISSION_STATUSES) counts[status] = 0
1775
2614
  for (const mission of state.missions) counts[mission.status] = (counts[mission.status] ?? 0) + 1
1776
2615
  const openTasks = state.missions.reduce((sum, mission) => sum + taskCounts(mission.tasks).open, 0)
2616
+ const findingsByStatus = {}
2617
+ for (const status of FINDING_STATUSES) findingsByStatus[status] = 0
2618
+ for (const finding of state.findings ?? []) {
2619
+ const status = finding.status ?? 'open'
2620
+ findingsByStatus[status] = (findingsByStatus[status] ?? 0) + 1
2621
+ }
1777
2622
  return {
1778
2623
  repoRoot: state.repoRoot,
1779
2624
  commonDir: state.commonDir,
@@ -1786,17 +2631,27 @@ export class Tower {
1786
2631
  messages: (state.messages ?? []).length,
1787
2632
  unreadTower: this.unreadFor(state, 'tower').length,
1788
2633
  findings: (state.findings ?? []).length,
2634
+ findingsByStatus,
1789
2635
  reviews: (state.reviews ?? []).length,
2636
+ config: {
2637
+ path: this.config.configPath ?? null,
2638
+ repoConfigPresent: this.config.repoConfigPresent === true,
2639
+ spawn: {
2640
+ workerPreset: this.config.spawn?.workerPreset ?? null,
2641
+ reviewerPreset: this.config.spawn?.reviewerPreset ?? null,
2642
+ },
2643
+ },
1790
2644
  recentFindings: (state.findings ?? []).slice(-5).map((finding) => ({
1791
2645
  id: finding.id,
1792
2646
  type: finding.type,
1793
2647
  severity: finding.severity,
2648
+ status: finding.status ?? 'open',
1794
2649
  title: finding.title,
1795
2650
  missionId: finding.missionId,
1796
2651
  reportedBy: finding.reportedBy,
1797
2652
  at: finding.at,
1798
2653
  })),
1799
- missions: await this.enrichedMissions(state),
2654
+ missions: await this.enrichedMissions(state, input.missionIds),
1800
2655
  activity,
1801
2656
  }
1802
2657
  }