dsh-tower 1.2.0 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/CHANGELOG.md +96 -0
  2. package/CONTRIBUTING.md +36 -0
  3. package/README.en.md +60 -23
  4. package/README.md +54 -21
  5. package/SECURITY.md +20 -0
  6. package/cordis.patch.yml +32 -0
  7. package/docs/README.md +17 -0
  8. package/docs/architecture/overview.md +70 -0
  9. package/docs/examples/first-mission.md +58 -0
  10. package/docs/faq.md +74 -0
  11. package/docs/guide/hot-reload.md +189 -0
  12. package/docs/guide/install-upgrade-migration.md +59 -0
  13. package/docs/guide/quickstart.md +97 -0
  14. package/docs/operations/runbook.md +80 -0
  15. package/docs/operations/security.md +62 -0
  16. package/docs/reference/blockers.md +58 -0
  17. package/docs/reference/config.md +114 -0
  18. package/docs/reference/doctor.md +31 -0
  19. package/docs/reference/errors.md +50 -0
  20. package/docs/reference/events.md +45 -0
  21. package/docs/reference/operations.md +52 -0
  22. package/docs/reference/testing.md +61 -0
  23. package/docs/reference/tools.md +75 -0
  24. package/docs/zh/README.md +58 -0
  25. package/docs/zh/faq.md +72 -0
  26. package/docs/zh/guide/hot-reload.md +178 -0
  27. package/docs/zh/guide/install-upgrade-migration.md +57 -0
  28. package/docs/zh/guide/quickstart.md +96 -0
  29. package/docs/zh/operations/security.md +42 -0
  30. package/docs/zh/reference/blockers.md +62 -0
  31. package/docs/zh/reference/config.md +113 -0
  32. package/docs/zh/reference/doctor.md +30 -0
  33. package/docs/zh/reference/errors.md +52 -0
  34. package/docs/zh/reference/events.md +46 -0
  35. package/docs/zh/reference/operations.md +48 -0
  36. package/docs/zh/reference/testing.md +60 -0
  37. package/docs/zh/reference/tools.md +75 -0
  38. package/index.js +293 -49
  39. package/lib/batch.js +578 -0
  40. package/lib/config.js +381 -0
  41. package/lib/doctor.js +165 -0
  42. package/lib/errors.js +111 -0
  43. package/lib/hmr.js +196 -0
  44. package/lib/leases.js +317 -0
  45. package/lib/legacy.js +413 -0
  46. package/lib/modeStore.js +47 -16
  47. package/lib/store.js +24 -1
  48. package/lib/tests.js +628 -0
  49. package/lib/tools.js +1594 -160
  50. package/lib/tower.js +1025 -101
  51. package/lib/workspaceLock.js +117 -0
  52. package/lib/worktreeOwnership.js +166 -0
  53. package/package.json +21 -4
  54. package/reviewer.js +40 -0
  55. package/scripts/check-docs.mjs +135 -0
  56. package/scripts/enable-dev-hmr.mjs +347 -0
  57. package/skills/tower-recovery.md +40 -0
  58. package/skills/tower-review.md +16 -0
  59. package/skills/tower-worker-protocol.md +15 -0
  60. package/skills.js +63 -0
  61. package/worker.js +7 -4
package/lib/tower.js CHANGED
@@ -17,17 +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'
34
+ import { runtimeConfig, resolveRepoConfig } from './config.js'
31
35
 
32
36
  export const SCHEMA_VERSION = 1
