dsh-tower 2.0.3 → 2.1.0-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (193) hide show
  1. package/CHANGELOG.md +141 -0
  2. package/COMPATIBILITY.md +38 -0
  3. package/CONTRIBUTING.md +150 -2
  4. package/README.en.md +59 -16
  5. package/README.md +57 -16
  6. package/SECURITY.md +31 -1
  7. package/adapters/default.js +10 -0
  8. package/adapters/store-fs.js +6 -0
  9. package/adapters/vcs-git.js +4 -0
  10. package/app/batch.js +79 -0
  11. package/app/board.js +25 -0
  12. package/app/checkCompletion.js +76 -0
  13. package/app/enrichedMissions.js +26 -0
  14. package/app/findings.js +78 -0
  15. package/app/gate.js +108 -0
  16. package/app/init.js +249 -0
  17. package/app/merge.js +567 -0
  18. package/app/messages.js +66 -0
  19. package/app/mission.js +35 -0
  20. package/app/missionContext.js +14 -0
  21. package/app/plan.js +42 -0
  22. package/app/provision.js +193 -0
  23. package/app/read.js +57 -0
  24. package/app/rebase.js +302 -0
  25. package/app/recording.js +72 -0
  26. package/app/review.js +145 -0
  27. package/app/roster.js +123 -0
  28. package/app/spawn.js +125 -0
  29. package/app/teardown.js +184 -0
  30. package/app/tower.js +524 -0
  31. package/cordis.patch.yml +61 -53
  32. package/docs/README.md +33 -1
  33. package/docs/architecture/overview.md +114 -14
  34. package/docs/board.md +72 -0
  35. package/docs/examples/first-mission.md +2 -1
  36. package/docs/faq.md +11 -7
  37. package/docs/guide/hot-reload.md +1 -1
  38. package/docs/guide/install-upgrade-migration.md +22 -6
  39. package/docs/guide/quickstart.md +12 -7
  40. package/docs/guide/release.md +125 -0
  41. package/docs/guide/scenarios-and-philosophy.md +86 -69
  42. package/docs/operations/security.md +65 -14
  43. package/docs/reference/blockers.md +11 -10
  44. package/docs/reference/config.generated.md +66 -0
  45. package/docs/reference/config.md +11 -7
  46. package/docs/reference/errors.generated.md +39 -0
  47. package/docs/reference/errors.md +10 -2
  48. package/docs/reference/events.md +7 -4
  49. package/docs/reference/testing.md +2 -1
  50. package/docs/reference/tools.generated.md +33 -0
  51. package/docs/reference/tools.md +8 -7
  52. package/docs/zh/README.md +4 -3
  53. package/docs/zh/faq.md +9 -6
  54. package/docs/zh/guide/hot-reload.md +1 -1
  55. package/docs/zh/guide/install-upgrade-migration.md +17 -5
  56. package/docs/zh/guide/quickstart.md +13 -8
  57. package/docs/zh/guide/scenarios-and-philosophy.md +55 -50
  58. package/docs/zh/operations/security.md +43 -7
  59. package/docs/zh/reference/blockers.md +10 -10
  60. package/docs/zh/reference/config.md +11 -7
  61. package/docs/zh/reference/errors.md +4 -1
  62. package/docs/zh/reference/events.md +6 -3
  63. package/docs/zh/reference/testing.md +3 -2
  64. package/docs/zh/reference/tools.md +8 -7
  65. package/domain/collab/batch.js +112 -0
  66. package/domain/collab/messages.js +127 -0
  67. package/domain/collab/roster.js +158 -0
  68. package/domain/collab/spawn.js +188 -0
  69. package/domain/config/schema.js +404 -0
  70. package/domain/contracts/outputs.js +279 -0
  71. package/domain/contracts/tool-inputs/collaboration.js +58 -0
  72. package/domain/contracts/tool-inputs/findings.js +29 -0
  73. package/domain/contracts/tool-inputs/index.js +94 -0
  74. package/domain/contracts/tool-inputs/integration.js +37 -0
  75. package/domain/contracts/tool-inputs/missions.js +94 -0
  76. package/domain/contracts/tool-inputs/ops.js +75 -0
  77. package/domain/contracts/tool-inputs/shared.js +25 -0
  78. package/domain/errors.js +96 -0
  79. package/domain/gate.js +125 -0
  80. package/domain/integration/gate.js +176 -0
  81. package/domain/integration/init.js +225 -0
  82. package/domain/integration/liveness.js +31 -0
  83. package/domain/integration/merge.js +377 -0
  84. package/domain/integration/provision.js +124 -0
  85. package/domain/integration/rebase.js +236 -0
  86. package/domain/integration/review.js +52 -0
  87. package/domain/integration/teardown.js +118 -0
  88. package/domain/mission.js +68 -0
  89. package/domain/missions/completion.js +114 -0
  90. package/domain/missions/findings.js +201 -0
  91. package/domain/missions/lifecycle.js +185 -0
  92. package/domain/missions/plan.js +149 -0
  93. package/domain/missions/review.js +179 -0
  94. package/domain/planning.js +118 -0
  95. package/domain/read/enrichedMissions.js +58 -0
  96. package/domain/read/missionContext.js +53 -0
  97. package/domain/read/views.js +128 -0
  98. package/domain/report.js +59 -0
  99. package/domain/roster.js +40 -0
  100. package/domain/scope.js +105 -0
  101. package/domain/state/schema.js +233 -0
  102. package/domain/state/semantics.js +117 -0
  103. package/domain/state.js +33 -0
  104. package/domain/summary.js +114 -0
  105. package/domain/vocabulary.js +179 -0
  106. package/index.js +86 -34
  107. package/lib/briefing.js +176 -0
  108. package/lib/compatibility.js +108 -0
  109. package/lib/config.js +54 -302
  110. package/lib/doctor.js +108 -9
  111. package/lib/git.js +93 -15
  112. package/lib/hmr.js +4 -3
  113. package/lib/leases.js +3 -2
  114. package/lib/legacy.js +20 -9
  115. package/lib/modeState.js +39 -0
  116. package/lib/modeStore.js +35 -33
  117. package/lib/persistence.js +349 -0
  118. package/lib/presets.js +35 -0
  119. package/lib/spawn-prechecks.js +168 -0
  120. package/lib/spawn-support.js +79 -0
  121. package/lib/store.js +57 -85
  122. package/lib/tests.js +46 -11
  123. package/lib/tool-registry.js +544 -0
  124. package/lib/workspaceLock.js +46 -22
  125. package/lib/worktreeOwnership.js +5 -4
  126. package/locale/en.json +23 -1
  127. package/locale/zh.json +22 -0
  128. package/package.json +33 -4
  129. package/ports/clock.js +6 -0
  130. package/ports/ids.js +7 -0
  131. package/ports/index.js +2 -0
  132. package/ports/store.js +11 -0
  133. package/ports/vcs.js +11 -0
  134. package/scripts/architecture.mjs +94 -0
  135. package/scripts/check-docs.mjs +44 -3
  136. package/scripts/check-pack.mjs +49 -0
  137. package/scripts/domain-metrics.mjs +48 -0
  138. package/scripts/generate-config-reference.mjs +46 -0
  139. package/scripts/generate-error-reference.mjs +29 -0
  140. package/scripts/generate-tools-reference.mjs +56 -0
  141. package/scripts/issue-audit.mjs +259 -0
  142. package/scripts/lib/cli.mjs +76 -0
  143. package/scripts/lib/tmp.mjs +20 -0
  144. package/scripts/mount-smoke/fixtures.mjs +162 -0
  145. package/scripts/mount-smoke/host.json +4 -0
  146. package/scripts/mount-smoke/probe.mjs +125 -0
  147. package/scripts/mount-smoke/surface.mjs +141 -0
  148. package/scripts/mount-smoke.mjs +270 -0
  149. package/scripts/pack-manifest.txt +203 -0
  150. package/scripts/release-check.mjs +303 -0
  151. package/skills/tower-recovery.md +7 -5
  152. package/tools/adopt.js +72 -0
  153. package/tools/batch.js +78 -0
  154. package/tools/complete.js +53 -0
  155. package/tools/diff.js +39 -0
  156. package/tools/doctor.js +50 -0
  157. package/tools/finding.js +40 -0
  158. package/tools/findings.js +39 -0
  159. package/tools/import-legacy.js +42 -0
  160. package/tools/inbox.js +39 -0
  161. package/tools/index.js +39 -0
  162. package/tools/init.js +63 -0
  163. package/tools/interrupt.js +42 -0
  164. package/tools/legacy-map.js +44 -0
  165. package/tools/merge.js +39 -0
  166. package/tools/mission.js +41 -0
  167. package/tools/plan.js +39 -0
  168. package/tools/preflight.js +44 -0
  169. package/tools/rebase.js +39 -0
  170. package/tools/recover.js +45 -0
  171. package/tools/reload.js +52 -0
  172. package/tools/resource.js +54 -0
  173. package/tools/review.js +45 -0
  174. package/tools/roster.js +60 -0
  175. package/tools/send.js +147 -0
  176. package/tools/spawn.js +196 -0
  177. package/tools/status.js +94 -0
  178. package/tools/teardown.js +42 -0
  179. package/tools/test.js +61 -0
  180. package/types/cordis.d.ts +6 -0
  181. package/types/index.d.ts +339 -0
  182. package/ui-bundle/README.md +111 -0
  183. package/ui-bundle/client.js +1980 -0
  184. package/ui-bundle/cordis.patch.yml +3 -0
  185. package/ui-bundle/icon.svg +6 -0
  186. package/ui-bundle/index.js +2 -0
  187. package/ui-bundle/locale/en.json +6 -0
  188. package/ui-bundle/locale/zh.json +6 -0
  189. package/ui-bundle/package.json +35 -0
  190. package/lib/errors.js +0 -111
  191. package/lib/tools.js +0 -2250
  192. package/lib/tower.js +0 -2689
  193. /package/{lib → domain}/batch.js +0 -0
