dsh-tower 2.0.3 → 2.1.0-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (193) hide show
  1. package/CHANGELOG.md +141 -0
  2. package/COMPATIBILITY.md +38 -0
  3. package/CONTRIBUTING.md +150 -2
  4. package/README.en.md +59 -16
  5. package/README.md +57 -16
  6. package/SECURITY.md +31 -1
  7. package/adapters/default.js +10 -0
  8. package/adapters/store-fs.js +6 -0
  9. package/adapters/vcs-git.js +4 -0
  10. package/app/batch.js +79 -0
  11. package/app/board.js +25 -0
  12. package/app/checkCompletion.js +76 -0
  13. package/app/enrichedMissions.js +26 -0
  14. package/app/findings.js +78 -0
  15. package/app/gate.js +108 -0
  16. package/app/init.js +249 -0
  17. package/app/merge.js +567 -0
  18. package/app/messages.js +66 -0
  19. package/app/mission.js +35 -0
  20. package/app/missionContext.js +14 -0
  21. package/app/plan.js +42 -0
  22. package/app/provision.js +193 -0
  23. package/app/read.js +57 -0
  24. package/app/rebase.js +302 -0
  25. package/app/recording.js +72 -0
  26. package/app/review.js +145 -0
  27. package/app/roster.js +123 -0
  28. package/app/spawn.js +125 -0
  29. package/app/teardown.js +184 -0
  30. package/app/tower.js +524 -0
  31. package/cordis.patch.yml +61 -53
  32. package/docs/README.md +33 -1
  33. package/docs/architecture/overview.md +114 -14
  34. package/docs/board.md +72 -0
  35. package/docs/examples/first-mission.md +2 -1
  36. package/docs/faq.md +11 -7
  37. package/docs/guide/hot-reload.md +1 -1
  38. package/docs/guide/install-upgrade-migration.md +22 -6
  39. package/docs/guide/quickstart.md +12 -7
  40. package/docs/guide/release.md +125 -0
  41. package/docs/guide/scenarios-and-philosophy.md +86 -69
  42. package/docs/operations/security.md +65 -14
  43. package/docs/reference/blockers.md +11 -10
  44. package/docs/reference/config.generated.md +66 -0
  45. package/docs/reference/config.md +11 -7
  46. package/docs/reference/errors.generated.md +39 -0
  47. package/docs/reference/errors.md +10 -2
  48. package/docs/reference/events.md +7 -4
  49. package/docs/reference/testing.md +2 -1
  50. package/docs/reference/tools.generated.md +33 -0
  51. package/docs/reference/tools.md +8 -7
  52. package/docs/zh/README.md +4 -3
  53. package/docs/zh/faq.md +9 -6
  54. package/docs/zh/guide/hot-reload.md +1 -1
  55. package/docs/zh/guide/install-upgrade-migration.md +17 -5
  56. package/docs/zh/guide/quickstart.md +13 -8
  57. package/docs/zh/guide/scenarios-and-philosophy.md +55 -50
  58. package/docs/zh/operations/security.md +43 -7
  59. package/docs/zh/reference/blockers.md +10 -10
  60. package/docs/zh/reference/config.md +11 -7
  61. package/docs/zh/reference/errors.md +4 -1
  62. package/docs/zh/reference/events.md +6 -3
  63. package/docs/zh/reference/testing.md +3 -2
  64. package/docs/zh/reference/tools.md +8 -7
  65. package/domain/collab/batch.js +112 -0
  66. package/domain/collab/messages.js +127 -0
  67. package/domain/collab/roster.js +158 -0
  68. package/domain/collab/spawn.js +188 -0
  69. package/domain/config/schema.js +404 -0
  70. package/domain/contracts/outputs.js +279 -0
  71. package/domain/contracts/tool-inputs/collaboration.js +58 -0
  72. package/domain/contracts/tool-inputs/findings.js +29 -0
  73. package/domain/contracts/tool-inputs/index.js +94 -0
  74. package/domain/contracts/tool-inputs/integration.js +37 -0
  75. package/domain/contracts/tool-inputs/missions.js +94 -0
  76. package/domain/contracts/tool-inputs/ops.js +75 -0
  77. package/domain/contracts/tool-inputs/shared.js +25 -0
  78. package/domain/errors.js +96 -0
  79. package/domain/gate.js +125 -0
  80. package/domain/integration/gate.js +176 -0
  81. package/domain/integration/init.js +225 -0
  82. package/domain/integration/liveness.js +31 -0
  83. package/domain/integration/merge.js +377 -0
  84. package/domain/integration/provision.js +124 -0
  85. package/domain/integration/rebase.js +236 -0
  86. package/domain/integration/review.js +52 -0
  87. package/domain/integration/teardown.js +118 -0
  88. package/domain/mission.js +68 -0
  89. package/domain/missions/completion.js +114 -0
  90. package/domain/missions/findings.js +201 -0
  91. package/domain/missions/lifecycle.js +185 -0
  92. package/domain/missions/plan.js +149 -0
  93. package/domain/missions/review.js +179 -0
  94. package/domain/planning.js +118 -0
  95. package/domain/read/enrichedMissions.js +58 -0
  96. package/domain/read/missionContext.js +53 -0
  97. package/domain/read/views.js +128 -0
  98. package/domain/report.js +59 -0
  99. package/domain/roster.js +40 -0
  100. package/domain/scope.js +105 -0
  101. package/domain/state/schema.js +233 -0
  102. package/domain/state/semantics.js +117 -0
  103. package/domain/state.js +33 -0
  104. package/domain/summary.js +114 -0
  105. package/domain/vocabulary.js +179 -0
  106. package/index.js +86 -34
  107. package/lib/briefing.js +176 -0
  108. package/lib/compatibility.js +108 -0
  109. package/lib/config.js +54 -302
  110. package/lib/doctor.js +108 -9
  111. package/lib/git.js +93 -15
  112. package/lib/hmr.js +4 -3
  113. package/lib/leases.js +3 -2
  114. package/lib/legacy.js +20 -9
  115. package/lib/modeState.js +39 -0
  116. package/lib/modeStore.js +35 -33
  117. package/lib/persistence.js +349 -0
  118. package/lib/presets.js +35 -0
  119. package/lib/spawn-prechecks.js +168 -0
  120. package/lib/spawn-support.js +79 -0
  121. package/lib/store.js +57 -85
  122. package/lib/tests.js +46 -11
  123. package/lib/tool-registry.js +544 -0
  124. package/lib/workspaceLock.js +46 -22
  125. package/lib/worktreeOwnership.js +5 -4
  126. package/locale/en.json +23 -1
  127. package/locale/zh.json +22 -0
  128. package/package.json +33 -4
  129. package/ports/clock.js +6 -0
  130. package/ports/ids.js +7 -0
  131. package/ports/index.js +2 -0
  132. package/ports/store.js +11 -0
  133. package/ports/vcs.js +11 -0
  134. package/scripts/architecture.mjs +94 -0
  135. package/scripts/check-docs.mjs +44 -3
  136. package/scripts/check-pack.mjs +49 -0
  137. package/scripts/domain-metrics.mjs +48 -0
  138. package/scripts/generate-config-reference.mjs +46 -0
  139. package/scripts/generate-error-reference.mjs +29 -0
  140. package/scripts/generate-tools-reference.mjs +56 -0
  141. package/scripts/issue-audit.mjs +259 -0
  142. package/scripts/lib/cli.mjs +76 -0
  143. package/scripts/lib/tmp.mjs +20 -0
  144. package/scripts/mount-smoke/fixtures.mjs +162 -0
  145. package/scripts/mount-smoke/host.json +4 -0
  146. package/scripts/mount-smoke/probe.mjs +125 -0
  147. package/scripts/mount-smoke/surface.mjs +141 -0
  148. package/scripts/mount-smoke.mjs +270 -0
  149. package/scripts/pack-manifest.txt +203 -0
  150. package/scripts/release-check.mjs +303 -0
  151. package/skills/tower-recovery.md +7 -5
  152. package/tools/adopt.js +72 -0
  153. package/tools/batch.js +78 -0
  154. package/tools/complete.js +53 -0
  155. package/tools/diff.js +39 -0
  156. package/tools/doctor.js +50 -0
  157. package/tools/finding.js +40 -0
  158. package/tools/findings.js +39 -0
  159. package/tools/import-legacy.js +42 -0
  160. package/tools/inbox.js +39 -0
  161. package/tools/index.js +39 -0
  162. package/tools/init.js +63 -0
  163. package/tools/interrupt.js +42 -0
  164. package/tools/legacy-map.js +44 -0
  165. package/tools/merge.js +39 -0
  166. package/tools/mission.js +41 -0
  167. package/tools/plan.js +39 -0
  168. package/tools/preflight.js +44 -0
  169. package/tools/rebase.js +39 -0
  170. package/tools/recover.js +45 -0
  171. package/tools/reload.js +52 -0
  172. package/tools/resource.js +54 -0
  173. package/tools/review.js +45 -0
  174. package/tools/roster.js +60 -0
  175. package/tools/send.js +147 -0
  176. package/tools/spawn.js +196 -0
  177. package/tools/status.js +94 -0
  178. package/tools/teardown.js +42 -0
  179. package/tools/test.js +61 -0
  180. package/types/cordis.d.ts +6 -0
  181. package/types/index.d.ts +339 -0
  182. package/ui-bundle/README.md +111 -0
  183. package/ui-bundle/client.js +1980 -0
  184. package/ui-bundle/cordis.patch.yml +3 -0
  185. package/ui-bundle/icon.svg +6 -0
  186. package/ui-bundle/index.js +2 -0
  187. package/ui-bundle/locale/en.json +6 -0
  188. package/ui-bundle/locale/zh.json +6 -0
  189. package/ui-bundle/package.json +35 -0
  190. package/lib/errors.js +0 -111
  191. package/lib/tools.js +0 -2250
  192. package/lib/tower.js +0 -2689
  193. /package/{lib → domain}/batch.js +0 -0