33
37
  export const MISSION_STATUSES = [
@@ -40,6 +44,14 @@ export const MISSION_STATUSES = [
40
44
  'abandoned',
41
45
  ]
42
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']
43
55
  const TRANSITIONS = {
44
56
  planned: ['active', 'abandoned'],
45
57
  active: ['completed', 'blocked', 'paused', 'abandoned'],
@@ -62,14 +74,24 @@ function slugify(title) {
62
74
 
63
75
  export const FINDING_TYPES = ['bug', 'improve', 'vuln', 'idea']
64
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
+ }
65
84
  export const MERGE_VERDICTS = ['merge', 'fix-then-merge', 'hold']
66
85
  const MAX_REVIEW_ROUNDS = 5
67
86
  const REVIEW_STATUS_RE = /^(clean|p1-\d+items|p2-\d+items)$/
68
87
  const DEFAULT_MERGE_TIMEOUT_MS = 15 * 60 * 1000
69
88
  const MERGE_MAX_BUFFER = 64 * 1024 * 1024
70
89
 
71
- function mergeTimeoutMs(value) {
72
- if (value === undefined || value === null) return DEFAULT_MERGE_TIMEOUT_MS
90
+ function mergeTimeoutMs(value, config) {
91
+ if (value === undefined || value === null) {
92
+ const configured = (config ?? runtimeConfig()).merge.timeoutMs
93
+ return Number.isInteger(configured) && configured >= 1000 ? configured : DEFAULT_MERGE_TIMEOUT_MS
94
+ }
73
95
  const ms = Number(value)
74
96
  if (!Number.isInteger(ms) || ms < 1000) fail('timeoutMs must be an integer of at least 1000 milliseconds')
75
97
  return ms
@@ -280,7 +302,26 @@ function normalizeMissionInput(input, index) {
280
302
  }
281
303
  }
282
304
  const context = typeof input.context === 'string' ? input.context.slice(0, 4096) : ''
283
- 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 }
284
325
  }
285
326
 
286
327
  function newMission(id, input, now) {
@@ -292,6 +333,9 @@ function newMission(id, input, now) {
292
333
  tasks: input.tasks,
293
334
  deps: input.deps,
294
335
  context: input.context,
336
+ references: input.references ?? [],
337
+ reviewPolicy: input.reviewPolicy ?? 'required-clean',
338
+ reviewExemptionReason: input.reviewExemptionReason ?? '',
295
339
  status: 'planned',
296
340
  owner: null,
297
341
  blockers: [],
@@ -320,6 +364,24 @@ export function validateState(state, statePath) {
320
364
  }
321
365
  if (!Array.isArray(mission.tasks)) fail(`mission ${mission.id} has no tasks array${where}`)
322
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}`)
323
385
  }
324
386
  if (state.roster !== undefined) {
325
387
  if (!Array.isArray(state.roster)) fail(`tower state roster is not an array${where}`)
@@ -354,6 +416,9 @@ export function validateState(state, statePath) {
354
416
  if (!finding || typeof finding.id !== 'string') fail(`invalid finding entry${where}`)
355
417
  if (ids.has(finding.id)) fail(`duplicate finding id ${finding.id}${where}`)
356
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
+ }
357
422
  }
358
423
  }
359
424
  if (state.reviews !== undefined) {
@@ -403,6 +468,9 @@ export function missionSummary(mission) {
403
468
  blockers: mission.blockers ?? [],
404
469
  tasks: taskCounts(mission.tasks ?? []),
405
470
  scopeGeneration: mission.scopeGeneration ?? 1,
471
+ ownerGeneration: mission.ownerGeneration ?? 0,
472
+ pauseCause: mission.pauseCause ?? null,
473
+ reviewPolicy: mission.reviewPolicy ?? 'required-clean',
406
474
  mergedCommit: mission.mergedCommit ?? null,
407
475
  mergedAt: mission.mergedAt ?? null,
408
476
  updatedAt: mission.updatedAt,
@@ -414,20 +482,96 @@ function fullMission(mission) {
414
482
  ...missionSummary(mission),
415
483
  tasks: (mission.tasks ?? []).map((task) => ({ ...task })),
416
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,
417
508
  }
418
509
  }
419
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
+
420
560
  export class Tower {
421
- constructor(repo) {
561
+ constructor(repo, config) {
422
562
  this.root = repo.root
423
563
  this.commonDir = repo.commonDir
424
564
  this.branch = repo.branch
425
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 }
426
569
  }
427
570
 
428
571
  static async open(repoRoot) {
429
572
  const repo = await resolveRepo(repoRoot)
430
- return new Tower(repo)
573
+ const config = await resolveRepoConfig(repo)
574
+ return new Tower(repo, config)
431
575
  }
432
576
 
433
577
  async load() {
@@ -441,8 +585,52 @@ export class Tower {
441
585
  async init(input = {}) {
442
586
  await ensureDir(this.paths.dir)
443
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
+
444
608
  if (await exists(this.paths.state)) {
445
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
+ }
446
634
  return {
447
635
  created: false,
448
636
  repoRoot: state.repoRoot,
@@ -451,23 +639,24 @@ export class Tower {
451
639
  base: state.base,
452
640
  revision: state.revision,
453
641
  missions: state.missions.length,
642
+ reanchored,
643
+ dirty,
644
+ snapshotCommit: state.snapshotCommit ?? null,
454
645
  }
455
646
  }
456
647
 
457
- const now = Date.now()
458
- const committed = await hasAnyCommit(this.root)
459
- let base = typeof input.base === 'string' && input.base.trim() ? input.base.trim() : null
460
- if (base) {
461
- if (/^refs\//.test(base) || base.startsWith('origin/')) {
462
- fail(`base must be a local branch name, not ${base}`)
463
- }
464
- if (committed && !(await localBranchExists(this.root, base))) {
465
- 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')
466
655
  }
467
- } else {
468
- base = this.branch
469
- if (!base && !committed) base = 'main'
470
- 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])
471
660
  }
472
661
 
473
662
  const state = {
@@ -478,11 +667,17 @@ export class Tower {
478
667
  revision: 0,
479
668
  createdAt: now,
480
669
  updatedAt: now,
670
+ dirtyBase: dirty,
671
+ ...(snapshotCommit ? { snapshotCommit, snapshotRef } : {}),
481
672
  missions: [],
482
673
  }
483
674
  validateState(state, this.paths.state)
484
675
  await writeJsonAtomic(this.paths.state, state)
485
- 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
+ })
486
681
  return {
487
682
  created: true,
488
683
  repoRoot: this.root,
@@ -491,6 +686,10 @@ export class Tower {
491
686
  base,
492
687
  revision: 0,
493
688
  missions: 0,
689
+ reanchored: false,
690
+ dirty,
691
+ snapshotCommit,
692
+ snapshotRef,
494
693
  }
495
694
  })
496
695
  }
@@ -536,11 +735,36 @@ export class Tower {
536
735
  }
537
736
 
538
737
  async mission(input = {}) {
539
- 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')
540
740
  return withLock(this.paths, async () => {
541
741
  const state = await this.load()
542
- const mission = state.missions.find((entry) => entry.id === input.id)
543
- 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
+ }
544
768
  const now = Date.now()
545
769
  const changes = []
546
770
 
@@ -553,6 +777,20 @@ export class Tower {
553
777
  if (!allowed.includes(input.status)) {
554
778
  fail(`mission ${mission.id} cannot move from ${mission.status} to ${input.status}`)
555
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
+ }
556
794
  mission.status = input.status
557
795
  changes.push(`status=${input.status}`)
558
796
  }
@@ -586,6 +824,9 @@ export class Tower {
586
824
  if (typeof input.blocker !== 'string' || input.blocker.trim().length === 0) {
587
825
  fail('blocker must be a non-empty string')
588
826
  }
827
+ if (TERMINAL.has(mission.status)) {
828
+ fail(`mission ${mission.id} is ${mission.status}; a blocker cannot reopen a terminal mission`)
829
+ }
589
830
  mission.blockers.push(input.blocker.trim())
590
831
  mission.status = 'blocked'
591
832
  changes.push('blocker added')
@@ -657,24 +898,73 @@ export class Tower {
657
898
 
658
899
  async assertWorktree(worktree) {
659
900
  if (!worktree || typeof worktree.path !== 'string') fail('mission has no worktree')
660
- const wtPath = path.resolve(worktree.path)
661
- if (!(await exists(wtPath))) fail(`worktree is missing: ${wtPath}`)
662
- const common = await currentCommonDir(wtPath)
663
- if (!common) fail(`worktree ${wtPath} is not a Git worktree`)
664
- if (path.resolve(common) !== path.resolve(this.commonDir)) {
665
- fail(`worktree ${wtPath} belongs to a different repository`)
666
- }
667
- const branch = await currentBranch(wtPath)
668
- if (branch !== worktree.branch) {
669
- fail(`worktree ${wtPath} is on ${branch ?? 'detached HEAD'}, expected ${worktree.branch}`)
670
- }
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
671
909
  const registered = await listWorktrees(this.root)
672
- if (!registered.some((entry) => path.resolve(entry.path) === wtPath)) {
673
- 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`)
674
912
  }
