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/app/merge.js ADDED
@@ -0,0 +1,567 @@
1
+ // Merge use case, split into three phases (#51):
2
+ //
3
+ // prepare — outside the state lock: load state, reconcile a stale merge
4
+ // intent / an already-landed merge (Git is only read here, never
5
+ // under the state lock), evaluate the gate, capture the exact
6
+ // base/branch tips, and build the merge plan.
7
+ // act — record the merge intent under a short state lock, then run the
8
+ // exact captured `git merge` and its failure cleanup outside the
9
+ // state lock but under the repo-scoped git mutex (`withGitLock`).
10
+ // commit — reload state under a short state lock, verify the state revision
11
+ // policy and the observed merge parents, then write the same
12
+ // merged/blocked/notes outcome the single-phase implementation did.
13
+ //
14
+ // No git call or process spawn runs inside a `withLock` scope. Reconciliation
15
+ // is two-phase too: observe Git unlocked, then write the resulting state
16
+ // mutation under a short lock that revalidates the revision it was prepared
17
+ // from. A writer that slipped in between observation and write makes the
18
+ // prepare stale, so the whole prepare is retried, never partially applied.
19
+ import path from 'node:path'
20
+ import { withLock, withGitLock, writeJsonAtomic, appendJsonl, exists } from '../lib/store.js'
21
+ import { validateState } from '../domain/state.js'
22
+ import {
23
+ alreadyMergedResult,
24
+ buildMergeIntent,
25
+ classifyCommitOutcome,
26
+ gateTipDrift,
27
+ intentMatches,
28
+ isExpectedMergeShape,
29
+ landedMergeCandidate,
30
+ landedMergeEvidence,
31
+ landedMergeNote,
32
+ landedMergeResult,
33
+ mergeBlockedResult,
34
+ mergeConflictNote,
35
+ mergeFailureKind,
36
+ mergeHookFailureNote,
37
+ mergeIntentEvidence,
38
+ mergeIntentReconcileNote,
39
+ mergeShapeDetail,
40
+ mergeSuccessNote,
41
+ missionMissingResult,
42
+ pendingMergeIntentResult,
43
+ staleBaseResult,
44
+ } from '../domain/integration/merge.js'
45
+
46
+ // A revision CAS that loses a race is retried this many times before the merge
47
+ // reports the same retryable stale-base the previous revision policy produced.
48
+ const PREPARE_ATTEMPTS = 3
49
+
50
+ /** `rev-list --parents -n 1` through the Tower git port (never under the state lock). */
51
+ async function readCommitParents(tower, sha) {
52
+ const line = await tower.tryGit(tower.root, ['rev-list', '--parents', '-n', '1', sha])
53
+ return line ? line.trim().split(/\s+/).slice(1) : []
54
+ }
55
+
56
+ /**
57
+ * Find a reachable merge commit whose second parent is one of the known tips.
58
+ * `--no-ff` merges put the integrated branch on the second parent, so this is
59
+ * the concrete evidence that a mission's work already landed in the base.
60
+ * Callers must not hold the state lock: this runs Git.
61
+ */
62
+ async function findLandedMerge(tower, baseTip, tips) {
63
+ const revs = (await tower.tryGit(tower.root, ['rev-list', '--merges', baseTip])) ?? ''
64
+ for (const sha of revs.split('\n').filter(Boolean)) {
65
+ const parents = await readCommitParents(tower, sha)
66
+ if (parents.length >= 2 && tips.has(parents[1])) return { commit: sha, parents }
67
+ }
68
+ return null
69
+ }
70
+
71
+ /**
72
+ * Unlocked observation half of `reconcileMergeIntent`: decide from Git whether
73
+ * a recorded intent can be consumed. Returns null when it cannot: the merge is
74
+ * still in progress, HEAD cannot be read, or no evidence shows the intent's
75
+ * merge landed.
76
+ */
77
+ async function observeMergeIntent(tower, state, mission) {
78
+ const intent = state.mergeIntent
79
+ if (!intent || intent.missionId !== mission.id || !mission.worktree) return null
80
+ let head
81
+ try {
82
+ head = await tower.revParse(tower.root, 'HEAD')
83
+ } catch {
84
+ return null
85
+ }
86
+ const mergeInProgress = await exists(path.join(tower.commonDir, 'MERGE_HEAD'))
87
+ if (mergeInProgress) return null
88
+ if (head === intent.baseTip) return mergeIntentEvidence({ head, baseTip: intent.baseTip, mergeInProgress, landed: null })
89
+ const tips = new Set([intent.branchTip, intent.baseTip].filter(Boolean))
90
+ const landed = await findLandedMerge(tower, head, tips)
91
+ return mergeIntentEvidence({ head, baseTip: intent.baseTip, mergeInProgress, landed })
92
+ }
93
+
94
+ /**
95
+ * Locked write half of `reconcileMergeIntent`: consume the intent exactly as
96
+ * observed. The revision check makes the write a CAS: if any writer changed
97
+ * state after the observation the reconcile is abandoned and retried against
98
+ * the new state instead of overwriting it.
99
+ */
100
+ async function applyMergeIntentReconcile(tower, stateRevision, missionId, evidence) {
101
+ return withLock(tower.paths, async () => {
102
+ const state = await tower.load()
103
+ if (state.revision !== stateRevision) return { retry: true }
104
+ const mission = state.missions.find((entry) => entry.id === missionId)
105
+ if (!mission) return { result: missionMissingResult(missionId) }
106
+ if (!state.mergeIntent || state.mergeIntent.missionId !== mission.id) return { state }
107
+ const now = Date.now()
108
+ delete state.mergeIntent
109
+ state.revision += 1
110
+ state.updatedAt = now
111
+ await writeJsonAtomic(tower.paths.state, state)
112
+ await appendJsonl(tower.paths.activity, {
113
+ at: now,
114
+ type: 'merge',
115
+ missionId: mission.id,
116
+ text: mergeIntentReconcileNote(evidence),
117
+ })
118
+ return { state }
119
+ })
120
+ }
121
+
122
+ /**
123
+ * Unlocked observation half of `reconcileLandedMerge`: the same review /
124
+ * exemption / ancestry evidence rules the domain reconciler applies, but only
125
+ * reading Git. Returns null when there is nothing to reconcile, a
126
+ * `reviewStale` blocker when only the reviewed commit landed, or the evidence
127
+ * needed by the locked write half.
128
+ */
129
+ async function observeLandedMerge(tower, state, mission) {
130
+ const candidate = landedMergeCandidate(mission, state.reviews, tower.config)
131
+ if (!candidate) return null
132
+ const { latest } = candidate
133
+ let baseTip
134
+ let branchTip
135
+ try {
136
+ baseTip = await tower.revParse(tower.root, state.base)
137
+ branchTip = await tower.revParse(mission.worktree.path, 'HEAD')
138
+ } catch {
139
+ return null
140
+ }
141
+ const reviewedTip = latest?.reviewedCommit ?? null
142
+ const branchLanded = await tower.isAncestor(tower.root, branchTip, baseTip)
143
+ const reviewedLanded = reviewedTip ? await tower.isAncestor(tower.root, reviewedTip, baseTip) : false
144
+ if (!branchLanded) {
145
+ const evidence = landedMergeEvidence({
146
+ missionId: mission.id,
147
+ base: state.base,
148
+ baseTip,
149
+ branchTip,
150
+ reviewedTip,
151
+ branchLanded,
152
+ reviewedLanded,
153
+ found: null,
154
+ latest,
155
+ })
156
+ return 'reviewStale' in evidence ? { reviewStale: evidence.reviewStale } : null
157
+ }
158
+ const tips = new Set([branchTip, reviewedTip].filter(Boolean))
159
+ const found = await findLandedMerge(tower, baseTip, tips)
160
+ const evidence = landedMergeEvidence({
161
+ missionId: mission.id,
162
+ base: state.base,
163
+ baseTip,
164
+ branchTip,
165
+ reviewedTip,
166
+ branchLanded,
167
+ reviewedLanded,
168
+ found,
169
+ latest,
170
+ })
171
+ return 'measured' in evidence ? evidence.measured : null
172
+ }
173
+
174
+ /** Locked write half of `reconcileLandedMerge`, guarded by the revision CAS. */
175
+ async function applyLandedMergeReconcile(tower, stateRevision, missionId, observed) {
176
+ return withLock(tower.paths, async () => {
177
+ const state = await tower.load()
178
+ if (state.revision !== stateRevision) return { retry: true }
179
+ const mission = state.missions.find((entry) => entry.id === missionId)
180
+ if (!mission) return { result: missionMissingResult(missionId) }
181
+ const now = Date.now()
182
+ if (state.mergeIntent?.missionId === mission.id) delete state.mergeIntent
183
+ mission.status = 'merged'
184
+ mission.mergedAt = now
185
+ mission.mergedCommit = observed.mergedCommit
186
+ mission.blockers = []
187
+ mission.notes.push(
188
+ landedMergeNote({ found: observed.found, mergedCommit: observed.mergedCommit, base: state.base }),
189
+ )
190
+ mission.updatedAt = now
191
+ state.revision += 1
192
+ state.updatedAt = now
193
+ validateState(state, tower.paths.state)
194
+ await writeJsonAtomic(tower.paths.state, state)
195
+ await appendJsonl(tower.paths.activity, {
196
+ at: now,
197
+ type: 'merge',
198
+ missionId: mission.id,
199
+ text: `reconciled already-landed ${observed.mergedCommit.slice(0, 7)} on ${state.base}`,
200
+ })
201
+ return { result: landedMergeResult(observed) }
202
+ })
203
+ }
204
+
205
+ async function dirtyPaths(tower) {
206
+ const text = await tower.git(tower.root, ['status', '--porcelain'])
207
+ return text.split('\n').filter(Boolean).map((line) => line.slice(3).trim())
208
+ }
209
+
210
+ /**
211
+ * Phase 1: everything that can be decided without holding the state lock.
212
+ *
213
+ * Reconciliation is the one mutation that must happen before the gate: a stale
214
+ * merge intent (crashed merge, merge that landed before it was recorded) would
215
+ * otherwise surface as a permanent `merge-intent-pending` blocker. Git evidence
216
+ * is gathered unlocked; the state write happens under a short lock that
217
+ * revalidates the revision the observation was made from. A writer that slipped
218
+ * in between makes the prepare stale and it is simply retried, never partially
219
+ * applied and never overwritten.
220
+ */
221
+ async function prepareMerge(tower, input, deps) {
222
+ for (let attempt = 0; attempt < PREPARE_ATTEMPTS; attempt += 1) {
223
+ const prepared = await prepareFromState(tower, input, deps)
224
+ if (!prepared.retry) return prepared
225
+ }
226
+ return {
227
+ result: staleBaseResult(input.missionId, 'tower state changed while the merge was being prepared; retry'),
228
+ }
229
+ }
230
+
231
+ async function prepareFromState(tower, input, deps) {
232
+ const { mergeTimeoutMs, MERGE_MAX_BUFFER } = deps
233
+ let state = await tower.load()
234
+ let mission = state.missions.find((entry) => entry.id === input.missionId)
235
+ if (!mission) return { result: missionMissingResult(input.missionId) }
236
+
237
+ // Reconcile a stale merge intent: Git runs here, outside every state lock.
238
+ if (state.mergeIntent && state.mergeIntent.missionId === mission.id) {
239
+ const evidence = await observeMergeIntent(tower, state, mission)
240
+ if (evidence) {
241
+ const applied = await applyMergeIntentReconcile(tower, state.revision, mission.id, evidence)
242
+ if (applied.retry) return applied
243
+ if (applied.result) return applied
244
+ state = applied.state
245
+ mission = state.missions.find((entry) => entry.id === input.missionId)
246
+ if (!mission) return { result: missionMissingResult(input.missionId) }
247
+ }
248
+ }
249
+
250
+ if (mission.status === 'merged') {
251
+ return { result: alreadyMergedResult(mission, await tower.revParse(tower.root, state.base)) }
252
+ }
253
+
254
+ // Reconcile a merge that already landed in Git: observe unlocked, write CAS.
255
+ const landed = await observeLandedMerge(tower, state, mission)
256
+ if (landed) {
257
+ if (landed.reviewStale) return { result: landed.reviewStale }
258
+ const applied = await applyLandedMergeReconcile(tower, state.revision, mission.id, landed)
259
+ if (applied.retry) return applied
260
+ return applied
261
+ }
262
+
263
+ const gate = await tower.evaluateGate(input)
264
+ if (!gate.ok) return { result: { ok: false, blockers: gate.blockers, missionId: input.missionId } }
265
+
266
+ const worktree = mission.worktree
267
+ // Capture the exact commits this merge is allowed to integrate. The merge
268
+ // command below uses the captured branch SHA, never the mutable branch name.
269
+ const baseTip = await tower.revParse(tower.root, 'HEAD')
270
+ const branchTip = await tower.revParse(worktree.path, 'HEAD')
271
+ const drift = gateTipDrift({
272
+ missionId: mission.id,
273
+ gateBaseTip: gate.baseTip,
274
+ gateBranchTip: gate.branchTip,
275
+ baseTip,
276
+ branchTip,
277
+ })
278
+ if (drift) return { result: drift }
279
+
280
+ return {
281
+ stateRevision: state.revision,
282
+ missionId: mission.id,
283
+ missionTitle: mission.title,
284
+ baseTip,
285
+ branchTip,
286
+ gateTip: gate.branchTip ?? branchTip,
287
+ changedFiles: gate.changedFiles ?? [],
288
+ timeoutMs: mergeTimeoutMs(input.timeoutMs, tower.config),
289
+ maxBuffer: tower.config.merge.maxBufferBytes || MERGE_MAX_BUFFER,
290
+ }
291
+ }
292
+
293
+ /**
294
+ * Record the intent under a short state lock, immediately before Git runs.
295
+ * A crash between the merge commit and the outcome write is reconciled by the
296
+ * next tower_merge/preflight instead of leaving an unexplained base tip.
297
+ */
298
+ async function recordMergeIntent(tower, plan) {
299
+ return withLock(tower.paths, async () => {
300
+ const state = await tower.load()
301
+ // State revision policy: the plan was built from `plan.stateRevision`; a
302
+ // writer that slipped in before any Git mutation makes the plan stale and
303
+ // the operation safely retryable.
304
+ if (state.revision !== plan.stateRevision) {
305
+ return {
306
+ result: staleBaseResult(
307
+ plan.missionId,
308
+ `tower state changed while the merge was being prepared (${plan.stateRevision} -> ${state.revision}); retry`,
309
+ ),
310
+ }
311
+ }
312
+ const mission = state.missions.find((entry) => entry.id === plan.missionId)
313
+ if (!mission) return { result: missionMissingResult(plan.missionId) }
314
+ if (mission.status === 'merged') {
315
+ return { result: alreadyMergedResult(mission, plan.baseTip) }
316
+ }
317
+ // Only one merge may be in flight: two prepared merges would otherwise
318
+ // capture the same base tip and the second would build a merge on a moved
319
+ // base. Leave the recorded intent alone for the next reconcile.
320
+ if (state.mergeIntent) {
321
+ return { result: pendingMergeIntentResult(plan.missionId, state.mergeIntent) }
322
+ }
323
+ const now = Date.now()
324
+ state.mergeIntent = buildMergeIntent({
325
+ missionId: mission.id,
326
+ baseTip: plan.baseTip,
327
+ branchTip: plan.branchTip,
328
+ gateTip: plan.gateTip,
329
+ startedAt: now,
330
+ })
331
+ state.revision += 1
332
+ state.updatedAt = now
333
+ await writeJsonAtomic(tower.paths.state, state)
334
+ return { intent: state.mergeIntent, intentRevision: state.revision }
335
+ })
336
+ }
337
+
338
+ /**
339
+ * Phase 2: run the exact captured merge and its failure cleanup. No state lock
340
+ * is held here; `withGitLock` serializes this against other Tower git mutations.
341
+ */
342
+ async function runMergeGit(tower, plan, intent, deps) {
343
+ const { describeGitFailure, commitParents, removeStaleGitLocks } = deps
344
+ let failure = null
345
+ try {
346
+ // Merge the exact reviewed commit, never a mutable branch name. A branch
347
+ // move after the gate must not smuggle extra commits into base.
348
+ await tower.git(
349
+ tower.root,
350
+ ['merge', '--no-ff', intent.branchTip, '-m', `tower: merge ${plan.missionId} — ${plan.missionTitle}`],
351
+ { timeoutMs: plan.timeoutMs, maxBuffer: plan.maxBuffer },
352
+ )
353
+ } catch (error) {
354
+ failure = error
355
+ }
356
+
357
+ const newTip = await tower.revParse(tower.root, 'HEAD')
358
+ const parents = await commitParents(tower.root, newTip)
359
+ const expectedShape = isExpectedMergeShape(parents, intent.baseTip, intent.branchTip)
360
+ const warnings = []
361
+
362
+ if (failure && expectedShape) {
363
+ warnings.push(
364
+ `merge command reported a failure (${describeGitFailure(failure)}) but merge commit ${newTip.slice(0, 7)} exists; treated as merged`,
365
+ )
366
+ const dirty = await dirtyPaths(tower)
367
+ if (dirty.length > 0) {
368
+ const changed = new Set(plan.changedFiles)
369
+ if (dirty.every((file) => changed.has(file))) {
370
+ await tower.git(tower.root, ['reset', '--hard', 'HEAD'])
371
+ warnings.push('restored index/worktree to the merge commit (only merge-related paths were dirty)')
372
+ } else {
373
+ warnings.push('working tree still has unrelated changes; left untouched — inspect it before the next Git operation')
374
+ }
375
+ }
376
+ } else if (failure) {
377
+ const unmerged = (await tower.tryGit(tower.root, ['ls-files', '-u'])) ?? ''
378
+ const conflict = unmerged.trim().length > 0
379
+ let cleanup = 'none'
380
+ const mergeHeadPath = path.join(tower.commonDir, 'MERGE_HEAD')
381
+ if (await exists(mergeHeadPath)) {
382
+ try {
383
+ await tower.git(tower.root, ['merge', '--abort'])
384
+ cleanup = 'aborted'
385
+ } catch {
386
+ await removeStaleGitLocks(tower.commonDir)
387
+ try {
388
+ await tower.git(tower.root, ['merge', '--abort'])
389
+ cleanup = 'aborted-after-lock-cleanup'
390
+ } catch {
391
+ await tower.git(tower.root, ['reset', '--hard', intent.baseTip]).catch(() => {})
392
+ cleanup = 'reset'
393
+ }
394
+ }
395
+ } else if (newTip !== intent.baseTip) {
396
+ // HEAD moved without Tower's expected merge shape. This may be a
397
+ // concurrent commit; never hard-reset it away. Leave a visible
398
+ // manual-recovery state instead.
399
+ cleanup = 'manual-required'
400
+ warnings.push('HEAD moved unexpectedly during a failed merge; left in place for manual recovery')
401
+ }
402
+ // A killed git process can leave the index staged even without MERGE_HEAD.
403
+ // Restore only when every dirty path belongs to this merge.
404
+ const dirtyAfter = await dirtyPaths(tower)
405
+ if (dirtyAfter.length > 0) {
406
+ const changed = new Set(plan.changedFiles)
407
+ if (newTip === intent.baseTip && dirtyAfter.every((file) => changed.has(file))) {
408
+ await removeStaleGitLocks(tower.commonDir)
409
+ await tower.git(tower.root, ['reset', '--hard', intent.baseTip]).catch(() => {})
410
+ cleanup = cleanup === 'none' ? 'reset' : `${cleanup}+reset`
411
+ } else {
412
+ warnings.push(`cleanup left changes for manual review: ${dirtyAfter.slice(0, 5).join(', ')}`)
413
+ }
414
+ }
415
+ return {
416
+ kind: mergeFailureKind(conflict),
417
+ detail: describeGitFailure(failure).slice(0, 2000),
418
+ cleanup,
419
+ warnings,
420
+ timeout: failure.timedOut === true,
421
+ }
422
+ }
423
+
424
+ if (!expectedShape) {
425
+ return { kind: 'merge-shape', newTip, parents, warnings }
426
+ }
427
+ return { kind: 'merged', newTip, parents, warnings }
428
+ }
429
+
430
+ /** Phase 3: apply the observed outcome under a short state lock. */
431
+ async function commitMerge(tower, plan, recorded, outcome) {
432
+ return withLock(tower.paths, async () => {
433
+ const state = await tower.load()
434
+ const mission = state.missions.find((entry) => entry.id === plan.missionId)
435
+ if (!mission) return missionMissingResult(plan.missionId)
436
+ // State revision policy: another writer changed the state while Git was
437
+ // running. Do not overwrite their update and do not guess: keep the merge
438
+ // intent (the pending marker) so the next tower_merge/preflight reconciles
439
+ // the landed commit. Nothing has been written by this call yet.
440
+ if (state.revision !== recorded.intentRevision) {
441
+ return staleBaseResult(
442
+ plan.missionId,
443
+ `tower state changed while the merge was running (${recorded.intentRevision} -> ${state.revision}); the merge intent is retained — retry tower_merge to reconcile`,
444
+ )
445
+ }
446
+ const intentActive = intentMatches(state.mergeIntent, recorded.intent)
447
+ // The parents were read from HEAD immediately after the merge while the git
448
+ // mutex was still held. Re-verify them here: recording a "merged" mission is
449
+ // only safe when HEAD is [intent.baseTip, intent.branchTip].
450
+ const kind = classifyCommitOutcome(
451
+ outcome.kind,
452
+ outcome.parents,
453
+ recorded.intent.baseTip,
454
+ recorded.intent.branchTip,
455
+ )
456
+
457
+ if (kind === 'merged') {
458
+ const now = Date.now()
459
+ if (intentActive) delete state.mergeIntent
460
+ mission.status = 'merged'
461
+ mission.mergedAt = now
462
+ mission.mergedCommit = outcome.newTip
463
+ mission.updatedAt = now
464
+ mission.notes.push(mergeSuccessNote(recorded.intent.branchTip, state.base, outcome.newTip))
465
+ state.revision += 1
466
+ state.updatedAt = now
467
+ validateState(state, tower.paths.state)
468
+ await writeJsonAtomic(tower.paths.state, state)
469
+ await appendJsonl(tower.paths.activity, {
470
+ at: now,
471
+ type: 'merge',
472
+ missionId: mission.id,
473
+ text: `${recorded.intent.branchTip.slice(0, 7)} -> ${outcome.newTip.slice(0, 7)} on ${state.base}`,
474
+ })
475
+ return {
476
+ ok: true,
477
+ mergedCommit: outcome.newTip,
478
+ baseTip: recorded.intent.baseTip,
479
+ branchTip: recorded.intent.branchTip,
480
+ parents: outcome.parents,
481
+ ...(outcome.warnings.length > 0 ? { warnings: outcome.warnings, recoveredFromFailure: true } : {}),
482
+ }
483
+ }
484
+
485
+ if (kind === 'merge-shape') {
486
+ // Keep the intent and the observed tip. A shape mismatch means Tower
487
+ // cannot prove what landed; deleting the intent here would make the
488
+ // moved base unreconcilable and could discard a real commit.
489
+ const detail = mergeShapeDetail(outcome.parents, recorded.intent.baseTip, recorded.intent.branchTip)
490
+ if (intentActive) {
491
+ state.mergeIntent.problem = { code: 'merge-shape', newTip: outcome.newTip, parents: outcome.parents, at: Date.now() }
492
+ state.revision += 1
493
+ state.updatedAt = Date.now()
494
+ await writeJsonAtomic(tower.paths.state, state)
495
+ await appendJsonl(tower.paths.activity, {
496
+ at: state.updatedAt,
497
+ type: 'merge',
498
+ missionId: mission.id,
499
+ text: `shape-mismatch newTip=${outcome.newTip.slice(0, 12)} parents=${outcome.parents.length}`,
500
+ })
501
+ }
502
+ return {
503
+ ok: false,
504
+ missionId: mission.id,
505
+ blockers: [{ code: 'merge-shape', detail, cleanup: 'manual-required' }],
506
+ newTip: outcome.newTip,
507
+ parents: outcome.parents,
508
+ intentRetained: true,
509
+ }
510
+ }
511
+
512
+ // merge-conflict / merge-hook-failed: the merge created no commit.
513
+ const conflict = kind === 'merge-conflict'
514
+ const now = Date.now()
515
+ if (intentActive) delete state.mergeIntent
516
+ if (conflict) {
517
+ mission.status = 'blocked'
518
+ mission.blockers.push(mergeConflictNote(outcome.detail))
519
+ } else {
520
+ mission.notes.push(mergeHookFailureNote(outcome.detail, mission.status))
521
+ }
522
+ mission.updatedAt = now
523
+ state.revision += 1
524
+ state.updatedAt = now
525
+ await writeJsonAtomic(tower.paths.state, state)
526
+ await appendJsonl(tower.paths.activity, {
527
+ at: now,
528
+ type: 'merge',
529
+ missionId: mission.id,
530
+ text: `${conflict ? 'conflict' : 'failed-before-commit'} (${outcome.cleanup})`,
531
+ })
532
+ return mergeBlockedResult({
533
+ missionId: mission.id,
534
+ code: conflict ? 'merge-conflict' : 'merge-hook-failed',
535
+ detail: outcome.detail,
536
+ timeout: outcome.timeout === true,
537
+ cleanup: outcome.cleanup,
538
+ warnings: outcome.warnings,
539
+ })
540
+ })
541
+ }
542
+
543
+ export async function merge(tower, input, deps) {
544
+ const plan = await prepareMerge(tower, input, deps)
545
+ if (plan.result) return plan.result
546
+
547
+ const acted = await withGitLock(tower.paths, async () => {
548
+ // No Git mutation has happened yet: a base that moved after prepare is a
549
+ // clean retryable stale-base rejection, with no intent left behind.
550
+ const head = await tower.revParse(tower.root, 'HEAD')
551
+ if (head !== plan.baseTip) {
552
+ return {
553
+ result: staleBaseResult(
554
+ plan.missionId,
555
+ `base moved after gate (${plan.baseTip.slice(0, 7)} -> ${head.slice(0, 7)}); re-run preflight`,
556
+ ),
557
+ }
558
+ }
559
+ const recorded = await recordMergeIntent(tower, plan)
560
+ if (recorded.result) return recorded
561
+ const outcome = await runMergeGit(tower, plan, recorded.intent, deps)
562
+ return { recorded, outcome }
563
+ })
564
+ if (acted.result) return acted.result
565
+
566
+ return commitMerge(tower, plan, acted.recorded, acted.outcome)
567
+ }
@@ -0,0 +1,66 @@
1
+ // Inbox/message use cases.
2
+ import { withLock, writeJsonAtomic, appendJsonl } from '../lib/store.js'
3
+ import { validateState } from '../domain/state.js'
4
+ import { TowerToolError } from '../domain/errors.js'
5
+ import {
6
+ assertMessageRecipient,
7
+ createMessage,
8
+ addDelivery,
9
+ inboxView,
10
+ } from '../domain/collab/messages.js'
11
+
12
+ function fail(message) {
13
+ throw new TowerToolError('invalid-argument', message)
14
+ }
15
+
16
+ export async function recordMessage(tower, input) {
17
+ return withLock(tower.paths, async () => {
18
+ const state = await tower.load()
19
+ if (!Array.isArray(state.messages)) state.messages = []
20
+ assertMessageRecipient({ roster: state.roster ?? [], to: input.to, from: input.from })
21
+ const now = Date.now()
22
+ const seq = (state.messageSeq ?? 0) + 1
23
+ state.messageSeq = seq
24
+ const message = createMessage(input, seq, now)
25
+ state.messages.push(message)
26
+ state.revision += 1
27
+ state.updatedAt = now
28
+
29
+ validateState(state, tower.paths.state)
30
+ await writeJsonAtomic(tower.paths.state, state)
31
+ await appendJsonl(tower.paths.activity, {
32
+ at: now,
33
+ type: 'message',
34
+ text: `${input.from} -> ${input.to}: ${input.subject}`,
35
+ })
36
+ return { ...message, readBy: [], delivery: [] }
37
+ })
38
+ }
39
+
40
+ export async function recordDelivery(tower, messageId, results) {
41
+ return withLock(tower.paths, async () => {
42
+ const state = await tower.load()
43
+ const message = (state.messages ?? []).find((entry) => entry.id === messageId)
44
+ if (!message) fail(`unknown message "${messageId}"`)
45
+ addDelivery(message, results)
46
+ state.revision += 1
47
+ state.updatedAt = Date.now()
48
+ await writeJsonAtomic(tower.paths.state, state)
49
+ return message.delivery
50
+ })
51
+ }
52
+
53
+ export async function inbox(tower, input) {
54
+ return withLock(tower.paths, async () => {
55
+ const state = await tower.load()
56
+ const now = Date.now()
57
+ const { payload, changed } = inboxView(state.messages ?? [], input, now)
58
+ if (changed) {
59
+ state.revision += 1
60
+ state.updatedAt = now
61
+ validateState(state, tower.paths.state)
62
+ await writeJsonAtomic(tower.paths.state, state)
63
+ }
64
+ return payload
65
+ })
66
+ }
package/app/mission.js ADDED
@@ -0,0 +1,35 @@
1
+ // Mission lifecycle update use case.
2
+ import { TowerToolError } from '../domain/errors.js'
3
+ import { applyMissionUpdate } from '../domain/missions/lifecycle.js'
4
+ import { validateState } from '../domain/state.js'
5
+ import { fullMission } from '../domain/summary.js'
6
+ import { withLock, writeJsonAtomic, appendJsonl } from '../lib/store.js'
7
+
8
+ function fail(message, code = 'invalid-argument') {
9
+ throw new TowerToolError(code, message)
10
+ }
11
+
12
+ export async function updateMission(tower, input = {}) {
13
+ const missionId = typeof input.id === 'string' ? input.id.trim() : input.id
14
+ if (typeof missionId !== 'string' || missionId.length === 0) fail('tower_mission requires id')
15
+ return withLock(tower.paths, async () => {
16
+ const state = await tower.load()
17
+ const mission = state.missions.find((entry) => entry.id === missionId)
18
+ if (!mission) fail(`unknown mission "${missionId}"`, 'mission-not-found')
19
+ const { changes } = applyMissionUpdate({ mission, state, input, config: tower.config })
20
+ const now = Date.now()
21
+ mission.updatedAt = now
22
+ state.revision += 1
23
+ state.updatedAt = now
24
+
25
+ validateState(state, tower.paths.state)
26
+ await writeJsonAtomic(tower.paths.state, state)
27
+ await appendJsonl(tower.paths.activity, {
28
+ at: now,
29
+ type: 'mission',
30
+ missionId: mission.id,
31
+ text: changes.join('; '),
32
+ })
33
+ return { revision: state.revision, mission: fullMission(mission) }
34
+ })
35
+ }
@@ -0,0 +1,14 @@
1
+ // Mission context projection use case (reviews, findings, dependencies).
2
+ import { TowerToolError } from '../domain/errors.js'
3
+ import { missionContextView } from '../domain/read/missionContext.js'
4
+
5
+ function fail(message, code = 'invalid-argument') {
6
+ throw new TowerToolError(code, message)
7
+ }
8
+
9
+ export async function missionContext(tower, missionId) {
10
+ const state = await tower.load()
11
+ const mission = state.missions.find((entry) => entry.id === missionId)
12
+ if (!mission) fail(`unknown mission "${missionId}"`, 'mission-not-found')
13
+ return missionContextView({ state, mission })
14
+ }