@@ -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
+ }
@@ -0,0 +1,124 @@
1
+ // Pure provision decisions: branch slug, base-commit choice, worktree root and
2
+ // path resolution, and result/activity projections. Git worktree creation and
3
+ // state persistence stay in app/provision.js.
4
+ // @ts-check
5
+ import path from 'node:path'
6
+
7
+ /** @typedef {import('../../types/index.js').Mission} Mission */
8
+ /** @typedef {import('../../types/index.js').MissionWorktree} MissionWorktree */
9
+
10
+ /**
11
+ * @param {unknown} title
12
+ * @returns {string}
13
+ */
14
+ export function slugify(title) {
15
+ const slug = String(title)
16
+ .toLowerCase()
17
+ .replace(/[^a-z0-9]+/g, '-')
18
+ .replace(/^-+|-+$/g, '')
19
+ .slice(0, 24)
20
+ return slug || 'mission'
21
+ }
22
+
23
+ /**
24
+ * @param {Mission} mission
25
+ * @returns {string}
26
+ */
27
+ export function worktreeBranch(mission) {
28
+ return `tower/${mission.id.toLowerCase()}-${slugify(mission.title)}`
29
+ }
30
+
31
+ /**
32
+ * Pick the base commit from the explicit argument, the requested base ref, or
33
+ * the recorded dirty snapshot. `source: 'base'` means the caller must rev-parse
34
+ * the configured base (the only I/O in the choice).
35
+ * @param {object} input
36
+ * @param {unknown} [input.baseCommit]
37
+ * @param {unknown} [input.includeDirtyBase]
38
+ * @param {string | null} [input.snapshotCommit]
39
+ * @param {boolean} [input.snapshotCommitValid]
40
+ * @returns {{ source: 'explicit' | 'snapshot', commit: string } | { source: 'base' }}
41
+ */
42
+ export function baseCommitChoice({ baseCommit, includeDirtyBase, snapshotCommit = null, snapshotCommitValid = false }) {
43
+ if (baseCommit) return { source: 'explicit', commit: String(baseCommit) }
44
+ if (includeDirtyBase === false) return { source: 'base' }
45
+ if (snapshotCommit && snapshotCommitValid) return { source: 'snapshot', commit: snapshotCommit }
46
+ return { source: 'base' }
47
+ }
48
+
49
+ /**
50
+ * @param {object} input
51
+ * @param {string | null} [input.worktreeRoot]
52
+ * @param {string | null} [input.configuredRoot]
53
+ * @param {string} input.repoRoot
54
+ * @param {string} input.commonDirHash
55
+ * @returns {string}
56
+ */
57
+ export function worktreeRoot({ worktreeRoot = null, configuredRoot = null, repoRoot, commonDirHash }) {
58
+ if (worktreeRoot) return path.resolve(worktreeRoot)
59
+ if (configuredRoot) return path.resolve(configuredRoot)
60
+ return path.join(path.dirname(repoRoot), '.dsh-tower-worktrees', commonDirHash)
61
+ }
62
+
63
+ /**
64
+ * @param {string} root
65
+ * @param {string} missionId
66
+ * @returns {string}
67
+ */
68
+ export function worktreePath(root, missionId) {
69
+ return path.join(root, missionId)
70
+ }
71
+
72
+ /**
73
+ * @param {unknown} missionId
74
+ * @returns {boolean}
75
+ */
76
+ export function hasMissionId(missionId) {
77
+ return typeof missionId === 'string' && missionId.trim().length > 0
78
+ }
79
+
80
+ /**
81
+ * @param {object} input
82
+ * @param {string} input.path
83
+ * @param {string} input.branch
84
+ * @param {string} input.baseCommit
85
+ * @param {number} input.createdAt
86
+ * @param {string | null} [input.realPath]
87
+ * @returns {MissionWorktree}
88
+ */
89
+ export function worktreeRecord({ path: wtPath, branch, baseCommit, createdAt, realPath = null }) {
90
+ return /** @type {MissionWorktree} */ ({
91
+ path: wtPath,
92
+ branch,
93
+ baseCommit,
94
+ createdAt,
95
+ ...(realPath === null ? {} : { realPath }),
96
+ })
97
+ }
98
+
99
+ /**
100
+ * @param {Mission} mission
101
+ * @param {MissionWorktree} worktree
102
+ * @returns {{ mission: Mission, worktree: MissionWorktree, created: false }}
103
+ */
104
+ export function existingWorktreeResult(mission, worktree) {
105
+ return { mission, worktree, created: false }
106
+ }
107
+
108
+ /**
109
+ * @param {Mission} mission
110
+ * @param {MissionWorktree} worktree
111
+ * @returns {{ mission: Mission, worktree: MissionWorktree, created: true }}
112
+ */
113
+ export function createdWorktreeResult(mission, worktree) {
114
+ return { mission, worktree, created: true }
115
+ }
116
+
117
+ /**
118
+ * @param {string} branch
119
+ * @param {string} wtPath
120
+ * @returns {string}
121
+ */
122
+ export function provisionActivityText(branch, wtPath) {
123
+ return `${branch} at ${wtPath}`
124
+ }