675
913
  return worktree
676
914
  }
677
915
 
916
+ /** Record that a spawned agent failed or went idle, so roster state is truthful. */
917
+ async recordSpawnOutcome(input) {
918
+ return withLock(this.paths, async () => {
919
+ const state = await this.load()
920
+ const entry = (state.roster ?? []).find((row) => row.name === input.name && row.missionId === input.missionId)
921
+ if (!entry) return null
922
+ const now = Date.now()
923
+ entry.state = input.state ?? 'failed'
924
+ entry.outcome = input.detail ? String(input.detail).slice(0, 1000) : null
925
+ entry.outcomeAt = now
926
+ state.revision += 1
927
+ state.updatedAt = now
928
+ validateState(state, this.paths.state)
929
+ await writeJsonAtomic(this.paths.state, state)
930
+ await appendJsonl(this.paths.activity, {
931
+ at: now,
932
+ type: 'spawn',
933
+ missionId: input.missionId,
934
+ text: `${entry.name} ${entry.state}${entry.outcome ? `: ${entry.outcome.slice(0, 160)}` : ''}`,
935
+ })
936
+ return entry
937
+ })
938
+ }
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
+
678
968
  async provision(input = {}) {
679
969
  const missionId = input.missionId
680
970
  if (typeof missionId !== 'string' || missionId.trim().length === 0) {
@@ -689,11 +979,20 @@ export class Tower {
689
979
  return { mission, worktree: mission.worktree, created: false }
690
980
  }
691
981
 
692
- 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)
693
989
  const branch = `tower/${mission.id.toLowerCase()}-${slugify(mission.title)}`
990
+ const configuredRoot = this.config.worktrees.root
694
991
  const root = input.worktreeRoot
695
992
  ? path.resolve(input.worktreeRoot)
696
- : path.join(path.dirname(this.root), '.dsh-tower-worktrees', shortHash(this.commonDir))
993
+ : configuredRoot
994
+ ? path.resolve(configuredRoot)
995
+ : path.join(path.dirname(this.root), '.dsh-tower-worktrees', shortHash(this.commonDir))
697
996
  if (!path.isAbsolute(root)) fail('worktreeRoot must be an absolute path')
698
997
  this.assertOutside(root)
699
998
  await ensureDir(root)
@@ -704,7 +1003,7 @@ export class Tower {
704
1003
 
705
1004
  await addWorktree(this.root, { path: wtPath, branch, commit: baseCommit })
706
1005
  const now = Date.now()
707
- const worktree = { path: wtPath, branch, baseCommit, createdAt: now }
1006
+ const worktree = { path: wtPath, branch, baseCommit, createdAt: now, realPath: await canonicalPath(wtPath) }
708
1007
  mission.worktree = worktree
709
1008
  mission.baseCommit = baseCommit
710
1009
  mission.updatedAt = now
@@ -728,9 +1027,37 @@ export class Tower {
728
1027
  const mission = state.missions.find((entry) => entry.id === missionId)
729
1028
  if (!mission) fail(`unknown mission "${missionId}"`)
730
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)
731
1043
  return {
732
1044
  base: state.base,
733
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)),
734
1061
  deps: (mission.deps ?? []).map((id) => {
735
1062
  const dep = byId.get(id)
736
1063
  return dep ? { id, title: dep.title, status: dep.status } : { id, title: null, status: 'unknown' }
@@ -751,6 +1078,7 @@ export class Tower {
751
1078
  fail(`agent ${input.agentId} is already in the roster`)
752
1079
  }
753
1080
  const now = Date.now()
1081
+ const spawnTip = input.cwd ? ((await tryGit(input.cwd, ['rev-parse', 'HEAD'])) ?? '').trim() || null : null
754
1082
  const entry = {
755
1083
  name: input.name,
756
1084
  role: input.role,
@@ -760,14 +1088,39 @@ export class Tower {
760
1088
  missionId: input.missionId,
761
1089
  cwd: input.cwd ?? null,
762
1090
  branch: input.branch ?? null,
1091
+ spawnTip,
763
1092
  state: 'active',
764
1093
  spawnedAt: now,
765
1094
  }
766
- state.roster.push(entry)
767
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
768
1120
  mission.owner = input.name
769
1121
  if (mission.status === 'planned') mission.status = 'active'
770
1122
  }
1123
+ state.roster.push(entry)
771
1124
  mission.updatedAt = now
772
1125
  state.revision += 1
773
1126
  state.updatedAt = now
@@ -804,6 +1157,129 @@ export class Tower {
804
1157
  }
805
1158
  }
806
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
+
807
1283
  async markRetired(agentIds) {
808
1284
  if (!Array.isArray(agentIds) || agentIds.length === 0) return { retired: [] }
809
1285
  return withLock(this.paths, async () => {
@@ -832,6 +1308,77 @@ export class Tower {
832
1308
  })
833
1309
  }
834
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
+
835
1382
  unreadFor(state, name) {
836
1383
  return (state.messages ?? []).filter((message) => {
837
1384
  if (message.from === name) return false
@@ -861,6 +1408,7 @@ export class Tower {
861
1408
  subject: input.subject,
862
1409
  body: input.body,
863
1410
  scope: input.scope ?? null,
1411
+ consentRef: input.consentRef ?? null,
864
1412
  at: now,
865
1413
  readBy: [],
866
1414
  delivery: [],
@@ -909,6 +1457,7 @@ export class Tower {
909
1457
  for (const message of page) {
910
1458
  if (!(message.readBy ?? []).includes(reader)) {
911
1459
  message.readBy = [...(message.readBy ?? []), reader]
1460
+ message.readAt = { ...(message.readAt ?? {}), [reader]: Date.now() }
912
1461
  changed = true
913
1462
  }
914
1463
  }
@@ -929,6 +1478,8 @@ export class Tower {
929
1478
  body: message.body,
930
1479
  scope: message.scope ?? null,
931
1480
  at: message.at,
1481
+ delivery: message.delivery ?? [],
1482
+ ackAt: message.readAt?.[reader] ?? null,
932
1483
  })),
933
1484
  pageSize: page.length,
934
1485
  unreadRemaining,
@@ -936,50 +1487,64 @@ export class Tower {
936
1487
  })
937
1488
  }
938
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
+
939
1540
  async complete(input) {
940
1541
  return withLock(this.paths, async () => {
941
- const state = await this.load()
942
- const mission = state.missions.find((entry) => entry.id === input.missionId)
943
- if (!mission) fail(`unknown mission "${input.missionId}"`)
944
- const worker = (state.roster ?? []).find((entry) => entry.agentId === input.agentId)
945
- if (!worker || worker.missionId !== mission.id) {
946
- fail(`agent ${input.agentId} is not assigned to mission ${mission.id}`)
947
- }
948
- if (worker.role !== 'worker') fail(`agent ${worker.name} is a ${worker.role}, not a worker`)
949
- if (mission.status === 'completed') fail(`mission ${mission.id} is already completed`)
950
- if (!['active', 'planned'].includes(mission.status)) {
951
- fail(`mission ${mission.id} is ${mission.status}; it cannot be completed from that state`)
952
- }
953
- if (typeof input.report !== 'string' || input.report.trim().length === 0) {
954
- fail('tower_complete requires a non-empty report')
955
- }
956
- const openTasks = mission.tasks.filter((task) => task.status === 'open')
957
- if (openTasks.length > 0) {
958
- fail(
959
- `mission ${mission.id} still has ${openTasks.length} open task(s): ${openTasks
960
- .map((task) => task.text)
961
- .join('; ')}`,
962
- )
963
- }
964
- if ((mission.blockers ?? []).length > 0) {
965
- fail(`mission ${mission.id} has unresolved blockers: ${mission.blockers.join('; ')}`)
966
- }
967
- if (!mission.worktree) fail(`mission ${mission.id} has no worktree`)
968
- await this.assertWorktree(mission.worktree)
969
- const commits = await countCommits(mission.worktree.path, mission.worktree.baseCommit)
970
- if (commits < 1) {
971
- fail(`mission branch has no commits since ${String(mission.worktree.baseCommit).slice(0, 12)}`)
972
- }
973
- const unread = this.unreadFor(state, worker.name)
974
- if (unread.length > 0) {
975
- fail(
976
- `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`,
977
- )
978
- }
1542
+ const { state, mission, worker, commits } = await this.checkCompletion(input)
979
1543
 
1544
+ const checks = normalizeChecks(input.checks)
980
1545
  const now = Date.now()
981
1546
  mission.status = 'completed'
982
- mission.completionReport = { report: input.report.trim(), by: worker.name, at: now }
1547
+ mission.completionReport = { report: input.report.trim(), by: worker.name, at: now, checks }
983
1548
  mission.updatedAt = now
984
1549
  state.revision += 1
985
1550
  state.updatedAt = now
@@ -1055,6 +1620,111 @@ export class Tower {
1055
1620
  })
1056
1621
  }
1057
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
+
1058
1728
  async submitReview(input) {
1059
1729
  return withLock(this.paths, async () => {
1060
1730
  const state = await this.load()
@@ -1111,11 +1781,17 @@ export class Tower {
1111
1781
  await this.assertWorktree(mission.worktree)
1112
1782
  const tip = await revParse(mission.worktree.path, 'HEAD')
1113
1783
  const branch = mission.worktree.branch
1784
+ if (caller && caller.role === 'reviewer' && caller.spawnTip && caller.spawnTip !== tip) {
1785
+ fail(
1786
+ `reviewer ${caller.name} modified the mission branch after it was spawned (${caller.spawnTip.slice(0, 7)} -> ${tip.slice(0, 7)}); reviewers must be read-only — spawn a fresh reviewer`,
1787
+ )
1788
+ }
1114
1789
  const prior = (state.reviews ?? []).filter((review) => review.branch === branch)
1115
1790
  const reviewerRounds = prior.filter((review) => review.reviewer === reviewerName).length
1116
- if (reviewerRounds >= MAX_REVIEW_ROUNDS) {
1791
+ const maxReviewRounds = Number(this.config.review.maxRoundsPerReviewer) || MAX_REVIEW_ROUNDS
1792
+ if (reviewerRounds >= maxReviewRounds) {
1117
1793
  fail(
1118
- `reviewer ${reviewerName} reached the ${MAX_REVIEW_ROUNDS}-round cap for ${branch}; continue with a different reviewer`,
1794
+ `reviewer ${reviewerName} reached the ${maxReviewRounds}-round cap for ${branch}; continue with a different reviewer`,
1119
1795
  )
1120
1796
  }
1121
1797
  const round = prior.length === 0 ? 1 : Math.max(...prior.map((review) => review.round)) + 1
@@ -1173,9 +1849,105 @@ export class Tower {
1173
1849
  })
1174
1850
  }
1175
1851
 
1176
- 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
1177
1948
  const out = []
1178
1949
  for (const mission of state.missions) {
1950
+ if (wanted && !wanted.has(mission.id)) continue
1179
1951
  const summary = missionSummary(mission)
1180
1952
  const branch = mission.worktree ? mission.worktree.branch : null
1181
1953
  const reviews = branch ? (state.reviews ?? []).filter((review) => review.branch === branch) : []
@@ -1237,6 +2009,18 @@ export class Tower {
1237
2009
  const openTasks = mission.tasks.filter((task) => task.status === 'open')
1238
2010
  if (openTasks.length > 0) push('tasks-open', `${openTasks.length} open task(s): ${openTasks.map((task) => task.text).join('; ')}`)
1239
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
+ }
1240
2024
 
1241
2025
  const worktree = mission.worktree ?? null
1242
2026
  let branchTip = null
@@ -1291,9 +2075,16 @@ export class Tower {
1291
2075
  const branch = worktree ? worktree.branch : null
1292
2076
  const reviews = branch ? (state.reviews ?? []).filter((review) => review.branch === branch) : []
1293
2077
  const latest = reviews.length > 0 ? reviews.reduce((a, b) => (a.round >= b.round ? a : b)) : null
1294
- 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) {
1295
2086
  push('no-review', `branch ${branch ?? '(none)'} has no review; assign a reviewer first`)
1296
- } else {
2087
+ } else if (!exempt) {
1297
2088
  const waived = this.rebaseWaived(mission, latest)
1298
2089
  if (latest.missionId !== mission.id) {
1299
2090
  push('review-mission-mismatch', `latest review belongs to mission ${latest.missionId}`)
@@ -1309,8 +2100,26 @@ export class Tower {
1309
2100
  }
1310
2101
  }
1311
2102
 
1312
- 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))) {
1313
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
+ }
1314
2123
  }
1315
2124
 
1316
2125
  const checkoutBranch = await currentBranch(this.root)
@@ -1330,6 +2139,37 @@ export class Tower {
1330
2139
  }
1331
2140
  }
1332
2141
 
2142
+ /** Read-only mission diff for reviewers: branch changes against the merge base. */
2143
+ async diff(input = {}) {
2144
+ const state = await this.load()
2145
+ const mission = state.missions.find((entry) => entry.id === input.missionId)
2146
+ if (!mission) fail(`unknown mission "${input.missionId}"`)
2147
+ if (!mission.worktree) fail(`mission ${mission.id} has no worktree to diff`)
2148
+ await this.assertWorktree(mission.worktree)
2149
+ const baseTip = await revParse(this.root, state.base)
2150
+ const branchTip = await revParse(mission.worktree.path, 'HEAD')
2151
+ const mergeBase = (await git(mission.worktree.path, ['merge-base', baseTip, branchTip])).trim()
2152
+ const files = (await git(mission.worktree.path, ['diff', '--name-status', `${mergeBase}..${branchTip}`]))
2153
+ .split('\n')
2154
+ .filter(Boolean)
2155
+ const commits = (await git(mission.worktree.path, ['log', '--oneline', `${mergeBase}..${branchTip}`]))
2156
+ .split('\n')
2157
+ .filter(Boolean)
2158
+ const fullPatch = await git(mission.worktree.path, ['diff', `${mergeBase}..${branchTip}`])
2159
+ const limit = Number.isInteger(input.maxBytes) && input.maxBytes > 0 ? input.maxBytes : 64 * 1024
2160
+ return {
2161
+ missionId: mission.id,
2162
+ branch: mission.worktree.branch,
2163
+ baseTip,
2164
+ branchTip,
2165
+ mergeBase,
2166
+ files,
2167
+ commits,
2168
+ truncated: fullPatch.length > limit,
2169
+ patch: fullPatch.slice(0, limit),
2170
+ }
2171
+ }
2172
+
1333
2173
  /**
1334
2174
  * Record a merge that already landed in Git without being recorded here:
1335
2175
  * a crashed/timed-out merge, a manual merge, or a rebase that fast-forwarded
@@ -1337,14 +2177,60 @@ export class Tower {
1337
2177
  * exists and either the branch tip or the reviewed commit is already an
1338
2178
  * ancestor of the base.
1339
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
+
1340
2221
  async reconcileLandedMerge(state, mission) {
1341
2222
  if (!['completed', 'blocked'].includes(mission.status)) return null
1342
2223
  if (!mission.worktree) return null
1343
2224
  const branch = mission.worktree.branch
1344
2225
  const reviews = (state.reviews ?? []).filter((review) => review.branch === branch)
1345
- if (reviews.length === 0) return null
1346
- const latest = reviews.reduce((a, b) => (a.round >= b.round ? a : b))
1347
- 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
1348
2234
  let baseTip
1349
2235
  let branchTip
1350
2236
  try {
@@ -1353,7 +2239,7 @@ export class Tower {
1353
2239
  } catch {
1354
2240
  return null
1355
2241
  }
1356
- const reviewedTip = latest.reviewedCommit
2242
+ const reviewedTip = latest?.reviewedCommit ?? null
1357
2243
  const branchLanded = await isAncestor(this.root, branchTip, baseTip)
1358
2244
  const reviewedLanded = reviewedTip ? await isAncestor(this.root, reviewedTip, baseTip) : false
1359
2245
  if (!branchLanded && !reviewedLanded) return null
@@ -1361,6 +2247,7 @@ export class Tower {
1361
2247
  const found = await findLandedMerge(this.root, baseTip, tips)
1362
2248
  const mergedCommit = found ? found.commit : reviewedLanded ? reviewedTip : branchTip
1363
2249
  const now = Date.now()
2250
+ if (state.mergeIntent?.missionId === mission.id) delete state.mergeIntent
1364
2251
  mission.status = 'merged'
1365
2252
  mission.mergedAt = now
1366
2253
  mission.mergedCommit = mergedCommit
@@ -1389,13 +2276,15 @@ export class Tower {
1389
2276
  baseTip,
1390
2277
  branchTip,
1391
2278
  parents: found ? found.parents : null,
1392
- review: {
1393
- round: latest.round,
1394
- status: latest.status,
1395
- mergeVerdict: latest.mergeVerdict,
1396
- reviewer: latest.reviewer,
1397
- reviewedCommit: reviewedTip,
1398
- },
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,
1399
2288
  }
1400
2289
  }
1401
2290
 
@@ -1406,6 +2295,7 @@ export class Tower {
1406
2295
  if (!mission) {
1407
2296
  return { ok: false, blockers: [{ code: 'mission-missing', detail: `unknown mission "${input.missionId}"` }] }
1408
2297
  }
2298
+ await this.reconcileMergeIntent(state, mission)
1409
2299
  const baseTip = await revParse(this.root, state.base)
1410
2300
  if (mission.status === 'merged') {
1411
2301
  return { ok: true, alreadyMerged: true, mergedCommit: mission.mergedCommit ?? null, baseTip }
@@ -1418,13 +2308,25 @@ export class Tower {
1418
2308
  const worktree = mission.worktree
1419
2309
  const baseTipBefore = await revParse(this.root, 'HEAD')
1420
2310
  const branchTip = await revParse(worktree.path, 'HEAD')
1421
- 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)
1422
2324
  let failure = null
1423
2325
  try {
1424
2326
  await git(
1425
2327
  this.root,
1426
2328
  ['merge', '--no-ff', worktree.branch, '-m', `tower: merge ${mission.id} — ${mission.title}`],
1427
- { timeoutMs, maxBuffer: MERGE_MAX_BUFFER },
2329
+ { timeoutMs, maxBuffer: this.config.merge.maxBufferBytes || MERGE_MAX_BUFFER },
1428
2330
  )
1429
2331
  } catch (error) {
1430
2332
  failure = error
@@ -1494,6 +2396,7 @@ export class Tower {
1494
2396
  }
1495
2397
  const detail = describeGitFailure(failure).slice(0, 2000)
1496
2398
  const now = Date.now()
2399
+ delete state.mergeIntent
1497
2400
  if (conflict) {
1498
2401
  mission.status = 'blocked'
1499
2402
  mission.blockers.push(`merge conflict: ${detail.slice(0, 240)}`)
@@ -1521,6 +2424,10 @@ export class Tower {
1521
2424
  }
1522
2425
 
1523
2426
  if (!expectedShape) {
2427
+ delete state.mergeIntent
2428
+ state.revision += 1
2429
+ state.updatedAt = Date.now()
2430
+ await writeJsonAtomic(this.paths.state, state).catch(() => {})
1524
2431
  return {
1525
2432
  ok: false,
1526
2433
  blockers: [
@@ -1533,6 +2440,7 @@ export class Tower {
1533
2440
  }
1534
2441
 
1535
2442
  const now = Date.now()
2443
+ delete state.mergeIntent
1536
2444
  mission.status = 'merged'
1537
2445
  mission.mergedAt = now
1538
2446
  mission.mergedCommit = newTip
@@ -1566,7 +2474,7 @@ export class Tower {
1566
2474
  if (!mission) fail(`unknown mission "${input.missionId}"`)
1567
2475
  if (!mission.worktree) fail(`mission ${mission.id} has no worktree to rebase`)
1568
2476
  const activity = input.activity ?? {}
1569
- 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))) {
1570
2478
  if (activity[entry.agentId] === 'running') {
1571
2479
  return { ok: false, blockers: [{ code: 'worker-busy', detail: `agent ${entry.name} is running; rebase refused` }] }
1572
2480
  }
@@ -1645,7 +2553,7 @@ export class Tower {
1645
2553
  continue
1646
2554
  }
1647
2555
  const live = (state.roster ?? []).some(
1648
- (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',
1649
2557
  )
1650
2558
  if (live) {
1651
2559
  decisions.push({ missionId: mission.id, action: 'keep', reason: 'live agent' })
@@ -1698,13 +2606,19 @@ export class Tower {
1698
2606
  })
1699
2607
  }
1700
2608
 
1701
- async status() {
2609
+ async status(input = {}) {
1702
2610
  const state = await this.load()
1703
2611
  const activity = await readActivityTail(this.paths.activity, 12)
1704
2612
  const counts = {}
1705
2613
  for (const status of MISSION_STATUSES) counts[status] = 0
1706
2614
  for (const mission of state.missions) counts[mission.status] = (counts[mission.status] ?? 0) + 1
1707
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
+ }
1708
2622
  return {
1709
2623
  repoRoot: state.repoRoot,
1710
2624
  commonDir: state.commonDir,
@@ -1717,17 +2631,27 @@ export class Tower {
1717
2631
  messages: (state.messages ?? []).length,
1718
2632
  unreadTower: this.unreadFor(state, 'tower').length,
1719
2633
  findings: (state.findings ?? []).length,
2634
+ findingsByStatus,
1720
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
+ },
1721
2644
  recentFindings: (state.findings ?? []).slice(-5).map((finding) => ({
1722
2645
  id: finding.id,
1723
2646
  type: finding.type,
1724
2647
  severity: finding.severity,
2648
+ status: finding.status ?? 'open',
1725
2649
  title: finding.title,
1726
2650
  missionId: finding.missionId,
1727
2651
  reportedBy: finding.reportedBy,
1728
2652
  at: finding.at,
1729
2653
  })),
1730
- missions: await this.enrichedMissions(state),
2654
+ missions: await this.enrichedMissions(state, input.missionIds),
1731
2655
  activity,
1732
2656
  }
1733
2657
  }