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