dsh-tower 2.0.4 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (184) hide show
  1. package/CHANGELOG.md +152 -0
  2. package/COMPATIBILITY.md +39 -0
  3. package/CONTRIBUTING.md +157 -2
  4. package/README.en.md +46 -3
  5. package/README.md +44 -3
  6. package/SECURITY.md +27 -0
  7. package/adapters/default.js +12 -0
  8. package/adapters/store-fs.js +6 -0
  9. package/adapters/vcs-git.js +4 -0
  10. package/app/archive.js +190 -0
  11. package/app/batch.js +79 -0
  12. package/app/board.js +27 -0
  13. package/app/checkCompletion.js +89 -0
  14. package/app/enrichedMissions.js +26 -0
  15. package/app/findings.js +78 -0
  16. package/app/gate.js +126 -0
  17. package/app/init.js +291 -0
  18. package/app/merge.js +573 -0
  19. package/app/messages.js +66 -0
  20. package/app/mission.js +37 -0
  21. package/app/missionContext.js +14 -0
  22. package/app/plan.js +50 -0
  23. package/app/provision.js +193 -0
  24. package/app/read.js +113 -0
  25. package/app/rebase.js +302 -0
  26. package/app/recording.js +72 -0
  27. package/app/review.js +147 -0
  28. package/app/roster.js +123 -0
  29. package/app/spawn.js +125 -0
  30. package/app/teardown.js +205 -0
  31. package/app/tower.js +552 -0
  32. package/cordis.patch.yml +61 -53
  33. package/docs/README.md +3 -1
  34. package/docs/agents/domain.md +38 -0
  35. package/docs/agents/issue-tracker.md +46 -0
  36. package/docs/agents/triage-labels.md +15 -0
  37. package/docs/architecture/overview.md +114 -13
  38. package/docs/board.md +72 -0
  39. package/docs/guide/hot-reload.md +1 -1
  40. package/docs/guide/install-upgrade-migration.md +25 -1
  41. package/docs/guide/release.md +125 -0
  42. package/docs/operations/security.md +47 -2
  43. package/docs/reference/blockers.md +1 -1
  44. package/docs/reference/config.generated.md +70 -0
  45. package/docs/reference/config.md +8 -1
  46. package/docs/reference/doctor.md +58 -8
  47. package/docs/reference/errors.generated.md +41 -0
  48. package/docs/reference/errors.md +7 -3
  49. package/docs/reference/tools.generated.md +33 -0
  50. package/docs/reference/tools.md +7 -6
  51. package/docs/zh/README.md +3 -2
  52. package/docs/zh/guide/hot-reload.md +1 -1
  53. package/docs/zh/guide/install-upgrade-migration.md +18 -1
  54. package/docs/zh/operations/security.md +30 -1
  55. package/docs/zh/reference/blockers.md +1 -1
  56. package/docs/zh/reference/config.md +8 -1
  57. package/docs/zh/reference/doctor.md +35 -2
  58. package/docs/zh/reference/errors.md +6 -2
  59. package/docs/zh/reference/tools.md +3 -3
  60. package/domain/collab/batch.js +112 -0
  61. package/domain/collab/messages.js +127 -0
  62. package/domain/collab/roster.js +158 -0
  63. package/domain/collab/spawn.js +188 -0
  64. package/domain/config/schema.js +427 -0
  65. package/domain/contracts/outputs.js +305 -0
  66. package/domain/contracts/tool-inputs/collaboration.js +58 -0
  67. package/domain/contracts/tool-inputs/findings.js +29 -0
  68. package/domain/contracts/tool-inputs/index.js +94 -0
  69. package/domain/contracts/tool-inputs/integration.js +37 -0
  70. package/domain/contracts/tool-inputs/missions.js +95 -0
  71. package/domain/contracts/tool-inputs/ops.js +75 -0
  72. package/domain/contracts/tool-inputs/shared.js +25 -0
  73. package/domain/errors.js +98 -0
  74. package/domain/gate.js +125 -0
  75. package/domain/integration/gate.js +190 -0
  76. package/domain/integration/init.js +261 -0
  77. package/domain/integration/liveness.js +31 -0
  78. package/domain/integration/merge.js +377 -0
  79. package/domain/integration/provision.js +124 -0
  80. package/domain/integration/rebase.js +236 -0
  81. package/domain/integration/review.js +52 -0
  82. package/domain/integration/teardown.js +118 -0
  83. package/domain/mission.js +68 -0
  84. package/domain/missions/completion.js +167 -0
  85. package/domain/missions/findings.js +201 -0
  86. package/domain/missions/lifecycle.js +186 -0
  87. package/domain/missions/plan.js +149 -0
  88. package/domain/missions/review.js +179 -0
  89. package/domain/planning.js +121 -0
  90. package/domain/read/enrichedMissions.js +58 -0
  91. package/domain/read/missionContext.js +53 -0
  92. package/domain/read/views.js +141 -0
  93. package/domain/report.js +67 -0
  94. package/domain/roster.js +40 -0
  95. package/domain/scope.js +177 -0
  96. package/domain/state/archive.js +257 -0
  97. package/domain/state/migrations.js +145 -0
  98. package/domain/state/schema.js +239 -0
  99. package/domain/state/semantics.js +117 -0
  100. package/domain/state.js +183 -0
  101. package/domain/summary.js +116 -0
  102. package/domain/vocabulary.js +179 -0
  103. package/index.js +63 -20
  104. package/lib/archive.js +279 -0
  105. package/lib/briefing.js +176 -0
  106. package/lib/caseSensitivity.js +66 -0
  107. package/lib/compatibility.js +108 -0
  108. package/lib/config.js +33 -375
  109. package/lib/doctor.js +275 -4
  110. package/lib/git.js +60 -16
  111. package/lib/hmr.js +4 -3
  112. package/lib/leases.js +17 -7
  113. package/lib/legacy.js +39 -14
  114. package/lib/modeState.js +39 -0
  115. package/lib/modeStore.js +11 -43
  116. package/lib/persistence.js +356 -0
  117. package/lib/presets.js +35 -0
  118. package/lib/spawn-prechecks.js +168 -0
  119. package/lib/spawn-support.js +79 -0
  120. package/lib/stateFile.js +99 -0
  121. package/lib/store.js +67 -135
  122. package/lib/tests.js +17 -12
  123. package/lib/tool-registry.js +544 -0
  124. package/lib/workspaceLock.js +2 -2
  125. package/lib/worktreeOwnership.js +22 -10
  126. package/locale/en.json +23 -1
  127. package/locale/zh.json +22 -0
  128. package/package.json +32 -4
  129. package/ports/clock.js +6 -0
  130. package/ports/ids.js +7 -0
  131. package/ports/index.js +2 -0
  132. package/ports/store.js +11 -0
  133. package/ports/vcs.js +11 -0
  134. package/scripts/architecture.mjs +94 -0
  135. package/scripts/check-docs.mjs +9 -3
  136. package/scripts/check-pack.mjs +49 -0
  137. package/scripts/domain-metrics.mjs +48 -0
  138. package/scripts/generate-config-reference.mjs +46 -0
  139. package/scripts/generate-error-reference.mjs +29 -0
  140. package/scripts/generate-tools-reference.mjs +56 -0
  141. package/scripts/issue-audit.mjs +259 -0
  142. package/scripts/lib/cli.mjs +76 -0
  143. package/scripts/lib/tmp.mjs +20 -0
  144. package/scripts/mount-smoke/fixtures.mjs +162 -0
  145. package/scripts/mount-smoke/host.json +4 -0
  146. package/scripts/mount-smoke/probe.mjs +125 -0
  147. package/scripts/mount-smoke/surface.mjs +141 -0
  148. package/scripts/mount-smoke.mjs +270 -0
  149. package/scripts/pack-manifest.txt +212 -0
  150. package/scripts/release-check.mjs +303 -0
  151. package/tools/adopt.js +72 -0
  152. package/tools/batch.js +78 -0
  153. package/tools/complete.js +53 -0
  154. package/tools/diff.js +39 -0
  155. package/tools/doctor.js +50 -0
  156. package/tools/finding.js +40 -0
  157. package/tools/findings.js +39 -0
  158. package/tools/import-legacy.js +42 -0
  159. package/tools/inbox.js +39 -0
  160. package/tools/index.js +39 -0
  161. package/tools/init.js +63 -0
  162. package/tools/interrupt.js +42 -0
  163. package/tools/legacy-map.js +44 -0
  164. package/tools/merge.js +39 -0
  165. package/tools/mission.js +41 -0
  166. package/tools/plan.js +39 -0
  167. package/tools/preflight.js +45 -0
  168. package/tools/rebase.js +39 -0
  169. package/tools/recover.js +45 -0
  170. package/tools/reload.js +52 -0
  171. package/tools/resource.js +54 -0
  172. package/tools/review.js +45 -0
  173. package/tools/roster.js +60 -0
  174. package/tools/send.js +147 -0
  175. package/tools/spawn.js +196 -0
  176. package/tools/status.js +102 -0
  177. package/tools/teardown.js +42 -0
  178. package/tools/test.js +61 -0
  179. package/types/cordis.d.ts +6 -0
  180. package/types/index.d.ts +373 -0
  181. package/lib/errors.js +0 -123
  182. package/lib/tools.js +0 -2460
  183. package/lib/tower.js +0 -2912
  184. /package/{lib → domain}/batch.js +0 -0
