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
@@ -0,0 +1,261 @@
1
+ // Pure init decisions: explicit/fallback base selection, ownership and reanchor
2
+ // policy, snapshot decisions, and the initial/existing result projections. All
3
+ // fs/git work and state persistence stay in app/init.js.
4
+ // @ts-check
5
+ import { SCHEMA_VERSION, TERMINAL_STATUSES } from '../mission.js'
6
+
7
+ /** @typedef {import('../../types/index.js').Mission} Mission */
8
+ /** @typedef {import('../../types/index.js').TowerState} TowerState */
9
+
10
+ export const SNAPSHOT_REF_PREFIX = 'refs/tower/snapshot/'
11
+
12
+ /**
13
+ * @param {string} commit
14
+ * @returns {string}
15
+ */
16
+ export function snapshotRefFor(commit) {
17
+ return `${SNAPSHOT_REF_PREFIX}${commit}`
18
+ }
19
+
20
+ /**
21
+ * @param {unknown} value
22
+ * @returns {string | null}
23
+ */
24
+ export function explicitBase(value) {
25
+ return typeof value === 'string' && value.trim() ? value.trim() : null
26
+ }
27
+
28
+ /**
29
+ * An explicit base must be a local branch name, never a raw ref or remote ref.
30
+ * @param {string} base
31
+ * @returns {string | null} the failure message, or null when valid
32
+ */
33
+ export function invalidBaseMessage(base) {
34
+ return /^refs\//.test(base) || base.startsWith('origin/') ? `base must be a local branch name, not ${base}` : null
35
+ }
36
+
37
+ /**
38
+ * Without an explicit base, use the checked-out branch and fall back to `main`
39
+ * only for an unborn repository.
40
+ * @param {object} input
41
+ * @param {string | null} [input.branch]
42
+ * @param {boolean} input.committed
43
+ * @returns {{ base: string } | { error: string }}
44
+ */
45
+ export function fallbackBase({ branch, committed }) {
46
+ let base = branch
47
+ if (!base && !committed) base = 'main'
48
+ if (!base) return { error: 'repository is in detached HEAD; pass an explicit local base branch' }
49
+ return { base }
50
+ }
51
+
52
+ /**
53
+ * @param {string | null} owner
54
+ * @param {string | null} caller
55
+ * @returns {boolean}
56
+ */
57
+ export function ownedByOther(owner, caller) {
58
+ return Boolean(owner && caller && owner !== caller)
59
+ }
60
+
61
+ /**
62
+ * @param {string | null} owner
63
+ * @param {string | null} caller
64
+ * @returns {string | null} the caller that claims an unowned state
65
+ */
66
+ export function claimCaller(owner, caller) {
67
+ return !owner && caller ? caller : null
68
+ }
69
+
70
+ /**
71
+ * @param {Mission[]} missions
72
+ * @returns {string | null} refusal message, or null when reanchor may proceed
73
+ */
74
+ export function reanchorBlockedMessage(missions) {
75
+ const active = missions.filter((mission) => !TERMINAL_STATUSES.has(mission.status))
76
+ return active.length > 0 ? `cannot reanchor base while ${active.length} mission(s) are not merged or abandoned` : null
77
+ }
78
+
79
+ /**
80
+ * @param {object} input
81
+ * @param {boolean} input.reanchor
82
+ * @param {string | null} input.reanchorBase
83
+ * @param {string} input.currentBase
84
+ * @returns {boolean}
85
+ */
86
+ export function shouldReanchor({ reanchor, reanchorBase, currentBase }) {
87
+ return reanchor === true && reanchorBase !== currentBase
88
+ }
89
+
90
+ /**
91
+ * @param {object} input
92
+ * @param {boolean} input.snapshotDirty
93
+ * @param {boolean} input.dirty
94
+ * @returns {boolean}
95
+ */
96
+ export function shouldSnapshot({ snapshotDirty, dirty }) {
97
+ return snapshotDirty === true && dirty
98
+ }
99
+
100
+ /**
101
+ * @param {object} input
102
+ * @param {TowerState} input.state
103
+ * @param {string} input.statePath
104
+ * @param {boolean} input.dirty
105
+ * @returns {object}
106
+ */
107
+ export function existingInitResult({ state, statePath, dirty }) {
108
+ return {
109
+ created: false,
110
+ repoRoot: state.repoRoot,
111
+ commonDir: state.commonDir,
112
+ statePath,
113
+ base: state.base,
114
+ revision: state.revision,
115
+ missions: state.missions.length,
116
+ reanchored: false,
117
+ dirty,
118
+ snapshotCommit: state.snapshotCommit ?? null,
119
+ caseInsensitive: state.caseInsensitive ?? null,
120
+ }
121
+ }
122
+
123
+ /**
124
+ * @param {object} input
125
+ * @param {string} input.repoRoot
126
+ * @param {string} input.commonDir
127
+ * @param {string} input.base
128
+ * @param {boolean} input.dirty
129
+ * @param {string | null} [input.towerAgentId]
130
+ * @param {string | null} [input.snapshotCommit]
131
+ * @param {string | null} [input.snapshotRef]
132
+ * @param {boolean | null} [input.caseInsensitive]
133
+ * @param {number} input.now
134
+ * @returns {TowerState}
135
+ */
136
+ export function initialState({
137
+ repoRoot,
138
+ commonDir,
139
+ base,
140
+ dirty,
141
+ towerAgentId = null,
142
+ snapshotCommit = null,
143
+ snapshotRef = null,
144
+ caseInsensitive = null,
145
+ now,
146
+ }) {
147
+ return {
148
+ schemaVersion: SCHEMA_VERSION,
149
+ repoRoot,
150
+ commonDir,
151
+ base,
152
+ revision: 0,
153
+ createdAt: now,
154
+ updatedAt: now,
155
+ dirtyBase: dirty,
156
+ caseInsensitive,
157
+ ...(towerAgentId ? { towerAgentId: String(towerAgentId) } : {}),
158
+ ...(snapshotCommit ? { snapshotCommit, snapshotRef } : {}),
159
+ missions: [],
160
+ }
161
+ }
162
+
163
+ /**
164
+ * @param {object} input
165
+ * @param {string} input.repoRoot
166
+ * @param {string} input.commonDir
167
+ * @param {string} input.statePath
168
+ * @param {string} input.base
169
+ * @param {boolean} input.dirty
170
+ * @param {string | null} input.snapshotCommit
171
+ * @param {string | null} input.snapshotRef
172
+ * @param {boolean | null} [input.caseInsensitive]
173
+ * @returns {object}
174
+ */
175
+ export function createdInitResult({
176
+ repoRoot,
177
+ commonDir,
178
+ statePath,
179
+ base,
180
+ dirty,
181
+ snapshotCommit,
182
+ snapshotRef,
183
+ caseInsensitive = null,
184
+ }) {
185
+ return {
186
+ created: true,
187
+ repoRoot,
188
+ commonDir,
189
+ statePath,
190
+ base,
191
+ revision: 0,
192
+ missions: 0,
193
+ reanchored: false,
194
+ dirty,
195
+ snapshotCommit,
196
+ snapshotRef,
197
+ caseInsensitive,
198
+ }
199
+ }
200
+
201
+ /**
202
+ * @param {object} input
203
+ * @param {TowerState} input.state
204
+ * @param {string} input.statePath
205
+ * @param {boolean} input.dirty
206
+ * @param {boolean} input.reanchored
207
+ * @returns {object}
208
+ */
209
+ export function existingInitWriteResult({ state, statePath, dirty, reanchored }) {
210
+ return {
211
+ created: false,
212
+ repoRoot: state.repoRoot,
213
+ commonDir: state.commonDir,
214
+ statePath,
215
+ base: state.base,
216
+ revision: state.revision,
217
+ missions: state.missions.length,
218
+ reanchored,
219
+ dirty,
220
+ snapshotCommit: state.snapshotCommit ?? null,
221
+ caseInsensitive: state.caseInsensitive ?? null,
222
+ }
223
+ }
224
+
225
+ /**
226
+ * @param {object} input
227
+ * @param {string} input.base
228
+ * @param {string | null} [input.snapshotCommit]
229
+ * @param {boolean} input.dirty
230
+ * @param {boolean | null} [input.caseInsensitive]
231
+ * @returns {string}
232
+ */
233
+ export function initActivityText({ base, snapshotCommit = null, dirty, caseInsensitive = null }) {
234
+ const mode = caseInsensitive === null ? '' : ` (${caseInsensitive ? 'case-insensitive' : 'case-sensitive'} filesystem)`
235
+ return `base ${base}${snapshotCommit ? ` snapshot ${snapshotCommit.slice(0, 12)}` : ''}${dirty ? ' (dirty base)' : ''}${mode}`
236
+ }
237
+
238
+ /**
239
+ * Activity line for a probe recorded on an existing state.
240
+ * @param {boolean} caseInsensitive
241
+ * @returns {string}
242
+ */
243
+ export function caseProbeActivityText(caseInsensitive) {
244
+ return `case sensitivity detected: ${caseInsensitive ? 'case-insensitive' : 'case-sensitive'}`
245
+ }
246
+
247
+ /**
248
+ * @param {string} caller
249
+ * @returns {string}
250
+ */
251
+ export function ownerClaimActivityText(caller) {
252
+ return `tower owner claimed by ${caller}`
253
+ }
254
+
255
+ /**
256
+ * @param {string} base
257
+ * @returns {string}
258
+ */
259
+ export function reanchorActivityText(base) {
260
+ return `base reanchored to ${base}`
261
+ }
@@ -0,0 +1,31 @@
1
+ // Pure roster-liveness decisions shared by rebase/teardown guards. A live
2
+ // worker is a roster row for the mission that is neither inactive nor finished
3
+ // and whose observed activity is "running".
4
+ // @ts-check
5
+ import { ROSTER_INACTIVE } from '../roster.js'
6
+
7
+ /** @typedef {import('../../types/index.js').RosterEntry} RosterEntry */
8
+ /** @typedef {import('../../types/index.js').TowerState} TowerState */
9
+
10
+ /**
11
+ * @param {TowerState} state
12
+ * @param {string} missionId
13
+ * @param {Record<string, string | undefined>} activity
14
+ * @returns {RosterEntry | undefined}
15
+ */
16
+ export function liveAgentFor(state, missionId, activity) {
17
+ return (state.roster ?? []).find(
18
+ (entry) =>
19
+ entry.missionId === missionId && !ROSTER_INACTIVE.has(entry.state) && activity[String(entry.agentId)] === 'running',
20
+ )
21
+ }
22
+
23
+ /**
24
+ * @param {TowerState} state
25
+ * @param {string} missionId
26
+ * @param {Record<string, string | undefined>} activity
27
+ * @returns {boolean}
28
+ */
29
+ export function hasLiveAgent(state, missionId, activity) {
30
+ return liveAgentFor(state, missionId, activity) !== undefined
31
+ }
@@ -0,0 +1,377 @@
1
+ // Pure merge decisions: merge-intent / parent / tip validation, commit-stage
2
+ // classification, and already-landed reconciliation decisions. Git reads and
3
+ // writes, the state lock, and persistence stay in app/merge.js.
4
+ // @ts-check
5
+ import { isReviewExempt, latestReview, reviewsForBranch } from './review.js'
6
+
7
+ /** @typedef {import('../../types/index.js').Mission} Mission */
8
+ /** @typedef {import('../../types/index.js').Review} Review */
9
+ /** @typedef {import('../../types/index.js').TowerConfig} TowerConfig */
10
+
11
+ /**
12
+ * @typedef {object} MergeIntent
13
+ * @property {string} missionId
14
+ * @property {string} baseTip
15
+ * @property {string} branchTip
16
+ * @property {string} [gateTip]
17
+ * @property {number} [startedAt]
18
+ */
19
+
20
+ /**
21
+ * @typedef {object} LandedMerge
22
+ * @property {string} commit
23
+ * @property {string[]} parents
24
+ */
25
+
26
+ /**
27
+ * @param {string} missionId
28
+ * @returns {{ ok: false, blockers: Array<{ code: string, detail: string }> }}
29
+ */
30
+ export function missionMissingResult(missionId) {
31
+ return { ok: false, blockers: [{ code: 'mission-missing', detail: `unknown mission "${missionId}"` }] }
32
+ }
33
+
34
+ /**
35
+ * @param {string} missionId
36
+ * @param {string} detail
37
+ * @returns {{ ok: false, missionId: string, blockers: Array<{ code: string, detail: string }> }}
38
+ */
39
+ export function staleBaseResult(missionId, detail) {
40
+ return { ok: false, missionId, blockers: [{ code: 'stale-base', detail }] }
41
+ }
42
+
43
+ /**
44
+ * @param {string} missionId
45
+ * @param {string} detail
46
+ * @returns {{ ok: false, missionId: string, blockers: Array<{ code: string, detail: string }> }}
47
+ */
48
+ export function tipMovedResult(missionId, detail) {
49
+ return { ok: false, missionId, blockers: [{ code: 'tip-moved', detail }] }
50
+ }
51
+
52
+ /**
53
+ * @param {Mission} mission
54
+ * @param {string} baseTip
55
+ * @returns {{ ok: true, alreadyMerged: true, mergedCommit: string | null, baseTip: string }}
56
+ */
57
+ export function alreadyMergedResult(mission, baseTip) {
58
+ return { ok: true, alreadyMerged: true, mergedCommit: mission.mergedCommit ?? null, baseTip }
59
+ }
60
+
61
+ /**
62
+ * The gate captured exact tips; a moved tip invalidates the prepared merge.
63
+ * Base drift keeps the historical `stale-base` code, branch drift `tip-moved`.
64
+ * @param {object} input
65
+ * @param {string} input.missionId
66
+ * @param {string | null} [input.gateBaseTip]
67
+ * @param {string | null} [input.gateBranchTip]
68
+ * @param {string} input.baseTip
69
+ * @param {string} input.branchTip
70
+ * @returns {ReturnType<typeof staleBaseResult> | ReturnType<typeof tipMovedResult> | null}
71
+ */
72
+ export function gateTipDrift({ missionId, gateBaseTip = null, gateBranchTip = null, baseTip, branchTip }) {
73
+ if (gateBaseTip && gateBaseTip !== baseTip) {
74
+ return staleBaseResult(
75
+ missionId,
76
+ `base moved after gate (${gateBaseTip.slice(0, 7)} -> ${baseTip.slice(0, 7)}); re-run preflight`,
77
+ )
78
+ }
79
+ if (gateBranchTip && gateBranchTip !== branchTip) {
80
+ return tipMovedResult(
81
+ missionId,
82
+ `branch moved after gate (${gateBranchTip.slice(0, 7)} -> ${branchTip.slice(0, 7)}); re-run preflight`,
83
+ )
84
+ }
85
+ return null
86
+ }
87
+
88
+ /**
89
+ * @param {MergeIntent | null | undefined} current
90
+ * @param {MergeIntent} intent
91
+ * @returns {boolean}
92
+ */
93
+ export function intentMatches(current, intent) {
94
+ return Boolean(
95
+ current &&
96
+ current.missionId === intent.missionId &&
97
+ current.baseTip === intent.baseTip &&
98
+ current.branchTip === intent.branchTip,
99
+ )
100
+ }
101
+
102
+ /**
103
+ * `--no-ff` puts the integrated branch on the second parent.
104
+ * @param {string[] | null | undefined} parents
105
+ * @param {string} baseTip
106
+ * @param {string} branchTip
107
+ * @returns {boolean}
108
+ */
109
+ export function isExpectedMergeShape(parents, baseTip, branchTip) {
110
+ return Array.isArray(parents) && parents.length === 2 && parents[0] === baseTip && parents[1] === branchTip
111
+ }
112
+
113
+ /**
114
+ * Only one merge may be in flight at a time.
115
+ * @param {string} missionId
116
+ * @param {MergeIntent} existing
117
+ * @returns {{ ok: false, missionId: string, blockers: Array<{ code: string, detail: string }> }}
118
+ */
119
+ export function pendingMergeIntentResult(missionId, existing) {
120
+ return {
121
+ ok: false,
122
+ missionId,
123
+ blockers: [
124
+ {
125
+ code: 'merge-intent-pending',
126
+ detail: `a merge intent for ${existing.missionId} is recorded; run tower_merge to reconcile it before starting another merge`,
127
+ },
128
+ ],
129
+ }
130
+ }
131
+
132
+ /**
133
+ * @param {object} input
134
+ * @param {string} input.missionId
135
+ * @param {string} input.baseTip
136
+ * @param {string} input.branchTip
137
+ * @param {string} input.gateTip
138
+ * @param {number} input.startedAt
139
+ * @returns {MergeIntent}
140
+ */
141
+ export function buildMergeIntent({ missionId, baseTip, branchTip, gateTip, startedAt }) {
142
+ return { missionId, baseTip, branchTip, gateTip, startedAt }
143
+ }
144
+
145
+ /**
146
+ * A "merged" observation only counts when the parents are the captured tips.
147
+ * @param {string} kind
148
+ * @param {string[]} parents
149
+ * @param {string} baseTip
150
+ * @param {string} branchTip
151
+ * @returns {string}
152
+ */
153
+ export function classifyCommitOutcome(kind, parents, baseTip, branchTip) {
154
+ return kind === 'merged' && !isExpectedMergeShape(parents, baseTip, branchTip) ? 'merge-shape' : kind
155
+ }
156
+
157
+ /**
158
+ * @param {boolean} conflict
159
+ * @returns {'merge-conflict' | 'merge-hook-failed'}
160
+ */
161
+ export function mergeFailureKind(conflict) {
162
+ return conflict ? 'merge-conflict' : 'merge-hook-failed'
163
+ }
164
+
165
+ /**
166
+ * @param {string[]} parents
167
+ * @param {string} baseTip
168
+ * @param {string} branchTip
169
+ * @returns {string}
170
+ */
171
+ export function mergeShapeDetail(parents, baseTip, branchTip) {
172
+ return `unexpected merge parents [${parents.join(', ')}], expected [${baseTip}, ${branchTip}]`
173
+ }
174
+
175
+ /**
176
+ * @param {string} branchTip
177
+ * @param {string} base
178
+ * @param {string} newTip
179
+ * @returns {string}
180
+ */
181
+ export function mergeSuccessNote(branchTip, base, newTip) {
182
+ return `merged ${branchTip.slice(0, 7)} into ${base} as ${newTip.slice(0, 7)}`
183
+ }
184
+
185
+ /**
186
+ * @param {string} detail
187
+ * @returns {string}
188
+ */
189
+ export function mergeConflictNote(detail) {
190
+ return `merge conflict: ${detail.slice(0, 240)}`
191
+ }
192
+
193
+ /**
194
+ * @param {string} detail
195
+ * @param {string} status
196
+ * @returns {string}
197
+ */
198
+ export function mergeHookFailureNote(detail, status) {
199
+ return `merge command failed before creating a commit (${detail.slice(0, 240)}); mission left ${status} for retry`
200
+ }
201
+
202
+ /**
203
+ * @param {object} input
204
+ * @param {string} input.missionId
205
+ * @param {string} input.code
206
+ * @param {string} input.detail
207
+ * @param {string[]} [input.warnings]
208
+ * @param {boolean} [input.timeout]
209
+ * @param {string} [input.cleanup]
210
+ * @returns {object}
211
+ */
212
+ export function mergeBlockedResult({ missionId, code, detail, warnings = [], timeout = false, cleanup }) {
213
+ return {
214
+ ok: false,
215
+ missionId,
216
+ blockers: [
217
+ {
218
+ code,
219
+ detail,
220
+ ...(timeout === true ? { timeout: true } : {}),
221
+ ...(cleanup === undefined ? {} : { cleanup }),
222
+ },
223
+ ],
224
+ ...(warnings.length > 0 ? { warnings } : {}),
225
+ }
226
+ }
227
+
228
+ /**
229
+ * Already-landed reconciliation only considers completed/blocked missions with
230
+ * a worktree whose review rules can be satisfied by the snapshot.
231
+ * @param {Mission} mission
232
+ * @param {Review[] | null | undefined} reviews
233
+ * @param {TowerConfig} [config]
234
+ * @returns {{ exempt: boolean, latest: Review | null } | null}
235
+ */
236
+ export function landedMergeCandidate(mission, reviews, config = {}) {
237
+ if (!['completed', 'blocked'].includes(mission.status)) return null
238
+ if (!mission.worktree) return null
239
+ const branchReviews = reviewsForBranch(reviews, mission.worktree.branch)
240
+ const exempt = isReviewExempt(mission, config)
241
+ if (branchReviews.length === 0 && !exempt) return null
242
+ const latest = latestReview(branchReviews)
243
+ if (latest && !exempt && (latest.status !== 'clean' || latest.mergeVerdict !== 'merge')) return null
244
+ if (!latest && !exempt) return null
245
+ return { exempt, latest }
246
+ }
247
+
248
+ /**
249
+ * @param {object} input
250
+ * @param {string} input.missionId
251
+ * @param {string} input.base
252
+ * @param {string} input.branchTip
253
+ * @param {string} input.reviewedTip
254
+ * @returns {object}
255
+ */
256
+ export function reviewStaleResult({ missionId, base, branchTip, reviewedTip }) {
257
+ return {
258
+ ok: false,
259
+ missionId,
260
+ blockers: [
261
+ {
262
+ code: 'review-stale',
263
+ detail: `reviewed commit ${reviewedTip.slice(0, 7)} is contained in ${base}, but branch tip ${branchTip.slice(0, 7)} is not; the branch was partially landed or moved — re-review or repair manually`,
264
+ },
265
+ ],
266
+ }
267
+ }
268
+
269
+ /**
270
+ * Decide what a landed-merge observation means from the two ancestry answers.
271
+ * `skip` means there is nothing safe to reconcile yet.
272
+ * @param {object} input
273
+ * @param {string} input.missionId
274
+ * @param {string} input.base
275
+ * @param {string} input.baseTip
276
+ * @param {string} input.branchTip
277
+ * @param {string | null} input.reviewedTip
278
+ * @param {boolean} input.branchLanded
279
+ * @param {boolean} input.reviewedLanded
280
+ * @param {LandedMerge | null} input.found
281
+ * @param {Review | null} input.latest
282
+ * @returns {{ skip: true } | { reviewStale: object } | { measured: object }}
283
+ */
284
+ export function landedMergeEvidence({
285
+ missionId,
286
+ base,
287
+ baseTip,
288
+ branchTip,
289
+ reviewedTip,
290
+ branchLanded,
291
+ reviewedLanded,
292
+ found,
293
+ latest,
294
+ }) {
295
+ if (!branchLanded) {
296
+ if (!reviewedLanded) return { skip: true }
297
+ return { reviewStale: reviewStaleResult({ missionId, base, branchTip, reviewedTip: /** @type {string} */ (reviewedTip) }) }
298
+ }
299
+ return {
300
+ measured: {
301
+ baseTip,
302
+ branchTip,
303
+ reviewedTip,
304
+ found,
305
+ mergedCommit: found ? found.commit : branchTip,
306
+ latest,
307
+ },
308
+ }
309
+ }
310
+
311
+ /**
312
+ * @param {object} observed
313
+ * @param {string} observed.mergedCommit
314
+ * @param {string} observed.baseTip
315
+ * @param {string} observed.branchTip
316
+ * @param {LandedMerge | null} observed.found
317
+ * @param {Review | null} observed.latest
318
+ * @param {string | null} observed.reviewedTip
319
+ * @returns {object}
320
+ */
321
+ export function landedMergeResult(observed) {
322
+ return {
323
+ ok: true,
324
+ reconciled: true,
325
+ alreadyLanded: true,
326
+ mergedCommit: observed.mergedCommit,
327
+ baseTip: observed.baseTip,
328
+ branchTip: observed.branchTip,
329
+ parents: observed.found ? observed.found.parents : null,
330
+ review: observed.latest
331
+ ? {
332
+ round: observed.latest.round,
333
+ status: observed.latest.status,
334
+ mergeVerdict: observed.latest.mergeVerdict,
335
+ reviewer: observed.latest.reviewer,
336
+ reviewedCommit: observed.reviewedTip,
337
+ }
338
+ : null,
339
+ }
340
+ }
341
+
342
+ /**
343
+ * @param {object} input
344
+ * @param {LandedMerge | null} input.found
345
+ * @param {string} input.mergedCommit
346
+ * @param {string} input.base
347
+ * @returns {string}
348
+ */
349
+ export function landedMergeNote({ found, mergedCommit, base }) {
350
+ return found
351
+ ? `reconciled already-landed merge ${mergedCommit.slice(0, 7)} with parents [${found.parents.map((parent) => parent.slice(0, 7)).join(', ')}]`
352
+ : `reconciled already-landed merge: ${mergedCommit.slice(0, 7)} is contained in ${base} (no merge commit found)`
353
+ }
354
+
355
+ /**
356
+ * @param {object} input
357
+ * @param {string} input.head
358
+ * @param {string} input.baseTip
359
+ * @param {boolean} input.mergeInProgress
360
+ * @param {LandedMerge | null} input.landed
361
+ * @returns {{ landed: LandedMerge | null } | null}
362
+ */
363
+ export function mergeIntentEvidence({ head, baseTip, mergeInProgress, landed }) {
364
+ if (mergeInProgress) return null
365
+ if (head === baseTip) return { landed: null }
366
+ return landed ? { landed } : null
367
+ }
368
+
369
+ /**
370
+ * @param {{ landed: LandedMerge | null } | null} evidence
371
+ * @returns {string}
372
+ */
373
+ export function mergeIntentReconcileNote(evidence) {
374
+ return evidence && evidence.landed
375
+ ? `reconciled merge intent; found landed commit ${String(evidence.landed.commit).slice(0, 7)}`
376
+ : 'cleared a merge intent that created no commit'
377
+ }