package/lib/tower.js DELETED
@@ -1,2689 +0,0 @@
1
- // Tower domain: repo-scoped mission state machine, validation, and gates.
2
- import path from 'node:path'
3
- import fsp from 'node:fs/promises'
4
- import {
5
- git,
6
- resolveRepo,
7
- hasAnyCommit,
8
- localBranchExists,
9
- revParse,
10
- branchExists,
11
- currentBranch,
12
- currentCommonDir,
13
- addWorktree,
14
- listWorktrees,
15
- countCommits,
16
- isClean,
17
- isAncestor,
18
- tryGit,
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'
22
- import {
23
- pathsFor,
24
- exists,
25
- ensureDir,
26
- readJson,
27
- writeJsonAtomic,
28
- renameWithRetry,
29
- appendJsonl,
30
- readActivityTail,
31
- withLock,
32
- shortHash,
33
- } from './store.js'
34
- import { runtimeConfig, resolveRepoConfig } from './config.js'
35
-
36
- export const SCHEMA_VERSION = 1
37
- export const MISSION_STATUSES = [
38
- 'planned',
39
- 'active',
40
- 'completed',
41
- 'blocked',
42
- 'paused',
43
- 'merged',
44
- 'abandoned',
45
- ]
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']
55
- const TRANSITIONS = {
56
- planned: ['active', 'abandoned'],
57
- active: ['completed', 'blocked', 'paused', 'abandoned'],
58
- blocked: ['active', 'paused', 'completed', 'abandoned'],
59
- paused: ['active', 'abandoned'],
60
- completed: ['active', 'abandoned'],
61
- merged: [],
62
- abandoned: [],
63
- }
64
- const GLOB_META = /[*?[{]/
65
-
66
- function slugify(title) {
67
- const slug = String(title)
68
- .toLowerCase()
69
- .replace(/[^a-z0-9]+/g, '-')
70
- .replace(/^-+|-+$/g, '')
71
- .slice(0, 24)
72
- return slug || 'mission'
73
- }
74
-
75
- export const FINDING_TYPES = ['bug', 'improve', 'vuln', 'idea']
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
- }
84
- export const MERGE_VERDICTS = ['merge', 'fix-then-merge', 'hold']
85
- const MAX_REVIEW_ROUNDS = 5
86
- const REVIEW_STATUS_RE = /^(clean|p1-\d+items|p2-\d+items)$/
87
- const DEFAULT_MERGE_TIMEOUT_MS = 15 * 60 * 1000
88
- const MERGE_MAX_BUFFER = 64 * 1024 * 1024
89
-
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
- }
95
- const ms = Number(value)
96
- if (!Number.isInteger(ms) || ms < 1000) fail('timeoutMs must be an integer of at least 1000 milliseconds')
97
- return ms
98
- }
99
-
100
- async function commitParents(cwd, sha) {
101
- const line = await tryGit(cwd, ['rev-list', '--parents', '-n', '1', sha])
102
- return line ? line.trim().split(/\s+/).slice(1) : []
103
- }
104
-
105
- /**
106
- * Find a reachable merge commit whose second parent is one of the known tips.
107
- * `--no-ff` merges put the integrated branch on the second parent, so this is
108
- * the concrete evidence that a mission's work already landed in the base.
109
- */
110
- async function findLandedMerge(cwd, baseTip, tips) {
111
- const revs = (await tryGit(cwd, ['rev-list', '--merges', '-n', '100', baseTip])) ?? ''
112
- for (const sha of revs.split('\n').filter(Boolean)) {
113
- const parents = await commitParents(cwd, sha)
114
- if (parents.length >= 2 && tips.has(parents[1])) return { commit: sha, parents }
115
- }
116
- return null
117
- }
118
-
119
- function describeGitFailure(error) {
120
- const prefix = error?.timedOut ? 'timed out: ' : error?.bufferOverflow ? 'output buffer overflow: ' : ''
121
- return `${prefix}${String(error?.message ?? error)}`
122
- }
123
-
124
- /** A killed git process can leave lock files behind; only used after a failed merge. */
125
- async function removeStaleGitLocks(commonDir) {
126
- for (const name of ['index.lock', 'HEAD.lock', 'MERGE_HEAD.lock', 'ORIG_HEAD.lock']) {
127
- await fsp.unlink(path.join(commonDir, name)).catch(() => {})
128
- }
129
- }
130
-
131
- function globToRegExp(pattern) {
132
- let out = '^'
133
- for (let index = 0; index < pattern.length; index += 1) {
134
- const char = pattern[index]
135
- if (char === '*') {
136
- if (pattern[index + 1] === '*') {
137
- out += '.*'
138
- index += 1
139
- } else {
140
- out += '[^/]*'
141
- }
142
- } else if (char === '?') {
143
- out += '[^/]'
144
- } else {
145
- out += char.replace(/[.+^${}()|[\]\\]/g, '\\$&')
146
- }
147
- }
148
- return new RegExp(`${out}$`)
149
- }
150
-
151
- export function fileInScope(scope, file) {
152
- return (scope ?? []).some((pattern) => globToRegExp(pattern).test(file))
153
- }
154
-
155
- function fail(message) {
156
- throw new Error(message)
157
- }
158
-
159
- function isPlainObject(value) {
160
- return value !== null && typeof value === 'object' && !Array.isArray(value)
161
- }
162
-
163
- export function validateTitle(title) {
164
- if (typeof title !== 'string' || title.trim().length === 0) fail('mission title is required')
165
- if (!/^[\x20-\x7E]+$/.test(title)) {
166
- fail(`mission title must be printable ASCII: ${JSON.stringify(title)}`)
167
- }
168
- return title.trim()
169
- }
170
-
171
- export function normalizeScope(scope, { allowEmpty = false } = {}) {
172
- if (!Array.isArray(scope)) fail('mission scope must be an array of relative globs')
173
- if (scope.length === 0 && !allowEmpty) fail('mission scope must not be empty for a build mission')
174
- const out = []
175
- for (const raw of scope) {
176
- if (typeof raw !== 'string' || raw.trim().length === 0) fail('scope entries must be non-empty strings')
177
- const value = raw.replace(/\\/g, '/').replace(/^\.\//, '').replace(/\/+$/, '').trim()
178
- if (value.length === 0) fail('scope entries must be non-empty strings')
179
- if (value.startsWith('/') || /^[A-Za-z]:/.test(value)) fail(`scope must be relative: ${raw}`)
180
- if (value.split('/').some((part) => part === '..')) fail(`scope must not contain "..": ${raw}`)
181
- if (!out.includes(value)) out.push(value)
182
- }
183
- return out
184
- }
185
-
186
- function scopePrefix(pattern) {
187
- const index = pattern.search(GLOB_META)
188
- const literal = index < 0 ? pattern : pattern.slice(0, index)
189
- return literal.replace(/\/+$/, '')
190
- }
191
-
192
- export function scopesOverlap(a, b) {
193
- for (const pa of a) {
194
- for (const pb of b) {
195
- if (pa === pb) return true
196
- const x = scopePrefix(pa)
197
- const y = scopePrefix(pb)
198
- if (!x || !y) return true
199
- if (x === y) return true
200
- if (x.startsWith(`${y}/`) || y.startsWith(`${x}/`)) return true
201
- }
202
- }
203
- return false
204
- }
205
-
206
- function nextMissionNumber(missions) {
207
- let max = 0
208
- for (const mission of missions) {
209
- const match = /^M(\d+)$/.exec(mission.id)
210
- if (match) max = Math.max(max, Number(match[1]))
211
- }
212
- return max + 1
213
- }
214
-
215
- function addDependency(mission, dep) {
216
- if (mission.id === dep) return
217
- if (!mission.deps.includes(dep)) mission.deps.push(dep)
218
- }
219
-
220
- function hasDependencyPath(missionsById, fromId, toId) {
221
- const seen = new Set()
222
- const stack = [fromId]
223
- while (stack.length > 0) {
224
- const current = stack.pop()
225
- if (current === toId) return true
226
- if (seen.has(current)) continue
227
- seen.add(current)
228
- const mission = missionsById.get(current)
229
- if (!mission) continue
230
- for (const dep of mission.deps) stack.push(dep)
231
- }
232
- return false
233
- }
234
-
235
- function assertAcyclic(missions) {
236
- const byId = new Map(missions.map((mission) => [mission.id, mission]))
237
- const state = new Map()
238
- const visit = (id, trail) => {
239
- const mark = state.get(id)
240
- if (mark === 'done') return
241
- if (mark === 'visiting') fail(`mission dependencies have a cycle: ${[...trail, id].join(' -> ')}`)
242
- state.set(id, 'visiting')
243
- const mission = byId.get(id)
244
- if (mission) for (const dep of mission.deps) visit(dep, [...trail, id])
245
- state.set(id, 'done')
246
- }
247
- for (const mission of missions) visit(mission.id, [])
248
- }
249
-
250
- /**
251
- * Kimi-style heuristic: overlapping build scopes are serialized by an
252
- * auto-added dependency. It is conservative, not a proof of disjointness.
253
- */
254
- function addAutoDeps(newMissions, existingMissions) {
255
- const openExisting = existingMissions.filter((mission) => !TERMINAL.has(mission.status))
256
- const all = [...existingMissions, ...newMissions]
257
- const byId = new Map(all.map((mission) => [mission.id, mission]))
258
-
259
- for (let i = 0; i < newMissions.length; i += 1) {
260
- for (let j = i + 1; j < newMissions.length; j += 1) {
261
- const earlier = newMissions[i]
262
- const later = newMissions[j]
263
- if (earlier.kind === 'survey' || later.kind === 'survey') continue
264
- if (!scopesOverlap(earlier.scope, later.scope)) continue
265
- if (hasDependencyPath(byId, later.id, earlier.id)) continue
266
- addDependency(later, earlier.id)
267
- }
268
- const mission = newMissions[i]
269
- if (mission.kind === 'survey') continue
270
- for (const other of openExisting) {
271
- if (other.kind === 'survey') continue
272
- if (!scopesOverlap(mission.scope, other.scope)) continue
273
- if (hasDependencyPath(byId, mission.id, other.id)) continue
274
- addDependency(mission, other.id)
275
- }
276
- }
277
- }
278
-
279
- function normalizeMissionInput(input, index) {
280
- if (!isPlainObject(input)) fail(`missions[${index}] must be an object`)
281
- const title = validateTitle(input.title)
282
- const kind = input.kind === 'survey' ? 'survey' : 'build'
283
- const scope = normalizeScope(input.scope ?? [], { allowEmpty: kind === 'survey' })
284
- const tasks = []
285
- if (input.tasks !== undefined) {
286
- if (!Array.isArray(input.tasks)) fail(`missions[${index}].tasks must be an array of strings`)
287
- for (const task of input.tasks) {
288
- if (typeof task !== 'string' || task.trim().length === 0) {
289
- fail(`missions[${index}].tasks entries must be non-empty strings`)
290
- }
291
- tasks.push({ text: task.trim(), status: 'open' })
292
- }
293
- }
294
- const deps = []
295
- if (input.deps !== undefined) {
296
- if (!Array.isArray(input.deps)) fail(`missions[${index}].deps must be an array of mission ids`)
297
- for (const dep of input.deps) {
298
- if (typeof dep !== 'string' || dep.trim().length === 0) {
299
- fail(`missions[${index}].deps entries must be non-empty mission ids`)
300
- }
301
- if (!deps.includes(dep.trim())) deps.push(dep.trim())
302
- }
303
- }
304
- const context = typeof input.context === 'string' ? input.context.slice(0, 4096) : ''
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 }
325
- }
326
-
327
- function newMission(id, input, now) {
328
- return {
329
- id,
330
- title: input.title,
331
- kind: input.kind,
332
- scope: input.scope,
333
- tasks: input.tasks,
334
- deps: input.deps,
335
- context: input.context,
336
- references: input.references ?? [],
337
- reviewPolicy: input.reviewPolicy ?? 'required-clean',
338
- reviewExemptionReason: input.reviewExemptionReason ?? '',
339
- status: 'planned',
340
- owner: null,
341
- blockers: [],
342
- notes: [],
343
- reviews: [],
344
- scopeGeneration: 1,
345
- createdAt: now,
346
- updatedAt: now,
347
- }
348
- }
349
-
350
- export function validateState(state, statePath) {
351
- const where = statePath ? ` at ${statePath}` : ''
352
- if (!isPlainObject(state)) fail(`tower state is not an object${where}`)
353
- if (state.schemaVersion !== SCHEMA_VERSION) {
354
- fail(`unsupported tower state schemaVersion ${state.schemaVersion}${where}`)
355
- }
356
- if (!Array.isArray(state.missions)) fail(`tower state has no missions array${where}`)
357
- const ids = new Set()
358
- for (const mission of state.missions) {
359
- if (!isPlainObject(mission) || typeof mission.id !== 'string') fail(`mission entry without id${where}`)
360
- if (ids.has(mission.id)) fail(`duplicate mission id ${mission.id}${where}`)
361
- ids.add(mission.id)
362
- if (!MISSION_STATUSES.includes(mission.status)) {
363
- fail(`mission ${mission.id} has unknown status ${mission.status}${where}`)
364
- }
365
- if (!Array.isArray(mission.tasks)) fail(`mission ${mission.id} has no tasks array${where}`)
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}`)
385
- }
386
- if (state.roster !== undefined) {
387
- if (!Array.isArray(state.roster)) fail(`tower state roster is not an array${where}`)
388
- const names = new Set()
389
- const agents = new Set()
390
- for (const entry of state.roster) {
391
- if (!entry || typeof entry.name !== 'string' || typeof entry.agentId !== 'string') {
392
- fail(`invalid roster entry${where}`)
393
- }
394
- if (names.has(entry.name)) fail(`duplicate roster name ${entry.name}${where}`)
395
- if (agents.has(entry.agentId)) fail(`duplicate roster agent ${entry.agentId}${where}`)
396
- names.add(entry.name)
397
- agents.add(entry.agentId)
398
- }
399
- }
400
- if (state.messages !== undefined) {
401
- if (!Array.isArray(state.messages)) fail(`tower state messages is not an array${where}`)
402
- const ids = new Set()
403
- for (const message of state.messages) {
404
- if (!message || typeof message.id !== 'string' || typeof message.to !== 'string') {
405
- fail(`invalid message entry${where}`)
406
- }
407
- if (ids.has(message.id)) fail(`duplicate message id ${message.id}${where}`)
408
- ids.add(message.id)
409
- if (!Array.isArray(message.readBy)) fail(`message ${message.id} has no readBy array${where}`)
410
- }
411
- }
412
- if (state.findings !== undefined) {
413
- if (!Array.isArray(state.findings)) fail(`tower state findings is not an array${where}`)
414
- const ids = new Set()
415
- for (const finding of state.findings) {
416
- if (!finding || typeof finding.id !== 'string') fail(`invalid finding entry${where}`)
417
- if (ids.has(finding.id)) fail(`duplicate finding id ${finding.id}${where}`)
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
- }
422
- }
423
- }
424
- if (state.reviews !== undefined) {
425
- if (!Array.isArray(state.reviews)) fail(`tower state reviews is not an array${where}`)
426
- const ids = new Set()
427
- for (const review of state.reviews) {
428
- if (!review || typeof review.id !== 'string') fail(`invalid review entry${where}`)
429
- if (ids.has(review.id)) fail(`duplicate review id ${review.id}${where}`)
430
- ids.add(review.id)
431
- if (!REVIEW_STATUS_RE.test(String(review.status))) {
432
- fail(`review ${review.id} has unknown status ${review.status}${where}`)
433
- }
434
- if (!MERGE_VERDICTS.includes(review.mergeVerdict)) {
435
- fail(`review ${review.id} has unknown merge verdict ${review.mergeVerdict}${where}`)
436
- }
437
- }
438
- }
439
- return state
440
- }
441
-
442
- function taskCounts(tasks) {
443
- const counts = { total: tasks.length, done: 0, open: 0, dropped: 0 }
444
- for (const task of tasks) {
445
- if (task.status === 'done') counts.done += 1
446
- else if (task.status === 'dropped') counts.dropped += 1
447
- else counts.open += 1
448
- }
449
- return counts
450
- }
451
-
452
- export function missionSummary(mission) {
453
- return {
454
- id: mission.id,
455
- title: mission.title,
456
- kind: mission.kind,
457
- status: mission.status,
458
- scope: mission.scope,
459
- deps: mission.deps,
460
- owner: mission.owner ?? null,
461
- worktree: mission.worktree
462
- ? {
463
- path: mission.worktree.path,
464
- branch: mission.worktree.branch,
465
- baseCommit: mission.worktree.baseCommit,
466
- }
467
- : null,
468
- blockers: mission.blockers ?? [],
469
- tasks: taskCounts(mission.tasks ?? []),
470
- scopeGeneration: mission.scopeGeneration ?? 1,
471
- ownerGeneration: mission.ownerGeneration ?? 0,
472
- pauseCause: mission.pauseCause ?? null,
473
- reviewPolicy: mission.reviewPolicy ?? 'required-clean',
474
- mergedCommit: mission.mergedCommit ?? null,
475
- mergedAt: mission.mergedAt ?? null,
476
- updatedAt: mission.updatedAt,
477
- }
478
- }
479
-
480
- function fullMission(mission) {
481
- return {
482
- ...missionSummary(mission),
483
- tasks: (mission.tasks ?? []).map((task) => ({ ...task })),
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,
508
- }
509
- }
510
-
511
- function countFindings(findings) {
512
- const counts = {}
513
- for (const status of FINDING_STATUSES) counts[status] = 0
514
- for (const finding of findings) {
515
- const status = finding.status ?? 'open'
516
- counts[status] = (counts[status] ?? 0) + 1
517
- }
518
- return counts
519
- }
520
-
521
- function liveWorkerEntry(state, missionId) {
522
- return (state.roster ?? []).find(
523
- (entry) => entry.missionId === missionId && entry.role === 'worker' && !ROSTER_INACTIVE.has(entry.state),
524
- )
525
- }
526
-
527
- function workerEntries(state, missionId) {
528
- return (state.roster ?? []).filter((entry) => entry.missionId === missionId && entry.role === 'worker')
529
- }
530
-
531
- function countLiveWorkers(state) {
532
- return (state.roster ?? []).filter((entry) => entry.role === 'worker' && !ROSTER_INACTIVE.has(entry.state)).length
533
- }
534
-
535
- function normalizePauseCause(value) {
536
- if (value === undefined || value === null || value === '') return 'user'
537
- if (!PAUSE_CAUSES.includes(value)) fail(`pause_cause must be one of ${PAUSE_CAUSES.join('|')}`)
538
- return value
539
- }
540
-
541
- function normalizeChecks(checks) {
542
- if (checks === undefined) return []
543
- if (!Array.isArray(checks)) fail('checks must be an array')
544
- if (checks.length > 20) fail('checks may contain at most 20 entries')
545
- return checks.map((check, index) => {
546
- if (!isPlainObject(check)) fail(`checks[${index}] must be an object`)
547
- const name = typeof check.name === 'string' ? check.name.trim() : ''
548
- if (!name) fail(`checks[${index}].name must be a non-empty string`)
549
- const entry = { name }
550
- if (typeof check.command === 'string' && check.command.trim()) entry.command = check.command.trim()
551
- if (check.exitCode !== undefined && check.exitCode !== null) {
552
- if (!Number.isInteger(check.exitCode)) fail(`checks[${index}].exitCode must be an integer`)
553
- entry.exitCode = check.exitCode
554
- }
555
- if (typeof check.evidence === 'string' && check.evidence.trim()) entry.evidence = check.evidence.trim().slice(0, 4000)
556
- return entry
557
- })
558
- }
559
-
560
- export class Tower {
561
- constructor(repo, config) {
562
- this.root = repo.root
563
- this.commonDir = repo.commonDir
564
- this.branch = repo.branch
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 }
569
- }
570
-
571
- static async open(repoRoot) {
572
- const repo = await resolveRepo(repoRoot)
573
- const config = await resolveRepoConfig(repo)
574
- return new Tower(repo, config)
575
- }
576
-
577
- async load() {
578
- if (!(await exists(this.paths.state))) {
579
- fail(`tower is not initialized for ${this.root} — run tower_init first`)
580
- }
581
- const state = await readJson(this.paths.state)
582
- return validateState(state, this.paths.state)
583
- }
584
-
585
- async init(input = {}) {
586
- await ensureDir(this.paths.dir)
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
-
608
- if (await exists(this.paths.state)) {
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
- }
634
- return {
635
- created: false,
636
- repoRoot: state.repoRoot,
637
- commonDir: state.commonDir,
638
- statePath: this.paths.state,
639
- base: state.base,
640
- revision: state.revision,
641
- missions: state.missions.length,
642
- reanchored,
643
- dirty,
644
- snapshotCommit: state.snapshotCommit ?? null,
645
- }
646
- }
647
-
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')
655
- }
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])
660
- }
661
-
662
- const state = {
663
- schemaVersion: SCHEMA_VERSION,
664
- repoRoot: this.root,
665
- commonDir: this.commonDir,
666
- base,
667
- revision: 0,
668
- createdAt: now,
669
- updatedAt: now,
670
- dirtyBase: dirty,
671
- ...(snapshotCommit ? { snapshotCommit, snapshotRef } : {}),
672
- missions: [],
673
- }
674
- validateState(state, this.paths.state)
675
- await writeJsonAtomic(this.paths.state, state)
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
- })
681
- return {
682
- created: true,
683
- repoRoot: this.root,
684
- commonDir: this.commonDir,
685
- statePath: this.paths.state,
686
- base,
687
- revision: 0,
688
- missions: 0,
689
- reanchored: false,
690
- dirty,
691
- snapshotCommit,
692
- snapshotRef,
693
- }
694
- })
695
- }
696
-
697
- async plan(input = {}) {
698
- if (!Array.isArray(input.missions) || input.missions.length === 0) {
699
- fail('tower_plan needs at least one mission')
700
- }
701
- const prepared = input.missions.map((mission, index) => normalizeMissionInput(mission, index))
702
- return withLock(this.paths, async () => {
703
- const state = await this.load()
704
- const now = Date.now()
705
- const start = nextMissionNumber(state.missions)
706
- const assigned = prepared.map((mission, index) => ({ ...mission, id: `M${start + index}` }))
707
-
708
- const known = new Set([...state.missions.map((mission) => mission.id), ...assigned.map((mission) => mission.id)])
709
- for (const mission of assigned) {
710
- for (const dep of mission.deps) {
711
- if (!known.has(dep)) fail(`mission ${mission.id} depends on unknown mission "${dep}"`)
712
- }
713
- }
714
-
715
- addAutoDeps(assigned, state.missions)
716
- assertAcyclic([...state.missions, ...assigned])
717
-
718
- const created = assigned.map((mission) => newMission(mission.id, mission, now))
719
- state.missions.push(...created)
720
- state.revision += 1
721
- state.updatedAt = now
722
-
723
- validateState(state, this.paths.state)
724
- await writeJsonAtomic(this.paths.state, state)
725
- await appendJsonl(this.paths.activity, {
726
- at: now,
727
- type: 'plan',
728
- text: `created ${created.map((mission) => mission.id).join(', ')}`,
729
- })
730
- return {
731
- revision: state.revision,
732
- missions: created.map((mission) => fullMission(mission)),
733
- }
734
- })
735
- }
736
-
737
- async mission(input = {}) {
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')
740
- return withLock(this.paths, async () => {
741
- const state = await this.load()
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
- }
768
- const now = Date.now()
769
- const changes = []
770
-
771
- if (input.status !== undefined) {
772
- if (typeof input.status !== 'string' || !MISSION_STATUSES.includes(input.status)) {
773
- fail(`unknown mission status "${input.status}"`)
774
- }
775
- if (input.status === 'merged') fail('merged is recorded only by tower_merge, not by a generic mission patch')
776
- const allowed = TRANSITIONS[mission.status] ?? []
777
- if (!allowed.includes(input.status)) {
778
- fail(`mission ${mission.id} cannot move from ${mission.status} to ${input.status}`)
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
- }
794
- mission.status = input.status
795
- changes.push(`status=${input.status}`)
796
- }
797
-
798
- if (input.task_done !== undefined) {
799
- const needle = String(input.task_done)
800
- const task = mission.tasks.find(
801
- (entry) => entry.status === 'open' && entry.text.toLowerCase().includes(needle.toLowerCase()),
802
- )
803
- if (!task) fail(`no open task matching "${needle}" in mission ${mission.id}`)
804
- task.status = 'done'
805
- changes.push(`task_done="${task.text}"`)
806
- }
807
-
808
- if (input.task_drop !== undefined) {
809
- if (typeof input.task_drop_reason !== 'string' || input.task_drop_reason.trim().length === 0) {
810
- fail('task_drop requires task_drop_reason')
811
- }
812
- const needle = String(input.task_drop)
813
- const task = mission.tasks.find(
814
- (entry) => entry.status === 'open' && entry.text.toLowerCase().includes(needle.toLowerCase()),
815
- )
816
- if (!task) fail(`no open task matching "${needle}" in mission ${mission.id}`)
817
- task.status = 'dropped'
818
- task.note = input.task_drop_reason.trim()
819
- mission.notes.push(`task dropped: ${task.text} — ${task.note}`)
820
- changes.push(`task_drop="${task.text}"`)
821
- }
822
-
823
- if (input.blocker !== undefined) {
824
- if (typeof input.blocker !== 'string' || input.blocker.trim().length === 0) {
825
- fail('blocker must be a non-empty string')
826
- }
827
- if (TERMINAL.has(mission.status)) {
828
- fail(`mission ${mission.id} is ${mission.status}; a blocker cannot reopen a terminal mission`)
829
- }
830
- mission.blockers.push(input.blocker.trim())
831
- mission.status = 'blocked'
832
- changes.push('blocker added')
833
- }
834
-
835
- if (input.clear_blockers === true) {
836
- mission.blockers = []
837
- changes.push('blockers cleared')
838
- }
839
-
840
- if (input.note !== undefined) {
841
- if (typeof input.note !== 'string' || input.note.trim().length === 0) {
842
- fail('note must be a non-empty string')
843
- }
844
- mission.notes.push(input.note.trim())
845
- changes.push('note added')
846
- }
847
-
848
- if (input.scope !== undefined) {
849
- if (!['active', 'completed'].includes(mission.status)) {
850
- fail(`scope can only be widened for active or completed missions, not ${mission.status}`)
851
- }
852
- const scope = normalizeScope(input.scope, { allowEmpty: mission.kind === 'survey' })
853
- const others = state.missions.filter(
854
- (entry) => entry.id !== mission.id && !TERMINAL.has(entry.status) && entry.kind !== 'survey',
855
- )
856
- for (const other of others) {
857
- if (mission.kind !== 'survey' && scopesOverlap(scope, other.scope)) {
858
- fail(`widened scope overlaps mission ${other.id}; retire it or add an explicit ordering`)
859
- }
860
- }
861
- mission.scope = scope
862
- mission.scopeGeneration = (mission.scopeGeneration ?? 1) + 1
863
- mission.status = 'active'
864
- changes.push('scope widened')
865
- }
866
-
867
- if (changes.length === 0) fail('tower_mission requires at least one change')
868
- mission.updatedAt = now
869
- state.revision += 1
870
- state.updatedAt = now
871
-
872
- validateState(state, this.paths.state)
873
- await writeJsonAtomic(this.paths.state, state)
874
- await appendJsonl(this.paths.activity, {
875
- at: now,
876
- type: 'mission',
877
- missionId: mission.id,
878
- text: changes.join('; '),
879
- })
880
- return { revision: state.revision, mission: fullMission(mission) }
881
- })
882
- }
883
-
884
- assertOutside(target) {
885
- const resolved = path.resolve(target)
886
- const inside = (parent) => {
887
- const rel = path.relative(path.resolve(parent), resolved)
888
- return rel !== '' && !rel.startsWith('..') && !path.isAbsolute(rel)
889
- }
890
- if (resolved === path.resolve(this.root) || inside(this.root)) {
891
- fail(`worktree path must be outside the main checkout: ${resolved}`)
892
- }
893
- if (resolved === path.resolve(this.commonDir) || inside(this.commonDir)) {
894
- fail(`worktree path must be outside Git metadata: ${resolved}`)
895
- }
896
- return resolved
897
- }
898
-
899
- async assertWorktree(worktree) {
900
- if (!worktree || typeof worktree.path !== 'string') fail('mission has no worktree')
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
909
- const registered = await listWorktrees(this.root)
910
- if (!registered.some((entry) => path.resolve(entry.path) === path.resolve(owned.path))) {
911
- fail(`worktree ${owned.path} is not registered with git`)
912
- }
913
- return worktree
914
- }
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
-
968
- async provision(input = {}) {
969
- const missionId = input.missionId
970
- if (typeof missionId !== 'string' || missionId.trim().length === 0) {
971
- fail('tower_spawn requires missionId in this version')
972
- }
973
- return withLock(this.paths, async () => {
974
- const state = await this.load()
975
- const mission = state.missions.find((entry) => entry.id === missionId)
976
- if (!mission) fail(`unknown mission "${missionId}"`)
977
- if (mission.worktree) {
978
- await this.assertWorktree(mission.worktree)
979
- return { mission, worktree: mission.worktree, created: false }
980
- }
981
-
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)
989
- const branch = `tower/${mission.id.toLowerCase()}-${slugify(mission.title)}`
990
- const configuredRoot = this.config.worktrees.root
991
- const root = input.worktreeRoot
992
- ? path.resolve(input.worktreeRoot)
993
- : configuredRoot
994
- ? path.resolve(configuredRoot)
995
- : path.join(path.dirname(this.root), '.dsh-tower-worktrees', shortHash(this.commonDir))
996
- if (!path.isAbsolute(root)) fail('worktreeRoot must be an absolute path')
997
- this.assertOutside(root)
998
- await ensureDir(root)
999
-
1000
- const wtPath = path.join(root, mission.id)
1001
- if (await exists(wtPath)) fail(`worktree path already exists: ${wtPath}`)
1002
- if (await branchExists(this.root, branch)) fail(`branch already exists: ${branch}`)
1003
-
1004
- await addWorktree(this.root, { path: wtPath, branch, commit: baseCommit })
1005
- const now = Date.now()
1006
- const worktree = { path: wtPath, branch, baseCommit, createdAt: now, realPath: await canonicalPath(wtPath) }
1007
- mission.worktree = worktree
1008
- mission.baseCommit = baseCommit
1009
- mission.updatedAt = now
1010
- state.revision += 1
1011
- state.updatedAt = now
1012
-
1013
- validateState(state, this.paths.state)
1014
- await writeJsonAtomic(this.paths.state, state)
1015
- await appendJsonl(this.paths.activity, {
1016
- at: now,
1017
- type: 'worktree',
1018
- missionId,
1019
- text: `${branch} at ${wtPath}`,
1020
- })
1021
- return { mission, worktree, created: true }
1022
- })
1023
- }
1024
-
1025
- async missionContext(missionId) {
1026
- const state = await this.load()
1027
- const mission = state.missions.find((entry) => entry.id === missionId)
1028
- if (!mission) fail(`unknown mission "${missionId}"`)
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)
1043
- return {
1044
- base: state.base,
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)),
1061
- deps: (mission.deps ?? []).map((id) => {
1062
- const dep = byId.get(id)
1063
- return dep ? { id, title: dep.title, status: dep.status } : { id, title: null, status: 'unknown' }
1064
- }),
1065
- }
1066
- }
1067
-
1068
- async recordSpawn(input) {
1069
- return withLock(this.paths, async () => {
1070
- const state = await this.load()
1071
- const mission = state.missions.find((entry) => entry.id === input.missionId)
1072
- if (!mission) fail(`unknown mission "${input.missionId}"`)
1073
- if (!Array.isArray(state.roster)) state.roster = []
1074
- if (state.roster.some((entry) => entry.name === input.name)) {
1075
- fail(`roster name already used: ${input.name}`)
1076
- }
1077
- if (state.roster.some((entry) => entry.agentId === input.agentId)) {
1078
- fail(`agent ${input.agentId} is already in the roster`)
1079
- }
1080
- const now = Date.now()
1081
- const spawnTip = input.cwd ? ((await tryGit(input.cwd, ['rev-parse', 'HEAD'])) ?? '').trim() || null : null
1082
- const entry = {
1083
- name: input.name,
1084
- role: input.role,
1085
- agentId: input.agentId,
1086
- sessionId: input.sessionId ?? input.agentId,
1087
- parentSessionId: input.parentSessionId,
1088
- missionId: input.missionId,
1089
- cwd: input.cwd ?? null,
1090
- branch: input.branch ?? null,
1091
- spawnTip,
1092
- state: 'active',
1093
- spawnedAt: now,
1094
- }
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
1120
- mission.owner = input.name
1121
- if (mission.status === 'planned') mission.status = 'active'
1122
- }
1123
- state.roster.push(entry)
1124
- mission.updatedAt = now
1125
- state.revision += 1
1126
- state.updatedAt = now
1127
-
1128
- validateState(state, this.paths.state)
1129
- await writeJsonAtomic(this.paths.state, state)
1130
- await appendJsonl(this.paths.activity, {
1131
- at: now,
1132
- type: 'spawn',
1133
- missionId: input.missionId,
1134
- text: `${input.role} ${input.name} (${input.agentId})`,
1135
- })
1136
- return entry
1137
- })
1138
- }
1139
-
1140
- async rosterView() {
1141
- const state = await this.load()
1142
- return {
1143
- revision: state.revision,
1144
- roster: (state.roster ?? []).map((entry) => ({ ...entry })),
1145
- missions: state.missions.map((mission) => ({
1146
- id: mission.id,
1147
- status: mission.status,
1148
- owner: mission.owner ?? null,
1149
- worktree: mission.worktree
1150
- ? {
1151
- path: mission.worktree.path,
1152
- branch: mission.worktree.branch,
1153
- baseCommit: mission.worktree.baseCommit,
1154
- }
1155
- : null,
1156
- })),
1157
- }
1158
- }
1159
-
1160
- async recordTestRun(input = {}) {
1161
- return withLock(this.paths, async () => {
1162
- const state = await this.load()
1163
- const mission = state.missions.find((entry) => entry.id === input.missionId)
1164
- if (!mission) fail(`unknown mission "${input.missionId}"`)
1165
- if (!mission.testRuns) mission.testRuns = []
1166
- const receipt = { ...input.receipt, recordedAt: Date.now() }
1167
- mission.testRuns.push(receipt)
1168
- if (mission.testRuns.length > 50) mission.testRuns = mission.testRuns.slice(-50)
1169
- state.revision += 1
1170
- state.updatedAt = Date.now()
1171
- validateState(state, this.paths.state)
1172
- await writeJsonAtomic(this.paths.state, state)
1173
- await appendJsonl(this.paths.activity, {
1174
- at: Date.now(),
1175
- type: 'test',
1176
- missionId: mission.id,
1177
- text: receipt.ok === true ? `passed ${receipt.tests?.length ?? 0} test(s)` : `failed: ${String(receipt.error ?? 'unknown').slice(0, 200)}`,
1178
- })
1179
- return receipt
1180
- })
1181
- }
1182
-
1183
- async recordLegacySnapshot(snapshot) {
1184
- return withLock(this.paths, async () => {
1185
- const state = await this.load()
1186
- if (!Array.isArray(state.legacySnapshots)) state.legacySnapshots = []
1187
- const existing = state.legacySnapshots.find((entry) => entry.sourceHash === snapshot.sourceHash)
1188
- if (existing) return { snapshot: existing, duplicate: true }
1189
- state.legacySnapshots.push(snapshot)
1190
- state.revision += 1
1191
- state.updatedAt = Date.now()
1192
- validateState(state, this.paths.state)
1193
- await writeJsonAtomic(this.paths.state, state)
1194
- return { snapshot, duplicate: false }
1195
- })
1196
- }
1197
-
1198
- async recordLegacyMap(map) {
1199
- return withLock(this.paths, async () => {
1200
- const state = await this.load()
1201
- if (!Array.isArray(state.legacyMaps)) state.legacyMaps = []
1202
- const existing = state.legacyMaps.find(
1203
- (entry) => entry.sourceHash === map.sourceHash && entry.oldId === map.oldId,
1204
- )
1205
- if (existing) return { map: existing, duplicate: true }
1206
- state.legacyMaps.push(map)
1207
- state.revision += 1
1208
- state.updatedAt = Date.now()
1209
- validateState(state, this.paths.state)
1210
- await writeJsonAtomic(this.paths.state, state)
1211
- return { map, duplicate: false }
1212
- })
1213
- }
1214
-
1215
- async createBatch(input = {}, actor = 'tower') {
1216
- return withLock(this.paths, async () => {
1217
- const state = await this.load()
1218
- const normalized = validateBatchCreate(state, input)
1219
- if (!Array.isArray(state.batches)) state.batches = []
1220
- const max = state.batches.reduce((value, batch) => {
1221
- const match = /^B(\d+)$/.exec(String(batch.id ?? ''))
1222
- return match ? Math.max(value, Number(match[1])) : value
1223
- }, 0)
1224
- const batch = {
1225
- id: `B${max + 1}`,
1226
- status: 'open',
1227
- ...normalized,
1228
- createdBy: actor,
1229
- createdAt: Date.now(),
1230
- }
1231
- state.batches.push(batch)
1232
- state.revision += 1
1233
- state.updatedAt = batch.createdAt
1234
- validateState(state, this.paths.state)
1235
- await writeJsonAtomic(this.paths.state, state)
1236
- await appendJsonl(this.paths.activity, {
1237
- at: batch.createdAt,
1238
- type: 'batch',
1239
- text: `created ${batch.id}: ${batch.missionIds.join(', ')}`,
1240
- })
1241
- return batch
1242
- })
1243
- }
1244
-
1245
- async closeBatch(batchId, options = {}, actor = 'tower') {
1246
- return withLock(this.paths, async () => {
1247
- const state = await this.load()
1248
- const batch = (state.batches ?? []).find((entry) => entry.id === batchId)
1249
- if (!batch) fail(`unknown batch "${batchId}"`)
1250
- const result = validateBatchClose(state, batch, { ...options, state })
1251
- const blockers = Array.isArray(result.blockers) ? result.blockers : []
1252
- if (blockers.length > 0) return { ok: false, batch, blockers, checks: result.checks ?? [] }
1253
- batch.status = 'closed'
1254
- batch.closedAt = Date.now()
1255
- batch.closedBy = actor
1256
- batch.close = {
1257
- checks: options.checks ?? [],
1258
- treeTip: options.treeTip ?? null,
1259
- receiptHashes: options.receiptHashes ?? {},
1260
- at: batch.closedAt,
1261
- }
1262
- state.revision += 1
1263
- state.updatedAt = batch.closedAt
1264
- validateState(state, this.paths.state)
1265
- await writeJsonAtomic(this.paths.state, state)
1266
- await appendJsonl(this.paths.activity, { at: batch.closedAt, type: 'batch', text: `closed ${batch.id}` })
1267
- return { ok: true, batch, blockers: [], checks: result.checks ?? [] }
1268
- })
1269
- }
1270
-
1271
- async abandonBatch(batchId, actor = 'tower', reason = null) {
1272
- return withLock(this.paths, async () => {
1273
- const state = await this.load()
1274
- const batch = (state.batches ?? []).find((entry) => entry.id === batchId)
1275
- if (!batch) fail(`unknown batch "${batchId}"`)
1276
- if (batch.status === 'closed') fail(`batch ${batchId} is already closed`)
1277
- if (batch.status === 'abandoned') return { ok: true, batch, alreadyAbandoned: true }
1278
- batch.status = 'abandoned'
1279
- batch.abandonedAt = Date.now()
1280
- batch.abandonedBy = actor
1281
- batch.abandonReason = typeof reason === 'string' && reason.trim() ? reason.trim() : null
1282
- state.revision += 1
1283
- state.updatedAt = batch.abandonedAt
1284
- validateState(state, this.paths.state)
1285
- await writeJsonAtomic(this.paths.state, state)
1286
- await appendJsonl(this.paths.activity, { at: batch.abandonedAt, type: 'batch', text: `abandoned ${batch.id}` })
1287
- return { ok: true, batch, alreadyAbandoned: false }
1288
- })
1289
- }
1290
-
1291
- async batchStatus(batchId) {
1292
- const state = await this.load()
1293
- const batch = batchId
1294
- ? (state.batches ?? []).find((entry) => entry.id === batchId)
1295
- : null
1296
- if (batchId && !batch) fail(`unknown batch "${batchId}"`)
1297
- return {
1298
- batches: (state.batches ?? []).map((entry) => summarizeBatch(state, entry)),
1299
- ...(batch ? { batch: summarizeBatch(state, batch) } : {}),
1300
- }
1301
- }
1302
-
1303
- async markRetired(agentIds) {
1304
- if (!Array.isArray(agentIds) || agentIds.length === 0) return { retired: [] }
1305
- return withLock(this.paths, async () => {
1306
- const state = await this.load()
1307
- const retired = []
1308
- const now = Date.now()
1309
- for (const entry of state.roster ?? []) {
1310
- if (entry.state !== 'retired' && agentIds.includes(entry.agentId)) {
1311
- entry.state = 'retired'
1312
- entry.retiredAt = now
1313
- retired.push(entry.agentId)
1314
- }
1315
- }
1316
- if (retired.length > 0) {
1317
- state.revision += 1
1318
- state.updatedAt = now
1319
- validateState(state, this.paths.state)
1320
- await writeJsonAtomic(this.paths.state, state)
1321
- await appendJsonl(this.paths.activity, {
1322
- at: now,
1323
- type: 'adopt',
1324
- text: `retired ${retired.join(', ')}`,
1325
- })
1326
- }
1327
- return { retired }
1328
- })
1329
- }
1330
-
1331
- async markMissing(agentIds) {
1332
- if (!Array.isArray(agentIds) || agentIds.length === 0) return { missing: [] }
1333
- return withLock(this.paths, async () => {
1334
- const state = await this.load()
1335
- const missing = []
1336
- const now = Date.now()
1337
- for (const entry of state.roster ?? []) {
1338
- if (!ROSTER_INACTIVE.has(entry.state) && agentIds.includes(entry.agentId)) {
1339
- entry.state = 'missing'
1340
- entry.missingSince = now
1341
- missing.push(entry.agentId)
1342
- }
1343
- }
1344
- if (missing.length > 0) {
1345
- state.revision += 1
1346
- state.updatedAt = now
1347
- validateState(state, this.paths.state)
1348
- await writeJsonAtomic(this.paths.state, state)
1349
- await appendJsonl(this.paths.activity, { at: now, type: 'adopt', text: `missing ${missing.join(', ')}` })
1350
- }
1351
- return { missing }
1352
- })
1353
- }
1354
-
1355
- /** Mark live roster entries whose catalog activity disappeared after staleMs.
1356
- * `authoritative` must be true only when the parent catalog was readable;
1357
- * unknown state is never treated as stopped. */
1358
- async sweepRoster(input = {}) {
1359
- const staleMs = Number(input.staleMs) || 0
1360
- if (staleMs <= 0 || input.authoritative !== true) return { marked: [], idled: [] }
1361
- const activity = input.activity ?? {}
1362
- return withLock(this.paths, async () => {
1363
- const state = await this.load()
1364
- const now = Date.now()
1365
- const marked = []
1366
- const idled = []
1367
- for (const entry of state.roster ?? []) {
1368
- if (ROSTER_INACTIVE.has(entry.state)) continue
1369
- const observed = activity[entry.agentId]
1370
- if (observed === 'inactive') {
1371
- entry.state = 'inactive'
1372
- entry.outcome = entry.outcome ?? 'idle (catalog)'
1373
- entry.outcomeAt = now
1374
- idled.push(entry.name)
1375
- continue
1376
- }
1377
- if (observed === 'running') continue
1378
- if (observed !== undefined) continue
1379
- const age = now - Number(entry.spawnedAt ?? now)
1380
- if (age > staleMs) {
1381
- entry.state = 'stale'
1382
- entry.stale = true
1383
- entry.staleAt = now
1384
- marked.push(entry.name)
1385
- }
1386
- }
1387
- if (marked.length > 0 || idled.length > 0) {
1388
- state.revision += 1
1389
- state.updatedAt = now
1390
- validateState(state, this.paths.state)
1391
- await writeJsonAtomic(this.paths.state, state)
1392
- await appendJsonl(this.paths.activity, {
1393
- at: now,
1394
- type: 'watchdog',
1395
- text: `stale ${marked.join(', ') || '-'}; idle ${idled.join(', ') || '-'}`,
1396
- })
1397
- }
1398
- return { marked, idled }
1399
- })
1400
- }
1401
-
1402
- unreadFor(state, name) {
1403
- return (state.messages ?? []).filter((message) => {
1404
- if (message.from === name) return false
1405
- if (message.to !== name && message.to !== 'all') return false
1406
- return !(message.readBy ?? []).includes(name)
1407
- })
1408
- }
1409
-
1410
- async recordMessage(input) {
1411
- return withLock(this.paths, async () => {
1412
- const state = await this.load()
1413
- if (!Array.isArray(state.messages)) state.messages = []
1414
- const names = new Set((state.roster ?? []).map((entry) => entry.name))
1415
- names.add('tower')
1416
- if (input.to !== 'all' && !names.has(input.to)) {
1417
- fail(`unknown message recipient "${input.to}" (known: ${[...names].join(', ')}, all)`)
1418
- }
1419
- if (input.to === input.from) fail('refusing to send a message to yourself')
1420
- const now = Date.now()
1421
- const seq = (state.messageSeq ?? 0) + 1
1422
- state.messageSeq = seq
1423
- const message = {
1424
- id: `msg-${seq}`,
1425
- seq,
1426
- from: input.from,
1427
- to: input.to,
1428
- subject: input.subject,
1429
- body: input.body,
1430
- scope: input.scope ?? null,
1431
- consentRef: input.consentRef ?? null,
1432
- at: now,
1433
- readBy: [],
1434
- delivery: [],
1435
- }
1436
- state.messages.push(message)
1437
- state.revision += 1
1438
- state.updatedAt = now
1439
-
1440
- validateState(state, this.paths.state)
1441
- await writeJsonAtomic(this.paths.state, state)
1442
- await appendJsonl(this.paths.activity, {
1443
- at: now,
1444
- type: 'message',
1445
- text: `${input.from} -> ${input.to}: ${input.subject}`,
1446
- })
1447
- return { ...message, readBy: [], delivery: [] }
1448
- })
1449
- }
1450
-
1451
- async recordDelivery(messageId, results) {
1452
- return withLock(this.paths, async () => {
1453
- const state = await this.load()
1454
- const message = (state.messages ?? []).find((entry) => entry.id === messageId)
1455
- if (!message) fail(`unknown message "${messageId}"`)
1456
- message.delivery = [...(message.delivery ?? []), ...results]
1457
- state.revision += 1
1458
- state.updatedAt = Date.now()
1459
- await writeJsonAtomic(this.paths.state, state)
1460
- return message.delivery
1461
- })
1462
- }
1463
-
1464
- async inbox(input) {
1465
- const reader = input.reader
1466
- return withLock(this.paths, async () => {
1467
- const state = await this.load()
1468
- const limit = Math.max(1, Math.min(100, input.limit ?? 20))
1469
- const includeRead = input.includeRead === true
1470
- const visible = [...(state.messages ?? [])]
1471
- .reverse()
1472
- .filter((message) => (message.to === reader || message.to === 'all') && message.from !== reader)
1473
- const unread = visible.filter((message) => !(message.readBy ?? []).includes(reader))
1474
- const page = (includeRead ? visible : unread).slice(0, limit)
1475
-
1476
- let changed = false
1477
- for (const message of page) {
1478
- if (!(message.readBy ?? []).includes(reader)) {
1479
- message.readBy = [...(message.readBy ?? []), reader]
1480
- message.readAt = { ...(message.readAt ?? {}), [reader]: Date.now() }
1481
- changed = true
1482
- }
1483
- }
1484
- const unreadRemaining = visible.filter((message) => !(message.readBy ?? []).includes(reader)).length
1485
- if (changed) {
1486
- state.revision += 1
1487
- state.updatedAt = Date.now()
1488
- validateState(state, this.paths.state)
1489
- await writeJsonAtomic(this.paths.state, state)
1490
- }
1491
- return {
1492
- reader,
1493
- messages: page.map((message) => ({
1494
- id: message.id,
1495
- from: message.from,
1496
- to: message.to,
1497
- subject: message.subject,
1498
- body: message.body,
1499
- scope: message.scope ?? null,
1500
- at: message.at,
1501
- delivery: message.delivery ?? [],
1502
- ackAt: message.readAt?.[reader] ?? null,
1503
- })),
1504
- pageSize: page.length,
1505
- unreadRemaining,
1506
- }
1507
- })
1508
- }
1509
-
1510
- /** Read-only completion preconditions shared by tower_complete and the tool
1511
- * layer's test-discovery preflight. Throws on the first failed condition. */
1512
- async checkCompletion(input) {
1513
- const state = await this.load()
1514
- const mission = state.missions.find((entry) => entry.id === input.missionId)
1515
- if (!mission) fail(`unknown mission "${input.missionId}"`)
1516
- const worker = (state.roster ?? []).find((entry) => entry.agentId === input.agentId)
1517
- if (!worker || worker.missionId !== mission.id) {
1518
- fail(`agent ${input.agentId} is not assigned to mission ${mission.id}`)
1519
- }
1520
- if (worker.role !== 'worker') fail(`agent ${worker.name} is a ${worker.role}, not a worker`)
1521
- if (['retired', 'failed', 'interrupted', 'missing', 'stale'].includes(worker.state)) {
1522
- fail(`agent ${worker.name} is ${worker.state}; a stopped worker cannot report completion`)
1523
- }
1524
- if (mission.owner && mission.owner !== worker.name) {
1525
- fail(`agent ${worker.name} is not the current owner of mission ${mission.id} (owner: ${mission.owner})`)
1526
- }
1527
- if (mission.status === 'completed') fail(`mission ${mission.id} is already completed`)
1528
- if (!['active', 'planned'].includes(mission.status)) {
1529
- fail(`mission ${mission.id} is ${mission.status}; it cannot be completed from that state`)
1530
- }
1531
- if (typeof input.report !== 'string' || input.report.trim().length === 0) {
1532
- fail('tower_complete requires a non-empty report')
1533
- }
1534
- const openTasks = mission.tasks.filter((task) => task.status === 'open')
1535
- if (openTasks.length > 0) {
1536
- fail(
1537
- `mission ${mission.id} still has ${openTasks.length} open task(s): ${openTasks
1538
- .map((task) => task.text)
1539
- .join('; ')}`,
1540
- )
1541
- }
1542
- if ((mission.blockers ?? []).length > 0) {
1543
- fail(`mission ${mission.id} has unresolved blockers: ${mission.blockers.join('; ')}`)
1544
- }
1545
- if (!mission.worktree) fail(`mission ${mission.id} has no worktree`)
1546
- await this.assertWorktree(mission.worktree)
1547
- const commits = await countCommits(mission.worktree.path, mission.worktree.baseCommit)
1548
- if (commits < 1) {
1549
- fail(`mission branch has no commits since ${String(mission.worktree.baseCommit).slice(0, 12)}`)
1550
- }
1551
- const unread = this.unreadFor(state, worker.name)
1552
- if (unread.length > 0) {
1553
- fail(
1554
- `mission ${mission.id} cannot complete with ${unread.length} unread inbox message(s) for ${worker.name}; call tower_inbox until unread is 0, incorporate them, then retry`,
1555
- )
1556
- }
1557
- return { state, mission, worker, commits }
1558
- }
1559
-
1560
- async complete(input) {
1561
- return withLock(this.paths, async () => {
1562
- const { state, mission, worker, commits } = await this.checkCompletion(input)
1563
-
1564
- const checks = normalizeChecks(input.checks)
1565
- const now = Date.now()
1566
- mission.status = 'completed'
1567
- mission.completionReport = { report: input.report.trim(), by: worker.name, at: now, checks }
1568
- mission.updatedAt = now
1569
- state.revision += 1
1570
- state.updatedAt = now
1571
-
1572
- validateState(state, this.paths.state)
1573
- await writeJsonAtomic(this.paths.state, state)
1574
- await appendJsonl(this.paths.activity, {
1575
- at: now,
1576
- type: 'complete',
1577
- missionId: mission.id,
1578
- text: `${worker.name} reported completion (${commits} commit(s))`,
1579
- })
1580
- return { mission: fullMission(mission), commits, unread: 0 }
1581
- })
1582
- }
1583
-
1584
- async addFinding(input) {
1585
- return withLock(this.paths, async () => {
1586
- const state = await this.load()
1587
- if (!Array.isArray(state.findings)) state.findings = []
1588
- if (!FINDING_TYPES.includes(input.type)) {
1589
- fail(`finding type must be one of ${FINDING_TYPES.join('|')}`)
1590
- }
1591
- const title = String(input.title ?? '').trim()
1592
- if (!title) fail('finding title is required')
1593
- const summary = String(input.summary ?? '').trim()
1594
- if (!summary) fail('finding summary is required')
1595
- const details = String(input.details ?? '').trim()
1596
- if (!details) fail('finding details are required')
1597
- const suggestedFix = String(input.suggested_fix ?? '').trim()
1598
- if (!suggestedFix) fail('finding suggested_fix is required')
1599
- const severity = input.severity ?? null
1600
- if (severity !== null && !FINDING_SEVERITIES.includes(severity)) {
1601
- fail(`finding severity must be one of ${FINDING_SEVERITIES.join('|')}`)
1602
- }
1603
- let mission = null
1604
- if (input.missionId !== undefined && input.missionId !== null && input.missionId !== '') {
1605
- mission = state.missions.find((entry) => entry.id === input.missionId)
1606
- if (!mission) fail(`unknown mission "${input.missionId}"`)
1607
- }
1608
- const now = Date.now()
1609
- const seq = (state.findingSeq ?? 0) + 1
1610
- state.findingSeq = seq
1611
- const finding = {
1612
- id: `F${seq}`,
1613
- seq,
1614
- type: input.type,
1615
- title,
1616
- severity,
1617
- summary,
1618
- location: typeof input.location === 'string' && input.location.trim() ? input.location.trim() : null,
1619
- details,
1620
- suggested_fix: suggestedFix,
1621
- missionId: mission ? mission.id : null,
1622
- branch: mission && mission.worktree ? mission.worktree.branch : null,
1623
- reportedBy: input.reportedBy,
1624
- at: now,
1625
- status: 'open',
1626
- }
1627
- state.findings.push(finding)
1628
- state.revision += 1
1629
- state.updatedAt = now
1630
-
1631
- validateState(state, this.paths.state)
1632
- await writeJsonAtomic(this.paths.state, state)
1633
- await appendJsonl(this.paths.activity, {
1634
- at: now,
1635
- type: 'finding',
1636
- missionId: finding.missionId,
1637
- text: `${finding.id} ${finding.type}: ${finding.title}`,
1638
- })
1639
- return finding
1640
- })
1641
- }
1642
-
1643
- /** List or read findings. Read-only paths never take the writer lock. */
1644
- async readFindings(input = {}) {
1645
- const state = await this.load()
1646
- const all = state.findings ?? []
1647
- const counts = countFindings(all)
1648
- if (input.id !== undefined && input.id !== null && input.id !== '') {
1649
- const finding = all.find((entry) => entry.id === input.id)
1650
- if (!finding) fail(`unknown finding "${input.id}"`)
1651
- return { finding: { ...finding }, counts, total: all.length, filtered: 1, action: 'read' }
1652
- }
1653
- if (input.status !== undefined && input.status !== 'all') {
1654
- if (!FINDING_STATUSES.includes(input.status)) {
1655
- fail(`finding status must be one of ${FINDING_STATUSES.join('|')}`)
1656
- }
1657
- }
1658
- let visible = all
1659
- if (input.status !== undefined && input.status !== 'all') {
1660
- visible = visible.filter((finding) => (finding.status ?? 'open') === input.status)
1661
- }
1662
- if (input.type !== undefined) {
1663
- if (!FINDING_TYPES.includes(input.type)) fail(`finding type must be one of ${FINDING_TYPES.join('|')}`)
1664
- visible = visible.filter((finding) => finding.type === input.type)
1665
- }
1666
- if (input.severity !== undefined) {
1667
- if (!FINDING_SEVERITIES.includes(input.severity)) {
1668
- fail(`finding severity must be one of ${FINDING_SEVERITIES.join('|')}`)
1669
- }
1670
- visible = visible.filter((finding) => finding.severity === input.severity)
1671
- }
1672
- if (input.missionId !== undefined && input.missionId !== null && input.missionId !== '') {
1673
- visible = visible.filter((finding) => finding.missionId === input.missionId)
1674
- }
1675
- if (input.open === true) {
1676
- visible = visible.filter((finding) => !['fixed', 'wontfix'].includes(finding.status ?? 'open'))
1677
- }
1678
- const limit = Math.max(1, Math.min(200, Number.isInteger(input.limit) ? input.limit : 50))
1679
- const page = visible.slice(-limit).reverse()
1680
- return {
1681
- findings: page.map((finding) => findingSummary(finding)),
1682
- counts,
1683
- total: all.length,
1684
- filtered: visible.length,
1685
- action: 'list',
1686
- }
1687
- }
1688
-
1689
- /**
1690
- * List, read, or transition findings. Listing and reading are lock-free;
1691
- * only a transition takes the writer lock. Reviewers may only read.
1692
- */
1693
- async findings(input = {}) {
1694
- const actor = input.actor ?? { name: 'tower', role: 'tower' }
1695
- if (typeof actor.role !== 'string') fail('finding actor role must be a string')
1696
- const hasId = input.id !== undefined && input.id !== null && input.id !== ''
1697
- const wantsUpdate = hasId && (input.status !== undefined || input.note !== undefined)
1698
- if (!wantsUpdate) return this.readFindings(input)
1699
-
1700
- return withLock(this.paths, async () => {
1701
- const state = await this.load()
1702
- const all = state.findings ?? []
1703
- const finding = all.find((entry) => entry.id === input.id)
1704
- if (!finding) fail(`unknown finding "${input.id}"`)
1705
- if (actor.role === 'reviewer') {
1706
- fail('reviewers may list and read findings, but only the tower or a worker may change finding status')
1707
- }
1708
- const now = Date.now()
1709
- if (input.status !== undefined) {
1710
- if (input.status === 'all') {
1711
- fail('finding status "all" is only a list filter; pass a concrete status when updating')
1712
- }
1713
- if (!FINDING_STATUSES.includes(input.status)) {
1714
- fail(`finding status must be one of ${FINDING_STATUSES.join('|')}`)
1715
- }
1716
- const current = finding.status ?? 'open'
1717
- const allowed = FINDING_TRANSITIONS[current] ?? []
1718
- if (!allowed.includes(input.status)) {
1719
- fail(`finding ${finding.id} cannot move from ${current} to ${input.status}`)
1720
- }
1721
- finding.status = input.status
1722
- if (input.status === 'fixed' || input.status === 'wontfix') finding.closedAt = now
1723
- else delete finding.closedAt
1724
- }
1725
- if (input.note !== undefined) {
1726
- if (typeof input.note !== 'string' || input.note.trim().length === 0) {
1727
- fail('finding note must be a non-empty string')
1728
- }
1729
- if (!Array.isArray(finding.notes)) finding.notes = []
1730
- finding.notes.push({ by: actor.name, at: now, text: input.note.trim() })
1731
- }
1732
- finding.updatedAt = now
1733
- state.revision += 1
1734
- state.updatedAt = now
1735
- validateState(state, this.paths.state)
1736
- await writeJsonAtomic(this.paths.state, state)
1737
- await appendJsonl(this.paths.activity, {
1738
- at: now,
1739
- type: 'finding',
1740
- missionId: finding.missionId,
1741
- text: `${finding.id} ${finding.status}${input.note !== undefined ? ' +note' : ''} (${actor.name})`,
1742
- })
1743
- // Counts must reflect the transition just recorded.
1744
- return { finding: { ...finding }, counts: countFindings(all), total: all.length, filtered: 1, action: 'updated' }
1745
- })
1746
- }
1747
-
1748
- async submitReview(input) {
1749
- return withLock(this.paths, async () => {
1750
- const state = await this.load()
1751
- const mission = state.missions.find((entry) => entry.id === input.missionId)
1752
- if (!mission) fail(`unknown mission "${input.missionId}"`)
1753
- if (!mission.worktree) fail(`mission ${mission.id} has no worktree to review`)
1754
-
1755
- const caller = (state.roster ?? []).find((entry) => entry.agentId === input.agentId)
1756
- let reviewerName
1757
- let reviewerRole
1758
- if (caller) {
1759
- if (caller.role !== 'reviewer') {
1760
- fail(`agent ${caller.name} is a ${caller.role}; only an assigned reviewer or the tower (break-glass) may review`)
1761
- }
1762
- if (caller.missionId !== mission.id) {
1763
- fail(`reviewer ${caller.name} was not assigned to mission ${mission.id}`)
1764
- }
1765
- if (mission.owner && caller.name === mission.owner) fail('the reviewer must differ from the worker')
1766
- reviewerName = caller.name
1767
- reviewerRole = 'reviewer'
1768
- } else {
1769
- if (typeof input.breakGlassReason !== 'string' || input.breakGlassReason.trim().length === 0) {
1770
- fail('a tower-submitted review is break-glass and requires a non-empty breakGlassReason')
1771
- }
1772
- reviewerName = 'tower'
1773
- reviewerRole = 'tower'
1774
- }
1775
-
1776
- if (mission.status !== 'completed') {
1777
- fail(`mission ${mission.id} is ${mission.status}; only a completed mission (awaiting review) can be reviewed`)
1778
- }
1779
- if (!REVIEW_STATUS_RE.test(String(input.status ?? ''))) {
1780
- fail('status must be "clean", "p1-Nitems", or "p2-Nitems"')
1781
- }
1782
- if (!MERGE_VERDICTS.includes(input.merge)) {
1783
- fail(`merge verdict must be one of ${MERGE_VERDICTS.join('|')}`)
1784
- }
1785
- const findingsText = String(input.findings ?? '').trim()
1786
- if (!findingsText) fail('findings text is required; write "none" for a clean verdict')
1787
- if (input.status === 'clean' && findingsText.toLowerCase() !== 'none') {
1788
- fail('a clean verdict requires findings text "none"')
1789
- }
1790
- if (input.status !== 'clean' && findingsText.toLowerCase() === 'none') {
1791
- fail('a non-clean verdict must describe its findings')
1792
- }
1793
- const decision = String(input.decision ?? '').trim()
1794
- if (!decision) fail('decision (the reasoning behind the verdict) is required')
1795
- let checks = []
1796
- if (input.checks !== undefined) {
1797
- if (!Array.isArray(input.checks)) fail('checks must be an array of strings')
1798
- checks = input.checks.map((entry) => String(entry))
1799
- }
1800
-
1801
- await this.assertWorktree(mission.worktree)
1802
- const tip = await revParse(mission.worktree.path, 'HEAD')
1803
- const branch = mission.worktree.branch
1804
- if (caller && caller.role === 'reviewer' && caller.spawnTip && caller.spawnTip !== tip) {
1805
- fail(
1806
- `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`,
1807
- )
1808
- }
1809
- const prior = (state.reviews ?? []).filter((review) => review.branch === branch)
1810
- const reviewerRounds = prior.filter((review) => review.reviewer === reviewerName).length
1811
- const maxReviewRounds = Number(this.config.review.maxRoundsPerReviewer) || MAX_REVIEW_ROUNDS
1812
- if (reviewerRounds >= maxReviewRounds) {
1813
- fail(
1814
- `reviewer ${reviewerName} reached the ${maxReviewRounds}-round cap for ${branch}; continue with a different reviewer`,
1815
- )
1816
- }
1817
- const round = prior.length === 0 ? 1 : Math.max(...prior.map((review) => review.round)) + 1
1818
-
1819
- const now = Date.now()
1820
- const seq = (state.reviewSeq ?? 0) + 1
1821
- state.reviewSeq = seq
1822
- const review = {
1823
- id: `R${seq}`,
1824
- seq,
1825
- missionId: mission.id,
1826
- branch,
1827
- reviewedCommit: tip,
1828
- status: input.status,
1829
- mergeVerdict: input.merge,
1830
- findings: findingsText,
1831
- checks,
1832
- decision,
1833
- reviewer: reviewerName,
1834
- reviewerRole,
1835
- round,
1836
- at: now,
1837
- ...(reviewerRole === 'tower' ? { breakGlassReason: input.breakGlassReason.trim() } : {}),
1838
- }
1839
- if (!Array.isArray(state.reviews)) state.reviews = []
1840
- state.reviews.push(review)
1841
-
1842
- if (input.status !== 'clean') {
1843
- mission.status = 'active'
1844
- mission.notes.push(
1845
- `review round ${round} by ${reviewerName}: ${input.status} (${input.merge}) — returned to active`,
1846
- )
1847
- } else {
1848
- mission.notes.push(`review round ${round} by ${reviewerName}: clean (${input.merge})`)
1849
- }
1850
- mission.updatedAt = now
1851
- state.revision += 1
1852
- state.updatedAt = now
1853
-
1854
- validateState(state, this.paths.state)
1855
- await writeJsonAtomic(this.paths.state, state)
1856
- await appendJsonl(this.paths.activity, {
1857
- at: now,
1858
- type: 'review',
1859
- missionId: mission.id,
1860
- text: `round ${round} ${reviewerName}: ${input.status} / ${input.merge} @ ${tip.slice(0, 12)}`,
1861
- })
1862
- return {
1863
- review,
1864
- branchTip: tip,
1865
- missionStatus: mission.status,
1866
- round,
1867
- reviewerRounds: reviewerRounds + 1,
1868
- }
1869
- })
1870
- }
1871
-
1872
- /** Full detail for one mission, including reviews, findings and recent activity. */
1873
- async missionDetail(missionId) {
1874
- const state = await this.load()
1875
- const mission = state.missions.find((entry) => entry.id === missionId)
1876
- if (!mission) fail(`unknown mission "${missionId}"`)
1877
- const branch = mission.worktree ? mission.worktree.branch : null
1878
- const reviews = (state.reviews ?? [])
1879
- .filter((review) => review.missionId === mission.id || (branch && review.branch === branch))
1880
- .slice()
1881
- .sort((a, b) => b.round - a.round)
1882
- const findings = (state.findings ?? []).filter((finding) => finding.missionId === mission.id)
1883
- const activity = (await readActivityTail(this.paths.activity, 200)).filter(
1884
- (entry) => entry.missionId === mission.id,
1885
- )
1886
- return {
1887
- mission: fullMission(mission),
1888
- reviews: reviews.slice(0, 10),
1889
- findings: findings.slice(-10).map((finding) => findingSummary(finding)),
1890
- activity: activity.slice(-20),
1891
- }
1892
- }
1893
-
1894
- /** Human-readable mission board (markdown) for the current state. */
1895
- boardMarkdown(state) {
1896
- const lines = []
1897
- lines.push(`# Tower missions — ${state.repoRoot}`)
1898
- lines.push('')
1899
- lines.push(`Base: \`${state.base}\` · revision ${state.revision} · updated ${new Date(state.updatedAt ?? Date.now()).toISOString()}`)
1900
- const counts = {}
1901
- for (const status of MISSION_STATUSES) counts[status] = 0
1902
- for (const mission of state.missions) counts[mission.status] += 1
1903
- lines.push('')
1904
- lines.push(
1905
- `Missions: ${state.missions.length} · ` +
1906
- MISSION_STATUSES.map((status) => `${status} ${counts[status]}`).join(' · '),
1907
- )
1908
- const openFindings = (state.findings ?? []).filter((finding) => !['fixed', 'wontfix'].includes(finding.status ?? 'open'))
1909
- lines.push(`Findings: ${openFindings.length} open of ${(state.findings ?? []).length}`)
1910
- lines.push('')
1911
- lines.push('| Mission | Status | Kind | Title | Owner | Tasks (done/open/dropped) | Latest review | Merged |')
1912
- lines.push('|---|---|---|---|---|---|---|---|')
1913
- for (const mission of state.missions) {
1914
- const tasks = taskCounts(mission.tasks ?? [])
1915
- const branch = mission.worktree ? mission.worktree.branch : null
1916
- const reviews = branch ? (state.reviews ?? []).filter((review) => review.branch === branch) : []
1917
- const latest = reviews.length > 0 ? reviews.reduce((a, b) => (a.round >= b.round ? a : b)) : null
1918
- const reviewText = latest ? `R${latest.round} ${latest.status}/${latest.mergeVerdict}` : '—'
1919
- lines.push(
1920
- `| ${mission.id} | ${mission.status} | ${mission.kind} | ${String(mission.title).replaceAll('|', '\\|')} | ${
1921
- mission.owner ?? '—'
1922
- } | ${tasks.done}/${tasks.open}/${tasks.dropped} | ${reviewText} | ${
1923
- mission.mergedCommit ? mission.mergedCommit.slice(0, 8) : '—'
1924
- } |`,
1925
- )
1926
- }
1927
- if ((state.roster ?? []).length > 0) {
1928
- lines.push('')
1929
- lines.push('## Roster')
1930
- lines.push('')
1931
- lines.push('| Name | Role | Mission | State | Agent |')
1932
- lines.push('|---|---|---|---|---|')
1933
- for (const entry of state.roster) {
1934
- lines.push(
1935
- `| ${entry.name} | ${entry.role} | ${entry.missionId} | ${entry.state} | \`${entry.agentId}\` |`,
1936
- )
1937
- }
1938
- }
1939
- lines.push('')
1940
- return lines.join('\n')
1941
- }
1942
-
1943
- /** Write the board when docs.board is enabled; failures never break a mutation. */
1944
- async writeBoard() {
1945
- if (this.config.docs?.board === false) return null
1946
- try {
1947
- const state = await this.load()
1948
- const file = this.config.docs?.boardLocation === 'repo'
1949
- ? path.join(this.root, 'MISSIONS.md')
1950
- : path.join(this.paths.dir, 'MISSIONS.md')
1951
- const tmp = `${file}.tmp-${process.pid}-${Date.now()}`
1952
- await fsp.writeFile(tmp, this.boardMarkdown(state), 'utf8')
1953
- await renameWithRetry(tmp, file)
1954
- return file
1955
- } catch {
1956
- return null
1957
- }
1958
- }
1959
-
1960
- boardFile() {
1961
- return this.config.docs?.boardLocation === 'repo'
1962
- ? path.join(this.root, 'MISSIONS.md')
1963
- : path.join(this.paths.dir, 'MISSIONS.md')
1964
- }
1965
-
1966
- async enrichedMissions(state, onlyIds) {
1967
- const wanted = onlyIds ? new Set(onlyIds) : null
1968
- const out = []
1969
- for (const mission of state.missions) {
1970
- if (wanted && !wanted.has(mission.id)) continue
1971
- const summary = missionSummary(mission)
1972
- const branch = mission.worktree ? mission.worktree.branch : null
1973
- const reviews = branch ? (state.reviews ?? []).filter((review) => review.branch === branch) : []
1974
- const latest = reviews.length > 0 ? reviews.reduce((a, b) => (a.round >= b.round ? a : b)) : null
1975
- let tipMoved = false
1976
- if (latest && mission.worktree && (await exists(mission.worktree.path))) {
1977
- try {
1978
- const tip = await revParse(mission.worktree.path, 'HEAD')
1979
- tipMoved = tip !== latest.reviewedCommit
1980
- } catch {
1981
- tipMoved = false
1982
- }
1983
- }
1984
- out.push({
1985
- ...summary,
1986
- review: latest
1987
- ? {
1988
- id: latest.id,
1989
- round: latest.round,
1990
- status: latest.status,
1991
- mergeVerdict: latest.mergeVerdict,
1992
- reviewer: latest.reviewer,
1993
- reviewerRole: latest.reviewerRole,
1994
- reviewedCommit: latest.reviewedCommit,
1995
- at: latest.at,
1996
- }
1997
- : null,
1998
- tipMoved,
1999
- })
2000
- }
2001
- return out
2002
- }
2003
-
2004
- rebaseWaived(mission, review) {
2005
- const rebase = mission.rebase
2006
- if (!rebase) return false
2007
- return review.at <= rebase.at && review.reviewedCommit === rebase.oldTip
2008
- }
2009
-
2010
- async evaluateGate(input) {
2011
- const state = await this.load()
2012
- const mission = state.missions.find((entry) => entry.id === input.missionId)
2013
- if (!mission) {
2014
- return {
2015
- ok: false,
2016
- blockers: [{ code: 'mission-missing', detail: `unknown mission "${input.missionId}"` }],
2017
- missionId: input.missionId,
2018
- branchTip: null,
2019
- baseTip: null,
2020
- changedFiles: [],
2021
- review: null,
2022
- }
2023
- }
2024
- const activity = input.activity ?? {}
2025
- const blockers = []
2026
- const push = (code, detail) => blockers.push({ code, detail })
2027
-
2028
- if (TERMINAL.has(mission.status)) {
2029
- push(
2030
- 'branch-owned-by-closed-missions',
2031
- `mission ${mission.id} is ${mission.status}; a merge never flips a closed mission — re-plan the work under a new title`,
2032
- )
2033
- }
2034
- if (mission.status !== 'completed') {
2035
- push('mission-not-completed', `mission ${mission.id} is ${mission.status}; completion (awaiting review) is required`)
2036
- }
2037
- const openTasks = mission.tasks.filter((task) => task.status === 'open')
2038
- if (openTasks.length > 0) push('tasks-open', `${openTasks.length} open task(s): ${openTasks.map((task) => task.text).join('; ')}`)
2039
- if ((mission.blockers ?? []).length > 0) push('blockers-present', mission.blockers.join('; '))
2040
- if (this.config.gates.requireChecks === true) {
2041
- const checks = mission.completionReport?.checks ?? []
2042
- if (checks.length === 0) {
2043
- push('no-check-evidence', `mission ${mission.id} has no completion checks; gates.requireChecks is enabled`)
2044
- }
2045
- }
2046
- if (state.mergeIntent && state.mergeIntent.missionId === mission.id) {
2047
- push(
2048
- 'merge-intent-pending',
2049
- `a merge intent for ${state.mergeIntent.missionId} was recorded at ${new Date(Number(state.mergeIntent.startedAt) || 0).toISOString()}; run tower_merge to reconcile it`,
2050
- )
2051
- }
2052
-
2053
- const worktree = mission.worktree ?? null
2054
- let branchTip = null
2055
- let baseTip = null
2056
- let changedFiles = []
2057
- if (!worktree) {
2058
- push('branch-missing', `mission ${mission.id} has no worktree or branch`)
2059
- } else {
2060
- try {
2061
- await this.assertWorktree(worktree)
2062
- branchTip = await revParse(worktree.path, 'HEAD')
2063
- baseTip = await revParse(this.root, state.base)
2064
- const mergeBase = (await git(worktree.path, ['merge-base', baseTip, branchTip])).trim()
2065
- changedFiles = (await git(worktree.path, ['diff', '--name-only', `${mergeBase}..${branchTip}`]))
2066
- .split('\n')
2067
- .filter(Boolean)
2068
- if (!(await isAncestor(this.root, baseTip, branchTip))) {
2069
- push(
2070
- 'stale-base',
2071
- `${worktree.branch} does not contain base "${state.base}" tip ${baseTip.slice(0, 12)}; run tower_rebase first`,
2072
- )
2073
- }
2074
- if (mission.kind === 'survey' && changedFiles.length > 0) {
2075
- push('read-only-survey', `survey ${mission.id} is read-only but ${changedFiles.length} file(s) changed: ${changedFiles.slice(0, 5).join(', ')}`)
2076
- }
2077
- if (mission.kind !== 'survey') {
2078
- const outOfScope = changedFiles.filter((file) => !fileInScope(mission.scope, file))
2079
- if (outOfScope.length > 0) {
2080
- push('out-of-scope', `changed files outside scope (${mission.scope.join(', ')}): ${outOfScope.slice(0, 8).join(', ')}`)
2081
- }
2082
- }
2083
- const dirty = (await git(this.root, ['status', '--porcelain']))
2084
- .split('\n')
2085
- .filter(Boolean)
2086
- .map((line) => line.slice(3).trim())
2087
- const wouldOverwrite = changedFiles.filter((file) => dirty.includes(file))
2088
- if (wouldOverwrite.length > 0) {
2089
- push('base-dirty', `main checkout has uncommitted changes this merge would overwrite: ${wouldOverwrite.slice(0, 8).join(', ')}`)
2090
- }
2091
- } catch (error) {
2092
- push('branch-missing', String(error?.message ?? error))
2093
- }
2094
- }
2095
-
2096
- for (const depId of mission.deps ?? []) {
2097
- const dep = state.missions.find((entry) => entry.id === depId)
2098
- if (!dep || !['merged', 'abandoned'].includes(dep.status)) {
2099
- push('deps-unmerged', `${depId} is ${dep ? dep.status : 'missing'}`)
2100
- }
2101
- }
2102
-
2103
- const branch = worktree ? worktree.branch : null
2104
- const reviews = branch ? (state.reviews ?? []).filter((review) => review.branch === branch) : []
2105
- const latest = reviews.length > 0 ? reviews.reduce((a, b) => (a.round >= b.round ? a : b)) : null
2106
- const requireReview = this.config.review?.requireReview !== false
2107
- const exempt =
2108
- !requireReview ||
2109
- (mission.reviewPolicy === 'none' &&
2110
- this.config.gates.allowReviewExemption === true &&
2111
- String(mission.reviewExemptionReason ?? '').trim().length > 0)
2112
- if (requireReview && mission.reviewPolicy === 'none' && this.config.gates.allowReviewExemption !== true) {
2113
- push('review-exemption-invalid', `mission ${mission.id} requests no review but gates.allowReviewExemption is disabled`)
2114
- }
2115
- if (requireReview && !exempt && !latest) {
2116
- push('no-review', `branch ${branch ?? '(none)'} has no review; assign a reviewer first (or set review.requireReview:false)`)
2117
- } else if (requireReview && !exempt) {
2118
- const waived = this.rebaseWaived(mission, latest)
2119
- if (latest.missionId !== mission.id) {
2120
- push('review-mission-mismatch', `latest review belongs to mission ${latest.missionId}`)
2121
- }
2122
- if (latest.status !== 'clean') push('not-clean', `latest review round ${latest.round} is "${latest.status}"`)
2123
- if (latest.mergeVerdict === 'hold') {
2124
- push('review-hold', `latest review recommends hold — a later clean review must supersede it`)
2125
- } else if (latest.status === 'clean' && latest.mergeVerdict !== 'merge') {
2126
- push('review-not-approved', `latest clean review recommends "${latest.mergeVerdict}" — only "merge" approves`)
2127
- }
2128
- if (branchTip && latest.reviewedCommit !== branchTip && !waived) {
2129
- push('tip-moved', `reviewed ${latest.reviewedCommit.slice(0, 7)} but tip is ${branchTip.slice(0, 7)}; re-review required`)
2130
- }
2131
- }
2132
-
2133
- for (const entry of (state.roster ?? []).filter((row) => row.missionId === mission.id && !ROSTER_INACTIVE.has(row.state))) {
2134
- if (activity[entry.agentId] === 'running') push('worker-busy', `agent ${entry.name} is still running`)
2135
- else if (input.parentStatus && entry.parentSessionId && input.parentStatus[entry.parentSessionId] !== 'ok') {
2136
- push(
2137
- 'worker-status-unknown',
2138
- `cannot prove agent ${entry.name} is stopped: its parent catalog is unavailable`,
2139
- )
2140
- }
2141
- }
2142
- const staleWorkers = (state.roster ?? []).filter(
2143
- (row) => row.missionId === mission.id && (row.state === 'stale' || row.stale === true),
2144
- )
2145
- if (staleWorkers.length > 0) {
2146
- push('worker-stale', `stale worker(s) for ${mission.id}: ${staleWorkers.map((row) => row.name).join(', ')}; adopt or interrupt before merge`)
2147
- }
2148
- if (this.config.gates.requireTests === true) {
2149
- const runs = (mission.testRuns ?? []).filter((run) => run.ok === true && run.treeTip && branchTip && run.treeTip === branchTip)
2150
- if (runs.length === 0) {
2151
- push('tests-missing', `mission ${mission.id} has no passing test discovery for tip ${branchTip ? branchTip.slice(0, 7) : '(none)'}`)
2152
- }
2153
- }
2154
-
2155
- const checkoutBranch = await currentBranch(this.root)
2156
- if (checkoutBranch !== state.base) {
2157
- push('base-wrong-branch', `main checkout is on ${checkoutBranch ?? 'detached HEAD'}, expected "${state.base}"`)
2158
- }
2159
-
2160
- return {
2161
- ok: blockers.length === 0,
2162
- blockers,
2163
- mission: fullMission(mission),
2164
- worktree,
2165
- branchTip,
2166
- baseTip,
2167
- changedFiles,
2168
- review: latest,
2169
- }
2170
- }
2171
-
2172
- /** Read-only mission diff for reviewers: branch changes against the merge base. */
2173
- async diff(input = {}) {
2174
- const state = await this.load()
2175
- const mission = state.missions.find((entry) => entry.id === input.missionId)
2176
- if (!mission) fail(`unknown mission "${input.missionId}"`)
2177
- if (!mission.worktree) fail(`mission ${mission.id} has no worktree to diff`)
2178
- await this.assertWorktree(mission.worktree)
2179
- const baseTip = await revParse(this.root, state.base)
2180
- const branchTip = await revParse(mission.worktree.path, 'HEAD')
2181
- const mergeBase = (await git(mission.worktree.path, ['merge-base', baseTip, branchTip])).trim()
2182
- const files = (await git(mission.worktree.path, ['diff', '--name-status', `${mergeBase}..${branchTip}`]))
2183
- .split('\n')
2184
- .filter(Boolean)
2185
- const commits = (await git(mission.worktree.path, ['log', '--oneline', `${mergeBase}..${branchTip}`]))
2186
- .split('\n')
2187
- .filter(Boolean)
2188
- const fullPatch = await git(mission.worktree.path, ['diff', `${mergeBase}..${branchTip}`])
2189
- const limit = Number.isInteger(input.maxBytes) && input.maxBytes > 0 ? input.maxBytes : 64 * 1024
2190
- return {
2191
- missionId: mission.id,
2192
- branch: mission.worktree.branch,
2193
- baseTip,
2194
- branchTip,
2195
- mergeBase,
2196
- files,
2197
- commits,
2198
- truncated: fullPatch.length > limit,
2199
- patch: fullPatch.slice(0, limit),
2200
- }
2201
- }
2202
-
2203
- /**
2204
- * Record a merge that already landed in Git without being recorded here:
2205
- * a crashed/timed-out merge, a manual merge, or a rebase that fast-forwarded
2206
- * the branch to the base tip. The evidence is concrete: a clean merge review
2207
- * exists and either the branch tip or the reviewed commit is already an
2208
- * ancestor of the base.
2209
- */
2210
- /** Clear a stale merge intent only when the crash left no merge in progress
2211
- * or no merge commit. Returns true when the intent was consumed. */
2212
- async reconcileMergeIntent(state, mission) {
2213
- const intent = state.mergeIntent
2214
- if (!intent || intent.missionId !== mission.id || !mission.worktree) return false
2215
- let head = null
2216
- try {
2217
- head = await revParse(this.root, 'HEAD')
2218
- } catch {
2219
- return false
2220
- }
2221
- const mergeHeadPath = path.join(this.commonDir, 'MERGE_HEAD')
2222
- const inProgress = await exists(mergeHeadPath)
2223
- if (inProgress) return false
2224
- let landed = null
2225
- if (head === intent.baseTip) {
2226
- landed = null // no merge commit was created; the intent is safe to drop
2227
- } else {
2228
- landed = await findLandedMerge(
2229
- this.root,
2230
- head,
2231
- new Set([intent.branchTip, intent.baseTip].filter(Boolean)),
2232
- ).catch(() => null)
2233
- if (!landed) return false
2234
- }
2235
- const now = Date.now()
2236
- delete state.mergeIntent
2237
- state.revision += 1
2238
- state.updatedAt = now
2239
- await writeJsonAtomic(this.paths.state, state)
2240
- await appendJsonl(this.paths.activity, {
2241
- at: now,
2242
- type: 'merge',
2243
- missionId: mission.id,
2244
- text: landed
2245
- ? `reconciled merge intent; found landed commit ${String(landed.commit).slice(0, 7)}`
2246
- : 'cleared a merge intent that created no commit',
2247
- })
2248
- return true
2249
- }
2250
-
2251
- async reconcileLandedMerge(state, mission) {
2252
- if (!['completed', 'blocked'].includes(mission.status)) return null
2253
- if (!mission.worktree) return null
2254
- const branch = mission.worktree.branch
2255
- const reviews = (state.reviews ?? []).filter((review) => review.branch === branch)
2256
- const exempt =
2257
- this.config.review?.requireReview === false ||
2258
- (mission.reviewPolicy === 'none' &&
2259
- this.config.gates.allowReviewExemption === true &&
2260
- String(mission.reviewExemptionReason ?? '').trim().length > 0)
2261
- if (reviews.length === 0 && !exempt) return null
2262
- const latest = reviews.length > 0 ? reviews.reduce((a, b) => (a.round >= b.round ? a : b)) : null
2263
- if (latest && !exempt && (latest.status !== 'clean' || latest.mergeVerdict !== 'merge')) return null
2264
- if (!latest && !exempt) return null
2265
- let baseTip
2266
- let branchTip
2267
- try {
2268
- baseTip = await revParse(this.root, state.base)
2269
- branchTip = await revParse(mission.worktree.path, 'HEAD')
2270
- } catch {
2271
- return null
2272
- }
2273
- const reviewedTip = latest?.reviewedCommit ?? null
2274
- const branchLanded = await isAncestor(this.root, branchTip, baseTip)
2275
- const reviewedLanded = reviewedTip ? await isAncestor(this.root, reviewedTip, baseTip) : false
2276
- if (!branchLanded && !reviewedLanded) return null
2277
- const tips = new Set([branchTip, reviewedTip].filter(Boolean))
2278
- const found = await findLandedMerge(this.root, baseTip, tips)
2279
- const mergedCommit = found ? found.commit : reviewedLanded ? reviewedTip : branchTip
2280
- const now = Date.now()
2281
- if (state.mergeIntent?.missionId === mission.id) delete state.mergeIntent
2282
- mission.status = 'merged'
2283
- mission.mergedAt = now
2284
- mission.mergedCommit = mergedCommit
2285
- mission.blockers = []
2286
- mission.notes.push(
2287
- found
2288
- ? `reconciled already-landed merge ${mergedCommit.slice(0, 7)} with parents [${found.parents.map((parent) => parent.slice(0, 7)).join(', ')}]`
2289
- : `reconciled already-landed merge: ${mergedCommit.slice(0, 7)} is contained in ${state.base} (no merge commit found)`,
2290
- )
2291
- mission.updatedAt = now
2292
- state.revision += 1
2293
- state.updatedAt = now
2294
- validateState(state, this.paths.state)
2295
- await writeJsonAtomic(this.paths.state, state)
2296
- await appendJsonl(this.paths.activity, {
2297
- at: now,
2298
- type: 'merge',
2299
- missionId: mission.id,
2300
- text: `reconciled already-landed ${mergedCommit.slice(0, 7)} on ${state.base}`,
2301
- })
2302
- return {
2303
- ok: true,
2304
- reconciled: true,
2305
- alreadyLanded: true,
2306
- mergedCommit,
2307
- baseTip,
2308
- branchTip,
2309
- parents: found ? found.parents : null,
2310
- review: latest
2311
- ? {
2312
- round: latest.round,
2313
- status: latest.status,
2314
- mergeVerdict: latest.mergeVerdict,
2315
- reviewer: latest.reviewer,
2316
- reviewedCommit: reviewedTip,
2317
- }
2318
- : null,
2319
- }
2320
- }
2321
-
2322
- async merge(input) {
2323
- return withLock(this.paths, async () => {
2324
- const state = await this.load()
2325
- const mission = state.missions.find((entry) => entry.id === input.missionId)
2326
- if (!mission) {
2327
- return { ok: false, blockers: [{ code: 'mission-missing', detail: `unknown mission "${input.missionId}"` }] }
2328
- }
2329
- await this.reconcileMergeIntent(state, mission)
2330
- const baseTip = await revParse(this.root, state.base)
2331
- if (mission.status === 'merged') {
2332
- return { ok: true, alreadyMerged: true, mergedCommit: mission.mergedCommit ?? null, baseTip }
2333
- }
2334
- const reconciled = await this.reconcileLandedMerge(state, mission)
2335
- if (reconciled) return reconciled
2336
- const gate = await this.evaluateGate(input)
2337
- if (!gate.ok) return { ok: false, blockers: gate.blockers, missionId: input.missionId }
2338
-
2339
- const worktree = mission.worktree
2340
- const baseTipBefore = await revParse(this.root, 'HEAD')
2341
- const branchTip = await revParse(worktree.path, 'HEAD')
2342
- // Record the intent before touching Git; a crash between merge and state
2343
- // write can be reconciled by the next tower_merge/preflight instead of
2344
- // leaving an unexplained base tip.
2345
- state.mergeIntent = {
2346
- missionId: mission.id,
2347
- baseTip: baseTipBefore,
2348
- branchTip,
2349
- startedAt: Date.now(),
2350
- }
2351
- state.revision += 1
2352
- state.updatedAt = state.mergeIntent.startedAt
2353
- await writeJsonAtomic(this.paths.state, state)
2354
- const timeoutMs = mergeTimeoutMs(input.timeoutMs, this.config)
2355
- let failure = null
2356
- try {
2357
- await git(
2358
- this.root,
2359
- ['merge', '--no-ff', worktree.branch, '-m', `tower: merge ${mission.id} — ${mission.title}`],
2360
- { timeoutMs, maxBuffer: this.config.merge.maxBufferBytes || MERGE_MAX_BUFFER },
2361
- )
2362
- } catch (error) {
2363
- failure = error
2364
- }
2365
-
2366
- const newTip = await revParse(this.root, 'HEAD')
2367
- const parents = await commitParents(this.root, newTip)
2368
- const expectedShape = parents.length === 2 && parents[0] === baseTipBefore && parents[1] === branchTip
2369
- const warnings = []
2370
-
2371
- if (failure && expectedShape) {
2372
- warnings.push(
2373
- `merge command reported a failure (${describeGitFailure(failure)}) but merge commit ${newTip.slice(0, 7)} exists; treated as merged`,
2374
- )
2375
- const dirty = (await git(this.root, ['status', '--porcelain']))
2376
- .split('\n')
2377
- .filter(Boolean)
2378
- .map((line) => line.slice(3).trim())
2379
- if (dirty.length > 0) {
2380
- const changed = new Set(gate.changedFiles ?? [])
2381
- if (dirty.every((file) => changed.has(file))) {
2382
- await git(this.root, ['reset', '--hard', 'HEAD'])
2383
- warnings.push('restored index/worktree to the merge commit (only merge-related paths were dirty)')
2384
- } else {
2385
- warnings.push('working tree still has unrelated changes; left untouched — inspect it before the next Git operation')
2386
- }
2387
- }
2388
- } else if (failure) {
2389
- const unmerged = (await tryGit(this.root, ['ls-files', '-u'])) ?? ''
2390
- const conflict = unmerged.trim().length > 0
2391
- let cleanup = 'none'
2392
- const mergeHeadPath = path.join(this.commonDir, 'MERGE_HEAD')
2393
- if (await exists(mergeHeadPath)) {
2394
- try {
2395
- await git(this.root, ['merge', '--abort'])
2396
- cleanup = 'aborted'
2397
- } catch {
2398
- await removeStaleGitLocks(this.commonDir)
2399
- try {
2400
- await git(this.root, ['merge', '--abort'])
2401
- cleanup = 'aborted-after-lock-cleanup'
2402
- } catch {
2403
- await git(this.root, ['reset', '--hard', baseTipBefore]).catch(() => {})
2404
- cleanup = 'reset'
2405
- }
2406
- }
2407
- } else if (newTip !== baseTipBefore) {
2408
- await removeStaleGitLocks(this.commonDir)
2409
- await git(this.root, ['reset', '--hard', baseTipBefore]).catch(() => {})
2410
- cleanup = 'reset'
2411
- }
2412
- // A killed git process can leave the index staged even without
2413
- // MERGE_HEAD. Restore only when every dirty path belongs to this merge.
2414
- const dirtyAfter = (await git(this.root, ['status', '--porcelain']))
2415
- .split('\n')
2416
- .filter(Boolean)
2417
- .map((line) => line.slice(3).trim())
2418
- if (dirtyAfter.length > 0) {
2419
- const changed = new Set(gate.changedFiles ?? [])
2420
- if (dirtyAfter.every((file) => changed.has(file))) {
2421
- await removeStaleGitLocks(this.commonDir)
2422
- await git(this.root, ['reset', '--hard', baseTipBefore]).catch(() => {})
2423
- cleanup = cleanup === 'none' ? 'reset' : `${cleanup}+reset`
2424
- } else {
2425
- warnings.push(`cleanup left unrelated changes untouched: ${dirtyAfter.slice(0, 5).join(', ')}`)
2426
- }
2427
- }
2428
- const detail = describeGitFailure(failure).slice(0, 2000)
2429
- const now = Date.now()
2430
- delete state.mergeIntent
2431
- if (conflict) {
2432
- mission.status = 'blocked'
2433
- mission.blockers.push(`merge conflict: ${detail.slice(0, 240)}`)
2434
- } else {
2435
- mission.notes.push(`merge command failed before creating a commit (${detail.slice(0, 240)}); mission left ${mission.status} for retry`)
2436
- }
2437
- mission.updatedAt = now
2438
- state.revision += 1
2439
- state.updatedAt = now
2440
- await writeJsonAtomic(this.paths.state, state)
2441
- await appendJsonl(this.paths.activity, { at: now, type: 'merge', missionId: mission.id, text: `${conflict ? 'conflict' : 'failed-before-commit'} (${cleanup})` })
2442
- return {
2443
- ok: false,
2444
- missionId: mission.id,
2445
- blockers: [
2446
- {
2447
- code: conflict ? 'merge-conflict' : 'merge-hook-failed',
2448
- detail,
2449
- ...(failure.timedOut === true ? { timeout: true } : {}),
2450
- cleanup,
2451
- },
2452
- ],
2453
- ...(warnings.length > 0 ? { warnings } : {}),
2454
- }
2455
- }
2456
-
2457
- if (!expectedShape) {
2458
- delete state.mergeIntent
2459
- state.revision += 1
2460
- state.updatedAt = Date.now()
2461
- await writeJsonAtomic(this.paths.state, state).catch(() => {})
2462
- return {
2463
- ok: false,
2464
- blockers: [
2465
- {
2466
- code: 'merge-shape',
2467
- detail: `unexpected merge parents [${parents.join(', ')}], expected [${baseTipBefore}, ${branchTip}]`,
2468
- },
2469
- ],
2470
- }
2471
- }
2472
-
2473
- const now = Date.now()
2474
- delete state.mergeIntent
2475
- mission.status = 'merged'
2476
- mission.mergedAt = now
2477
- mission.mergedCommit = newTip
2478
- mission.updatedAt = now
2479
- mission.notes.push(`merged ${branchTip.slice(0, 7)} into ${state.base} as ${newTip.slice(0, 7)}`)
2480
- state.revision += 1
2481
- state.updatedAt = now
2482
- validateState(state, this.paths.state)
2483
- await writeJsonAtomic(this.paths.state, state)
2484
- await appendJsonl(this.paths.activity, {
2485
- at: now,
2486
- type: 'merge',
2487
- missionId: mission.id,
2488
- text: `${branchTip.slice(0, 7)} -> ${newTip.slice(0, 7)} on ${state.base}`,
2489
- })
2490
- return {
2491
- ok: true,
2492
- mergedCommit: newTip,
2493
- baseTip: baseTipBefore,
2494
- branchTip,
2495
- parents,
2496
- ...(warnings.length > 0 ? { warnings, recoveredFromFailure: true } : {}),
2497
- }
2498
- })
2499
- }
2500
-
2501
- async rebase(input) {
2502
- return withLock(this.paths, async () => {
2503
- const state = await this.load()
2504
- const mission = state.missions.find((entry) => entry.id === input.missionId)
2505
- if (!mission) fail(`unknown mission "${input.missionId}"`)
2506
- if (!mission.worktree) fail(`mission ${mission.id} has no worktree to rebase`)
2507
- const activity = input.activity ?? {}
2508
- for (const entry of (state.roster ?? []).filter((row) => row.missionId === mission.id && !ROSTER_INACTIVE.has(row.state))) {
2509
- if (activity[entry.agentId] === 'running') {
2510
- return { ok: false, blockers: [{ code: 'worker-busy', detail: `agent ${entry.name} is running; rebase refused` }] }
2511
- }
2512
- }
2513
- await this.assertWorktree(mission.worktree)
2514
- if (!(await isClean(mission.worktree.path))) {
2515
- return { ok: false, blockers: [{ code: 'worktree-dirty', detail: `${mission.worktree.path} has uncommitted changes` }] }
2516
- }
2517
- const oldTip = await revParse(mission.worktree.path, 'HEAD')
2518
- const baseTip = await revParse(this.root, state.base)
2519
- if (await isAncestor(this.root, oldTip, baseTip)) {
2520
- // The branch is already contained in the base (manual merge, crashed
2521
- // merge, earlier fast-forward). Rewriting the branch tip to the base
2522
- // would destroy the reviewed tip and the ability to record the merge.
2523
- return {
2524
- ok: true,
2525
- alreadyMerged: true,
2526
- skipped: true,
2527
- oldTip,
2528
- newTip: oldTip,
2529
- baseTip,
2530
- note: `${oldTip.slice(0, 7)} is already contained in ${state.base} ${baseTip.slice(0, 7)}; branch left untouched — call tower_merge to record the landed merge`,
2531
- }
2532
- }
2533
- if (await isAncestor(this.root, baseTip, oldTip)) {
2534
- return { ok: true, alreadyUpToDate: true, oldTip, newTip: oldTip, baseTip }
2535
- }
2536
- const now = Date.now()
2537
- try {
2538
- await git(mission.worktree.path, ['rebase', baseTip])
2539
- } catch (error) {
2540
- await git(mission.worktree.path, ['rebase', '--abort']).catch(() => {})
2541
- mission.status = 'blocked'
2542
- mission.blockers.push(`rebase conflict onto ${state.base} @ ${baseTip.slice(0, 12)}`)
2543
- mission.updatedAt = now
2544
- state.revision += 1
2545
- state.updatedAt = now
2546
- await writeJsonAtomic(this.paths.state, state)
2547
- await appendJsonl(this.paths.activity, { at: now, type: 'rebase', missionId: mission.id, text: 'conflict' })
2548
- return { ok: false, blockers: [{ code: 'rebase-conflict', detail: String(error?.message ?? error) }] }
2549
- }
2550
- const newTip = await revParse(mission.worktree.path, 'HEAD')
2551
- mission.rebase = { at: now, oldTip, newTip, baseTip }
2552
- if (mission.status === 'blocked') mission.status = 'active'
2553
- mission.notes.push(
2554
- `rebased ${oldTip.slice(0, 7)} -> ${newTip.slice(0, 7)} onto ${baseTip.slice(0, 7)}; conflict-free rebase waives re-review`,
2555
- )
2556
- mission.updatedAt = now
2557
- state.revision += 1
2558
- state.updatedAt = now
2559
- validateState(state, this.paths.state)
2560
- await writeJsonAtomic(this.paths.state, state)
2561
- await appendJsonl(this.paths.activity, {
2562
- at: now,
2563
- type: 'rebase',
2564
- missionId: mission.id,
2565
- text: `${oldTip.slice(0, 7)} -> ${newTip.slice(0, 7)} onto ${baseTip.slice(0, 7)}`,
2566
- })
2567
- return { ok: true, oldTip, newTip, baseTip, reviewWaived: true }
2568
- })
2569
- }
2570
-
2571
- async teardown(input) {
2572
- return withLock(this.paths, async () => {
2573
- const state = await this.load()
2574
- const activity = input.activity ?? {}
2575
- const exclude = new Set(input.exclude ?? [])
2576
- const decisions = []
2577
- const removed = []
2578
- const now = Date.now()
2579
-
2580
- for (const mission of state.missions) {
2581
- if (!mission.worktree || mission.worktreeRemovedAt) continue
2582
- if (exclude.has(mission.id)) {
2583
- decisions.push({ missionId: mission.id, action: 'keep', reason: 'excluded' })
2584
- continue
2585
- }
2586
- const live = (state.roster ?? []).some(
2587
- (entry) => entry.missionId === mission.id && !ROSTER_INACTIVE.has(entry.state) && activity[entry.agentId] === 'running',
2588
- )
2589
- if (live) {
2590
- decisions.push({ missionId: mission.id, action: 'keep', reason: 'live agent' })
2591
- continue
2592
- }
2593
- if (!['merged', 'abandoned'].includes(mission.status)) {
2594
- decisions.push({ missionId: mission.id, action: 'keep', reason: `mission is ${mission.status}` })
2595
- continue
2596
- }
2597
- let dirty = true
2598
- try {
2599
- dirty = !(await isClean(mission.worktree.path))
2600
- } catch {
2601
- dirty = true
2602
- }
2603
- if (dirty && input.force !== true) {
2604
- decisions.push({ missionId: mission.id, action: 'keep', reason: 'uncommitted changes' })
2605
- continue
2606
- }
2607
- if (input.dryRun === true) {
2608
- decisions.push({ missionId: mission.id, action: 'remove', reason: 'eligible', forced: dirty })
2609
- continue
2610
- }
2611
- try {
2612
- await git(this.root, ['worktree', 'remove', ...(dirty ? ['--force'] : []), mission.worktree.path])
2613
- } catch (error) {
2614
- decisions.push({ missionId: mission.id, action: 'keep', reason: `remove failed: ${String(error?.message ?? error)}` })
2615
- continue
2616
- }
2617
- await git(this.root, ['branch', '-d', mission.worktree.branch]).catch(() => {})
2618
- mission.worktreeRemovedAt = now
2619
- mission.updatedAt = now
2620
- removed.push(mission.id)
2621
- decisions.push({ missionId: mission.id, action: 'removed', branch: mission.worktree.branch })
2622
- await appendJsonl(this.paths.activity, {
2623
- at: now,
2624
- type: 'teardown',
2625
- missionId: mission.id,
2626
- text: mission.worktree.branch,
2627
- })
2628
- }
2629
-
2630
- if (!input.dryRun && removed.length > 0) {
2631
- state.revision += 1
2632
- state.updatedAt = now
2633
- validateState(state, this.paths.state)
2634
- await writeJsonAtomic(this.paths.state, state)
2635
- }
2636
- return { ok: true, dryRun: input.dryRun === true, removed, decisions }
2637
- })
2638
- }
2639
-
2640
- async status(input = {}) {
2641
- const state = await this.load()
2642
- const activity = await readActivityTail(this.paths.activity, 12)
2643
- const counts = {}
2644
- for (const status of MISSION_STATUSES) counts[status] = 0
2645
- for (const mission of state.missions) counts[mission.status] = (counts[mission.status] ?? 0) + 1
2646
- const openTasks = state.missions.reduce((sum, mission) => sum + taskCounts(mission.tasks).open, 0)
2647
- const findingsByStatus = {}
2648
- for (const status of FINDING_STATUSES) findingsByStatus[status] = 0
2649
- for (const finding of state.findings ?? []) {
2650
- const status = finding.status ?? 'open'
2651
- findingsByStatus[status] = (findingsByStatus[status] ?? 0) + 1
2652
- }
2653
- return {
2654
- repoRoot: state.repoRoot,
2655
- commonDir: state.commonDir,
2656
- statePath: this.paths.state,
2657
- base: state.base,
2658
- revision: state.revision,
2659
- counts,
2660
- openTasks,
2661
- roster: (state.roster ?? []).length,
2662
- messages: (state.messages ?? []).length,
2663
- unreadTower: this.unreadFor(state, 'tower').length,
2664
- findings: (state.findings ?? []).length,
2665
- findingsByStatus,
2666
- reviews: (state.reviews ?? []).length,
2667
- config: {
2668
- path: this.config.configPath ?? null,
2669
- repoConfigPresent: this.config.repoConfigPresent === true,
2670
- spawn: {
2671
- workerPreset: this.config.spawn?.workerPreset ?? null,
2672
- reviewerPreset: this.config.spawn?.reviewerPreset ?? null,
2673
- },
2674
- },
2675
- recentFindings: (state.findings ?? []).slice(-5).map((finding) => ({
2676
- id: finding.id,
2677
- type: finding.type,
2678
- severity: finding.severity,
2679
- status: finding.status ?? 'open',
2680
- title: finding.title,
2681
- missionId: finding.missionId,
2682
- reportedBy: finding.reportedBy,
2683
- at: finding.at,
2684
- })),
2685
- missions: await this.enrichedMissions(state, input.missionIds),
2686
- activity,
2687
- }
2688
- }
2689
- }