package/app/merge.js ADDED
@@ -0,0 +1,573 @@
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) {
236
+ // An archived terminal mission still answers `alreadyMerged` (or the same
237
+ // blockers) instead of turning into mission-missing after the move (#73).
238
+ const archived = await tower.archivedMission(input.missionId)
239
+ if (archived.ok && archived.entry) mission = archived.entry.mission
240
+ }
241
+ if (!mission) return { result: missionMissingResult(input.missionId) }
242
+
243
+ // Reconcile a stale merge intent: Git runs here, outside every state lock.
244
+ if (state.mergeIntent && state.mergeIntent.missionId === mission.id) {
245
+ const evidence = await observeMergeIntent(tower, state, mission)
246
+ if (evidence) {
247
+ const applied = await applyMergeIntentReconcile(tower, state.revision, mission.id, evidence)
248
+ if (applied.retry) return applied
249
+ if (applied.result) return applied
250
+ state = applied.state
251
+ mission = state.missions.find((entry) => entry.id === input.missionId)
252
+ if (!mission) return { result: missionMissingResult(input.missionId) }
253
+ }
254
+ }
255
+
256
+ if (mission.status === 'merged') {
257
+ return { result: alreadyMergedResult(mission, await tower.revParse(tower.root, state.base)) }
258
+ }
259
+
260
+ // Reconcile a merge that already landed in Git: observe unlocked, write CAS.
261
+ const landed = await observeLandedMerge(tower, state, mission)
262
+ if (landed) {
263
+ if (landed.reviewStale) return { result: landed.reviewStale }
264
+ const applied = await applyLandedMergeReconcile(tower, state.revision, mission.id, landed)
265
+ if (applied.retry) return applied
266
+ return applied
267
+ }
268
+
269
+ const gate = await tower.evaluateGate(input)
270
+ if (!gate.ok) return { result: { ok: false, blockers: gate.blockers, missionId: input.missionId } }
271
+
272
+ const worktree = mission.worktree
273
+ // Capture the exact commits this merge is allowed to integrate. The merge
274
+ // command below uses the captured branch SHA, never the mutable branch name.
275
+ const baseTip = await tower.revParse(tower.root, 'HEAD')
276
+ const branchTip = await tower.revParse(worktree.path, 'HEAD')
277
+ const drift = gateTipDrift({
278
+ missionId: mission.id,
279
+ gateBaseTip: gate.baseTip,
280
+ gateBranchTip: gate.branchTip,
281
+ baseTip,
282
+ branchTip,
283
+ })
284
+ if (drift) return { result: drift }
285
+
286
+ return {
287
+ stateRevision: state.revision,
288
+ missionId: mission.id,
289
+ missionTitle: mission.title,
290
+ baseTip,
291
+ branchTip,
292
+ gateTip: gate.branchTip ?? branchTip,
293
+ changedFiles: gate.changedFiles ?? [],
294
+ timeoutMs: mergeTimeoutMs(input.timeoutMs, tower.config),
295
+ maxBuffer: tower.config.merge.maxBufferBytes || MERGE_MAX_BUFFER,
296
+ }
297
+ }
298
+
299
+ /**
300
+ * Record the intent under a short state lock, immediately before Git runs.
301
+ * A crash between the merge commit and the outcome write is reconciled by the
302
+ * next tower_merge/preflight instead of leaving an unexplained base tip.
303
+ */
304
+ async function recordMergeIntent(tower, plan) {
305
+ return withLock(tower.paths, async () => {
306
+ const state = await tower.load()
307
+ // State revision policy: the plan was built from `plan.stateRevision`; a
308
+ // writer that slipped in before any Git mutation makes the plan stale and
309
+ // the operation safely retryable.
310
+ if (state.revision !== plan.stateRevision) {
311
+ return {
312
+ result: staleBaseResult(
313
+ plan.missionId,
314
+ `tower state changed while the merge was being prepared (${plan.stateRevision} -> ${state.revision}); retry`,
315
+ ),
316
+ }
317
+ }
318
+ const mission = state.missions.find((entry) => entry.id === plan.missionId)
319
+ if (!mission) return { result: missionMissingResult(plan.missionId) }
320
+ if (mission.status === 'merged') {
321
+ return { result: alreadyMergedResult(mission, plan.baseTip) }
322
+ }
323
+ // Only one merge may be in flight: two prepared merges would otherwise
324
+ // capture the same base tip and the second would build a merge on a moved
325
+ // base. Leave the recorded intent alone for the next reconcile.
326
+ if (state.mergeIntent) {
327
+ return { result: pendingMergeIntentResult(plan.missionId, state.mergeIntent) }
328
+ }
329
+ const now = Date.now()
330
+ state.mergeIntent = buildMergeIntent({
331
+ missionId: mission.id,
332
+ baseTip: plan.baseTip,
333
+ branchTip: plan.branchTip,
334
+ gateTip: plan.gateTip,
335
+ startedAt: now,
336
+ })
337
+ state.revision += 1
338
+ state.updatedAt = now
339
+ await writeJsonAtomic(tower.paths.state, state)
340
+ return { intent: state.mergeIntent, intentRevision: state.revision }
341
+ })
342
+ }
343
+
344
+ /**
345
+ * Phase 2: run the exact captured merge and its failure cleanup. No state lock
346
+ * is held here; `withGitLock` serializes this against other Tower git mutations.
347
+ */
348
+ async function runMergeGit(tower, plan, intent, deps) {
349
+ const { describeGitFailure, commitParents, removeStaleGitLocks } = deps
350
+ let failure = null
351
+ try {
352
+ // Merge the exact reviewed commit, never a mutable branch name. A branch
353
+ // move after the gate must not smuggle extra commits into base.
354
+ await tower.git(
355
+ tower.root,
356
+ ['merge', '--no-ff', intent.branchTip, '-m', `tower: merge ${plan.missionId} — ${plan.missionTitle}`],
357
+ { timeoutMs: plan.timeoutMs, maxBuffer: plan.maxBuffer },
358
+ )
359
+ } catch (error) {
360
+ failure = error
361
+ }
362
+
363
+ const newTip = await tower.revParse(tower.root, 'HEAD')
364
+ const parents = await commitParents(tower.root, newTip)
365
+ const expectedShape = isExpectedMergeShape(parents, intent.baseTip, intent.branchTip)
366
+ const warnings = []
367
+
368
+ if (failure && expectedShape) {
369
+ warnings.push(
370
+ `merge command reported a failure (${describeGitFailure(failure)}) but merge commit ${newTip.slice(0, 7)} exists; treated as merged`,
371
+ )
372
+ const dirty = await dirtyPaths(tower)
373
+ if (dirty.length > 0) {
374
+ const changed = new Set(plan.changedFiles)
375
+ if (dirty.every((file) => changed.has(file))) {
376
+ await tower.git(tower.root, ['reset', '--hard', 'HEAD'])
377
+ warnings.push('restored index/worktree to the merge commit (only merge-related paths were dirty)')
378
+ } else {
379
+ warnings.push('working tree still has unrelated changes; left untouched — inspect it before the next Git operation')
380
+ }
381
+ }
382
+ } else if (failure) {
383
+ const unmerged = (await tower.tryGit(tower.root, ['ls-files', '-u'])) ?? ''
384
+ const conflict = unmerged.trim().length > 0
385
+ let cleanup = 'none'
386
+ const mergeHeadPath = path.join(tower.commonDir, 'MERGE_HEAD')
387
+ if (await exists(mergeHeadPath)) {
388
+ try {
389
+ await tower.git(tower.root, ['merge', '--abort'])
390
+ cleanup = 'aborted'
391
+ } catch {
392
+ await removeStaleGitLocks(tower.commonDir)
393
+ try {
394
+ await tower.git(tower.root, ['merge', '--abort'])
395
+ cleanup = 'aborted-after-lock-cleanup'
396
+ } catch {
397
+ await tower.git(tower.root, ['reset', '--hard', intent.baseTip]).catch(() => {})
398
+ cleanup = 'reset'
399
+ }
400
+ }
401
+ } else if (newTip !== intent.baseTip) {
402
+ // HEAD moved without Tower's expected merge shape. This may be a
403
+ // concurrent commit; never hard-reset it away. Leave a visible
404
+ // manual-recovery state instead.
405
+ cleanup = 'manual-required'
406
+ warnings.push('HEAD moved unexpectedly during a failed merge; left in place for manual recovery')
407
+ }
408
+ // A killed git process can leave the index staged even without MERGE_HEAD.
409
+ // Restore only when every dirty path belongs to this merge.
410
+ const dirtyAfter = await dirtyPaths(tower)
411
+ if (dirtyAfter.length > 0) {
412
+ const changed = new Set(plan.changedFiles)
413
+ if (newTip === intent.baseTip && dirtyAfter.every((file) => changed.has(file))) {
414
+ await removeStaleGitLocks(tower.commonDir)
415
+ await tower.git(tower.root, ['reset', '--hard', intent.baseTip]).catch(() => {})
416
+ cleanup = cleanup === 'none' ? 'reset' : `${cleanup}+reset`
417
+ } else {
418
+ warnings.push(`cleanup left changes for manual review: ${dirtyAfter.slice(0, 5).join(', ')}`)
419
+ }
420
+ }
421
+ return {
422
+ kind: mergeFailureKind(conflict),
423
+ detail: describeGitFailure(failure).slice(0, 2000),
424
+ cleanup,
425
+ warnings,
426
+ timeout: failure.timedOut === true,
427
+ }
428
+ }
429
+
430
+ if (!expectedShape) {
431
+ return { kind: 'merge-shape', newTip, parents, warnings }
432
+ }
433
+ return { kind: 'merged', newTip, parents, warnings }
434
+ }
435
+
436
+ /** Phase 3: apply the observed outcome under a short state lock. */
437
+ async function commitMerge(tower, plan, recorded, outcome) {
438
+ return withLock(tower.paths, async () => {
439
+ const state = await tower.load()
440
+ const mission = state.missions.find((entry) => entry.id === plan.missionId)
441
+ if (!mission) return missionMissingResult(plan.missionId)
442
+ // State revision policy: another writer changed the state while Git was
443
+ // running. Do not overwrite their update and do not guess: keep the merge
444
+ // intent (the pending marker) so the next tower_merge/preflight reconciles
445
+ // the landed commit. Nothing has been written by this call yet.
446
+ if (state.revision !== recorded.intentRevision) {
447
+ return staleBaseResult(
448
+ plan.missionId,
449
+ `tower state changed while the merge was running (${recorded.intentRevision} -> ${state.revision}); the merge intent is retained — retry tower_merge to reconcile`,
450
+ )
451
+ }
452
+ const intentActive = intentMatches(state.mergeIntent, recorded.intent)
453
+ // The parents were read from HEAD immediately after the merge while the git
454
+ // mutex was still held. Re-verify them here: recording a "merged" mission is
455
+ // only safe when HEAD is [intent.baseTip, intent.branchTip].
456
+ const kind = classifyCommitOutcome(
457
+ outcome.kind,
458
+ outcome.parents,
459
+ recorded.intent.baseTip,
460
+ recorded.intent.branchTip,
461
+ )
462
+
463
+ if (kind === 'merged') {
464
+ const now = Date.now()
465
+ if (intentActive) delete state.mergeIntent
466
+ mission.status = 'merged'
467
+ mission.mergedAt = now
468
+ mission.mergedCommit = outcome.newTip
469
+ mission.updatedAt = now
470
+ mission.notes.push(mergeSuccessNote(recorded.intent.branchTip, state.base, outcome.newTip))
471
+ state.revision += 1
472
+ state.updatedAt = now
473
+ validateState(state, tower.paths.state)
474
+ await writeJsonAtomic(tower.paths.state, state)
475
+ await appendJsonl(tower.paths.activity, {
476
+ at: now,
477
+ type: 'merge',
478
+ missionId: mission.id,
479
+ text: `${recorded.intent.branchTip.slice(0, 7)} -> ${outcome.newTip.slice(0, 7)} on ${state.base}`,
480
+ })
481
+ return {
482
+ ok: true,
483
+ mergedCommit: outcome.newTip,
484
+ baseTip: recorded.intent.baseTip,
485
+ branchTip: recorded.intent.branchTip,
486
+ parents: outcome.parents,
487
+ ...(outcome.warnings.length > 0 ? { warnings: outcome.warnings, recoveredFromFailure: true } : {}),
488
+ }
489
+ }
490
+
491
+ if (kind === 'merge-shape') {
492
+ // Keep the intent and the observed tip. A shape mismatch means Tower
493
+ // cannot prove what landed; deleting the intent here would make the
494
+ // moved base unreconcilable and could discard a real commit.
495
+ const detail = mergeShapeDetail(outcome.parents, recorded.intent.baseTip, recorded.intent.branchTip)
496
+ if (intentActive) {
497
+ state.mergeIntent.problem = { code: 'merge-shape', newTip: outcome.newTip, parents: outcome.parents, at: Date.now() }
498
+ state.revision += 1
499
+ state.updatedAt = Date.now()
500
+ await writeJsonAtomic(tower.paths.state, state)
501
+ await appendJsonl(tower.paths.activity, {
502
+ at: state.updatedAt,
503
+ type: 'merge',
504
+ missionId: mission.id,
505
+ text: `shape-mismatch newTip=${outcome.newTip.slice(0, 12)} parents=${outcome.parents.length}`,
506
+ })
507
+ }
508
+ return {
509
+ ok: false,
510
+ missionId: mission.id,
511
+ blockers: [{ code: 'merge-shape', detail, cleanup: 'manual-required' }],
512
+ newTip: outcome.newTip,
513
+ parents: outcome.parents,
514
+ intentRetained: true,
515
+ }
516
+ }
517
+
518
+ // merge-conflict / merge-hook-failed: the merge created no commit.
519
+ const conflict = kind === 'merge-conflict'
520
+ const now = Date.now()
521
+ if (intentActive) delete state.mergeIntent
522
+ if (conflict) {
523
+ mission.status = 'blocked'
524
+ mission.blockers.push(mergeConflictNote(outcome.detail))
525
+ } else {
526
+ mission.notes.push(mergeHookFailureNote(outcome.detail, mission.status))
527
+ }
528
+ mission.updatedAt = now
529
+ state.revision += 1
530
+ state.updatedAt = now
531
+ await writeJsonAtomic(tower.paths.state, state)
532
+ await appendJsonl(tower.paths.activity, {
533
+ at: now,
534
+ type: 'merge',
535
+ missionId: mission.id,
536
+ text: `${conflict ? 'conflict' : 'failed-before-commit'} (${outcome.cleanup})`,
537
+ })
538
+ return mergeBlockedResult({
539
+ missionId: mission.id,
540
+ code: conflict ? 'merge-conflict' : 'merge-hook-failed',
541
+ detail: outcome.detail,
542
+ timeout: outcome.timeout === true,
543
+ cleanup: outcome.cleanup,
544
+ warnings: outcome.warnings,
545
+ })
546
+ })
547
+ }
548
+
549
+ export async function merge(tower, input, deps) {
550
+ const plan = await prepareMerge(tower, input, deps)
551
+ if (plan.result) return plan.result
552
+
553
+ const acted = await withGitLock(tower.paths, async () => {
554
+ // No Git mutation has happened yet: a base that moved after prepare is a
555
+ // clean retryable stale-base rejection, with no intent left behind.
556
+ const head = await tower.revParse(tower.root, 'HEAD')
557
+ if (head !== plan.baseTip) {
558
+ return {
559
+ result: staleBaseResult(
560
+ plan.missionId,
561
+ `base moved after gate (${plan.baseTip.slice(0, 7)} -> ${head.slice(0, 7)}); re-run preflight`,
562
+ ),
563
+ }
564
+ }
565
+ const recorded = await recordMergeIntent(tower, plan)
566
+ if (recorded.result) return recorded
567
+ const outcome = await runMergeGit(tower, plan, recorded.intent, deps)
568
+ return { recorded, outcome }
569
+ })
570
+ if (acted.result) return acted.result
571
+
572
+ return commitMerge(tower, plan, acted.recorded, acted.outcome)
573
+ }
@@ -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,37 @@
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
+ import { sweepArchive } from './archive.js'
8
+
9
+ function fail(message, code = 'invalid-argument') {
10
+ throw new TowerToolError(code, message)
11
+ }
12
+
13
+ export async function updateMission(tower, input = {}) {
14
+ const missionId = typeof input.id === 'string' ? input.id.trim() : input.id
15
+ if (typeof missionId !== 'string' || missionId.length === 0) fail('tower_mission requires id')
16
+ return withLock(tower.paths, async () => {
17
+ const state = await tower.load()
18
+ const mission = state.missions.find((entry) => entry.id === missionId)
19
+ if (!mission) fail(`unknown mission "${missionId}"`, 'mission-not-found')
20
+ const { changes } = applyMissionUpdate({ mission, state, input, config: tower.config })
21
+ const now = Date.now()
22
+ mission.updatedAt = now
23
+ await sweepArchive(tower, state, { now })
24
+ state.revision += 1
25
+ state.updatedAt = now
26
+
27
+ validateState(state, tower.paths.state)
28
+ await writeJsonAtomic(tower.paths.state, state)
29
+ await appendJsonl(tower.paths.activity, {
30
+ at: now,
31
+ type: 'mission',
32
+ missionId: mission.id,
33
+ text: changes.join('; '),
34
+ })
35
+ return { revision: state.revision, mission: fullMission(mission) }
36
+ })
37
+ }
@@ -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
+ }