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,236 @@
1
+ // Pure rebase decisions: the prepared snapshot, durable-intent classification,
2
+ // replay/conflict outcomes, and the strings recorded for reconciliation.
3
+ // Git reads/writes and persistence stay in app/rebase.js.
4
+ // @ts-check
5
+
6
+ /** @typedef {import('../../types/index.js').Mission} Mission */
7
+ /** @typedef {import('../../types/index.js').RosterEntry} RosterEntry */
8
+
9
+ export const STALE_PREPARE = 'tower state changed while the rebase was being prepared; retry'
10
+ export const STALE_ACT =
11
+ 'tower state changed while the rebase was running; the rebase intent is retained so the next tower_rebase can reconcile it; retry'
12
+
13
+ /**
14
+ * @param {string | null | undefined} tip
15
+ * @returns {string}
16
+ */
17
+ export function shortTip(tip) {
18
+ return String(tip ?? '').slice(0, 7)
19
+ }
20
+
21
+ /**
22
+ * @param {RosterEntry} agent
23
+ * @returns {{ ok: false, blockers: Array<{ code: string, detail: string }> }}
24
+ */
25
+ export function workerBusyResult(agent) {
26
+ return { ok: false, blockers: [{ code: 'worker-busy', detail: `agent ${agent.name} is running; rebase refused` }] }
27
+ }
28
+
29
+ /**
30
+ * @param {string} path
31
+ * @returns {{ ok: false, blockers: Array<{ code: string, detail: string }> }}
32
+ */
33
+ export function dirtyWorktreeResult(path) {
34
+ return { ok: false, blockers: [{ code: 'worktree-dirty', detail: `${path} has uncommitted changes` }] }
35
+ }
36
+
37
+ /**
38
+ * Rewriting an already-landed branch would destroy the reviewed tip.
39
+ * @param {object} input
40
+ * @param {string} input.oldTip
41
+ * @param {string} input.baseTip
42
+ * @param {string} input.base
43
+ * @returns {object}
44
+ */
45
+ export function alreadyMergedRebaseResult({ oldTip, baseTip, base }) {
46
+ return {
47
+ ok: true,
48
+ alreadyMerged: true,
49
+ skipped: true,
50
+ oldTip,
51
+ newTip: oldTip,
52
+ baseTip,
53
+ note: `${oldTip.slice(0, 7)} is already contained in ${base} ${baseTip.slice(0, 7)}; branch left untouched — call tower_merge to record the landed merge`,
54
+ }
55
+ }
56
+
57
+ /**
58
+ * @param {object} input
59
+ * @param {string} input.oldTip
60
+ * @param {string} input.baseTip
61
+ * @returns {object}
62
+ */
63
+ export function alreadyUpToDateResult({ oldTip, baseTip }) {
64
+ return { ok: true, alreadyUpToDate: true, oldTip, newTip: oldTip, baseTip }
65
+ }
66
+
67
+ /**
68
+ * Both tips must still match the prepared snapshot before the branch rewrite.
69
+ * @param {object} input
70
+ * @param {string} input.oldTip
71
+ * @param {string} input.baseTip
72
+ * @param {string} input.currentTip
73
+ * @param {string} input.currentBase
74
+ * @returns {boolean}
75
+ */
76
+ export function rebaseTipsMoved({ oldTip, baseTip, currentTip, currentBase }) {
77
+ return currentTip !== oldTip || currentBase !== baseTip
78
+ }
79
+
80
+ /**
81
+ * @param {object} input
82
+ * @param {string} input.oldTip
83
+ * @param {string} input.baseTip
84
+ * @param {string} input.currentTip
85
+ * @param {string} input.currentBase
86
+ * @returns {string}
87
+ */
88
+ export function rebaseTipsMovedDetail({ oldTip, baseTip, currentTip, currentBase }) {
89
+ return `tips moved after preparation (${shortTip(oldTip)}/${shortTip(baseTip)} -> ${shortTip(currentTip)}/${shortTip(currentBase)}), so nothing was rebased`
90
+ }
91
+
92
+ /**
93
+ * Malformed durable intents are refused rather than guessed.
94
+ * @param {unknown} intent
95
+ * @returns {boolean}
96
+ */
97
+ export function malformedRebaseIntent(intent) {
98
+ const record = /** @type {Record<string, unknown> | null} */ (intent)
99
+ return (
100
+ typeof record !== 'object' ||
101
+ record === null ||
102
+ typeof record.missionId !== 'string' ||
103
+ typeof record.token !== 'string'
104
+ )
105
+ }
106
+
107
+ /**
108
+ * Classify the durable intent from the observed worktree tip. Only the two
109
+ * unambiguous outcomes are recordable; anything else stays pending.
110
+ * @param {object} input
111
+ * @param {{ oldTip: string, baseTip: string, commits?: number | null }} input.intent
112
+ * @param {Mission | undefined} input.mission
113
+ * @param {string} [input.tip]
114
+ * @param {boolean} input.tipReadable
115
+ * @param {boolean} input.baseIsAncestor
116
+ * @param {number | null} [input.actualCommits]
117
+ * @returns {{ action: 'clear' | 'ambiguous' | 'record', reason?: string, tip?: string }}
118
+ */
119
+ export function classifyRebaseIntent({ intent, mission, tip, tipReadable, baseIsAncestor, actualCommits = null }) {
120
+ if (!mission || !mission.worktree || typeof mission.worktree.path !== 'string') {
121
+ return { action: 'clear', reason: 'the mission has no worktree' }
122
+ }
123
+ if (!tipReadable) return { action: 'clear', reason: 'the worktree is not readable' }
124
+ if (tip === intent.oldTip) {
125
+ return { action: 'clear', reason: 'the branch tip is unchanged (the rebase never ran)' }
126
+ }
127
+ if (!baseIsAncestor) return { action: 'ambiguous', tip }
128
+ if (intent.commits !== null && intent.commits !== undefined) {
129
+ if (actualCommits !== Number(intent.commits)) return { action: 'ambiguous', tip }
130
+ }
131
+ return { action: 'record', tip }
132
+ }
133
+
134
+ /**
135
+ * @param {{ missionId: string, startedAt?: number }} intent
136
+ * @param {string | undefined} tip
137
+ * @returns {string}
138
+ */
139
+ export function ambiguousRebaseIntentMessage(intent, tip) {
140
+ return `a rebase intent for ${intent.missionId} from ${new Date(Number(intent.startedAt) || 0).toISOString()} is pending and cannot be reconciled automatically (tip ${shortTip(tip)} does not match the prepared snapshot); finish or abort the rebase in that worktree, then retry`
141
+ }
142
+
143
+ /**
144
+ * @param {object} input
145
+ * @param {string} input.token
146
+ * @param {string} input.missionId
147
+ * @param {string} input.base
148
+ * @param {string} input.oldTip
149
+ * @param {string} input.baseTip
150
+ * @param {number | null} input.commits
151
+ * @param {number} input.startedAt
152
+ * @returns {object}
153
+ */
154
+ export function buildRebaseIntent({ token, missionId, base, oldTip, baseTip, commits, startedAt }) {
155
+ return { token, missionId, base, oldTip, baseTip, commits, startedAt }
156
+ }
157
+
158
+ /**
159
+ * @param {string} base
160
+ * @param {string} baseTip
161
+ * @returns {string}
162
+ */
163
+ export function rebaseConflictNote(base, baseTip) {
164
+ return `rebase conflict onto ${base} @ ${baseTip.slice(0, 12)}`
165
+ }
166
+
167
+ /**
168
+ * @param {string} detail
169
+ * @returns {{ ok: false, blockers: Array<{ code: string, detail: string }> }}
170
+ */
171
+ export function rebaseConflictResult(detail) {
172
+ return { ok: false, blockers: [{ code: 'rebase-conflict', detail }] }
173
+ }
174
+
175
+ /**
176
+ * @param {object} input
177
+ * @param {string} input.oldTip
178
+ * @param {string} input.newTip
179
+ * @param {string} input.baseTip
180
+ * @returns {{ ok: true, oldTip: string, newTip: string, baseTip: string, reviewWaived: true }}
181
+ */
182
+ export function rebaseSuccessResult({ oldTip, newTip, baseTip }) {
183
+ return { ok: true, oldTip, newTip, baseTip, reviewWaived: true }
184
+ }
185
+
186
+ /**
187
+ * @param {object} input
188
+ * @param {string} input.oldTip
189
+ * @param {string} input.newTip
190
+ * @param {string} input.baseTip
191
+ * @returns {string}
192
+ */
193
+ export function rebaseSuccessNote({ oldTip, newTip, baseTip }) {
194
+ return `rebased ${shortTip(oldTip)} -> ${shortTip(newTip)} onto ${shortTip(baseTip)}; conflict-free rebase waives re-review`
195
+ }
196
+
197
+ /**
198
+ * @param {object} input
199
+ * @param {string} input.oldTip
200
+ * @param {string} input.newTip
201
+ * @param {string} input.baseTip
202
+ * @returns {string}
203
+ */
204
+ export function rebaseReconcileNote({ oldTip, newTip, baseTip }) {
205
+ return `reconciled interrupted rebase ${shortTip(oldTip)} -> ${shortTip(newTip)} onto ${shortTip(baseTip)}; the clean rebase waiver is preserved`
206
+ }
207
+
208
+ /**
209
+ * @param {object} input
210
+ * @param {string} input.oldTip
211
+ * @param {string} input.newTip
212
+ * @param {string} input.baseTip
213
+ * @returns {string}
214
+ */
215
+ export function rebaseActivityText({ oldTip, newTip, baseTip }) {
216
+ return `${shortTip(oldTip)} -> ${shortTip(newTip)} onto ${shortTip(baseTip)}`
217
+ }
218
+
219
+ /**
220
+ * @param {object} input
221
+ * @param {string} input.oldTip
222
+ * @param {string} input.newTip
223
+ * @param {string} input.baseTip
224
+ * @returns {string}
225
+ */
226
+ export function rebaseReconcileActivityText({ oldTip, newTip, baseTip }) {
227
+ return `reconciled ${shortTip(oldTip)} -> ${shortTip(newTip)} onto ${shortTip(baseTip)}`
228
+ }
229
+
230
+ /**
231
+ * @param {string | undefined} reason
232
+ * @returns {string}
233
+ */
234
+ export function clearedRebaseIntentActivityText(reason) {
235
+ return `cleared a stale rebase intent (${reason})`
236
+ }
@@ -0,0 +1,52 @@
1
+ // Pure review-selection and exemption decisions shared by the merge/gate/read
2
+ // projections. No repository I/O.
3
+ // @ts-check
4
+
5
+ /** @typedef {import('../../types/index.js').Mission} Mission */
6
+ /** @typedef {import('../../types/index.js').Review} Review */
7
+ /** @typedef {import('../../types/index.js').TowerConfig} TowerConfig */
8
+
9
+ /**
10
+ * Reviews are branch-scoped; a missing branch has no reviews.
11
+ * @param {Review[] | null | undefined} reviews
12
+ * @param {string | null} branch
13
+ * @returns {Review[]}
14
+ */
15
+ export function reviewsForBranch(reviews, branch) {
16
+ return branch ? (reviews ?? []).filter((review) => review.branch === branch) : []
17
+ }
18
+
19
+ /**
20
+ * The latest review round, or null when the branch has no reviews.
21
+ * @param {Review[]} reviews
22
+ * @returns {Review | null}
23
+ */
24
+ export function latestReview(reviews) {
25
+ return reviews.length > 0 ? reviews.reduce((left, right) => (left.round >= right.round ? left : right)) : null
26
+ }
27
+
28
+ /**
29
+ * A mission may skip review only with both the exemption gate and a reason.
30
+ * @param {Mission} mission
31
+ * @param {TowerConfig} [config]
32
+ * @returns {boolean}
33
+ */
34
+ export function isReviewExempt(mission, config = {}) {
35
+ return (
36
+ config.review?.requireReview === false ||
37
+ (mission.reviewPolicy === 'none' &&
38
+ config.gates?.allowReviewExemption === true &&
39
+ String(mission.reviewExemptionReason ?? '').trim().length > 0)
40
+ )
41
+ }
42
+
43
+ /**
44
+ * `reviewPolicy: none` is invalid when the reviewer gate does not allow it.
45
+ * @param {Mission} mission
46
+ * @param {TowerConfig} [config]
47
+ * @returns {boolean}
48
+ */
49
+ export function reviewExemptionInvalid(mission, config = {}) {
50
+ const requireReview = config.review?.requireReview !== false
51
+ return requireReview && mission.reviewPolicy === 'none' && config.gates?.allowReviewExemption !== true
52
+ }
@@ -0,0 +1,118 @@
1
+ // Pure teardown decisions: keep/remove eligibility and the revalidation of a
2
+ // prepared candidate. Worktree ownership checks and git removals stay in
3
+ // app/teardown.js.
4
+ // @ts-check
5
+ import { hasLiveAgent } from './liveness.js'
6
+
7
+ /** @typedef {import('../../types/index.js').Mission} Mission */
8
+ /** @typedef {import('../../types/index.js').TowerState} TowerState */
9
+
10
+ export const STALE_TEARDOWN = 'tower state changed while teardown was being prepared; retry'
11
+
12
+ /** @typedef {{ missionId: string, action: string, reason?: string, forced?: boolean }} Decision */
13
+
14
+ /**
15
+ * Replace a decision object in place so mission order is preserved.
16
+ * @param {Decision} decision
17
+ * @param {Record<string, unknown>} value
18
+ * @returns {void}
19
+ */
20
+ export function replaceDecision(decision, value) {
21
+ const missionId = decision.missionId
22
+ for (const key of Object.keys(decision)) delete /** @type {Record<string, unknown>} */ (decision)[key]
23
+ Object.assign(decision, { missionId, ...value })
24
+ }
25
+
26
+ /**
27
+ * Snapshot-only eligibility before ownership/dirty diagnostics run.
28
+ * `skip` means the mission has no removable worktree; `decision` is a keep
29
+ * outcome; `proceed` means the caller must run the ownership/dirty checks.
30
+ * @param {Mission} mission
31
+ * @param {object} input
32
+ * @param {boolean} [input.excluded]
33
+ * @param {boolean} [input.hasLiveAgent]
34
+ * @returns {{ skip: true } | { decision: Decision } | { proceed: true }}
35
+ */
36
+ export function teardownPrecheck(mission, { excluded = false, hasLiveAgent = false } = {}) {
37
+ const record = /** @type {Record<string, any>} */ (mission)
38
+ if (!record.worktree || record.worktreeRemovedAt) return { skip: true }
39
+ if (excluded) return { decision: { missionId: mission.id, action: 'keep', reason: 'excluded' } }
40
+ if (hasLiveAgent) return { decision: { missionId: mission.id, action: 'keep', reason: 'live agent' } }
41
+ if (!['merged', 'abandoned'].includes(mission.status)) {
42
+ return { decision: { missionId: mission.id, action: 'keep', reason: `mission is ${mission.status}` } }
43
+ }
44
+ return { proceed: true }
45
+ }
46
+
47
+ /**
48
+ * @param {string} missionId
49
+ * @param {unknown} error
50
+ * @returns {Decision}
51
+ */
52
+ export function ownershipUnverifiedDecision(missionId, error) {
53
+ return {
54
+ missionId,
55
+ action: 'keep',
56
+ reason: `worktree ownership/registration unverified: ${String(/** @type {{ message?: unknown }} */ (error)?.message ?? error).slice(0, 240)}`,
57
+ }
58
+ }
59
+
60
+ /**
61
+ * @param {boolean} dirty
62
+ * @param {boolean} [force]
63
+ * @returns {{ action: 'keep', reason: string } | null}
64
+ */
65
+ export function teardownDirtyDecision(dirty, force) {
66
+ return dirty && force !== true ? { action: 'keep', reason: 'uncommitted changes' } : null
67
+ }
68
+
69
+ /**
70
+ * @param {boolean} dirty
71
+ * @returns {{ action: 'remove', reason: 'eligible', forced: boolean }}
72
+ */
73
+ export function teardownRemoveDecision(dirty) {
74
+ return { action: 'remove', reason: 'eligible', forced: dirty }
75
+ }
76
+
77
+ /**
78
+ * Revalidate a prepared candidate against the current state. Used both before
79
+ * the git lock and again after re-reading state inside it.
80
+ * @param {TowerState} state
81
+ * @param {{ missionId: string, worktree: { path: string } }} candidate
82
+ * @param {Record<string, string | undefined>} [activity]
83
+ * @returns {{ ok: true, mission: Mission } | { ok: false, reason: string }}
84
+ */
85
+ export function revalidateTeardownCandidate(state, candidate, activity = {}) {
86
+ const mission = state.missions.find((entry) => entry.id === candidate.missionId)
87
+ const record = mission ? /** @type {Record<string, any>} */ (mission) : null
88
+ if (!record || !record.worktree || record.worktreeRemovedAt) return { ok: false, reason: 'worktree is already gone' }
89
+ if (record.worktree.path !== candidate.worktree.path) {
90
+ return { ok: false, reason: 'worktree path changed during teardown' }
91
+ }
92
+ if (!['merged', 'abandoned'].includes(record.status)) return { ok: false, reason: `mission is ${record.status}` }
93
+ if (hasLiveAgent(state, record.id, activity)) return { ok: false, reason: 'live agent' }
94
+ return { ok: true, mission: /** @type {Mission} */ (mission) }
95
+ }
96
+
97
+ /**
98
+ * @param {object} input
99
+ * @param {string} input.branch
100
+ * @param {string | null} [input.branchDeleteError]
101
+ * @returns {{ action: 'removed', branch: string, branchRetained?: true, branchDeleteError?: string }}
102
+ */
103
+ export function teardownRemovedDecision({ branch, branchDeleteError = null }) {
104
+ return {
105
+ action: 'removed',
106
+ branch,
107
+ ...(branchDeleteError ? { branchRetained: true, branchDeleteError } : {}),
108
+ }
109
+ }
110
+
111
+ /**
112
+ * @param {string} branch
113
+ * @param {string | null} [branchDeleteError]
114
+ * @returns {string}
115
+ */
116
+ export function teardownActivityText(branch, branchDeleteError = null) {
117
+ return `${branch}${branchDeleteError ? ' (branch retained)' : ''}`
118
+ }
@@ -0,0 +1,68 @@
1
+ // Pure mission vocabulary and transition rules (#49). No fs, git, or config.
2
+ // @ts-check
3
+
4
+ /** @typedef {import('../types/index.js').FindingSeverity} FindingSeverity */
5
+ /** @typedef {import('../types/index.js').FindingStatus} FindingStatus */
6
+ /** @typedef {import('../types/index.js').FindingType} FindingType */
7
+ /** @typedef {import('../types/index.js').MergeVerdict} MergeVerdict */
8
+ /** @typedef {import('../types/index.js').MissionStatus} MissionStatus */
9
+
10
+ /** @type {readonly MissionStatus[]} */
11
+ export const MISSION_STATUSES = ['planned', 'active', 'completed', 'blocked', 'paused', 'merged', 'abandoned']
12
+ export const SCHEMA_VERSION = 1
13
+ /** @type {readonly MergeVerdict[]} */
14
+ export const MERGE_VERDICTS = ['merge', 'fix-then-merge', 'hold']
15
+ export const REVIEW_STATUS_RE = /^(clean|p1-\d+items|p2-\d+items)$/
16
+ /** @type {ReadonlySet<string>} */
17
+ export const TERMINAL_STATUSES = new Set(['merged', 'abandoned'])
18
+ /** @type {readonly FindingType[]} */
19
+ export const FINDING_TYPES = ['bug', 'improve', 'vuln', 'idea']
20
+ /** @type {readonly FindingSeverity[]} */
21
+ export const FINDING_SEVERITIES = ['low', 'medium', 'high', 'critical']
22
+ /** @type {readonly FindingStatus[]} */
23
+ export const FINDING_STATUSES = ['open', 'triaged', 'fixed', 'wontfix']
24
+ /** @type {Record<string, readonly string[]>} */
25
+ export const FINDING_TRANSITIONS = {
26
+ open: ['triaged', 'fixed', 'wontfix'],
27
+ triaged: ['open', 'fixed', 'wontfix'],
28
+ fixed: ['open'],
29
+ wontfix: ['open'],
30
+ }
31
+ /** @type {readonly string[]} */
32
+ export const PAUSE_CAUSES = ['user', 'failure', 'resource', 'dependency']
33
+ /** @type {Record<string, readonly string[] | undefined>} */
34
+ export const TRANSITIONS = {
35
+ planned: ['active', 'abandoned'],
36
+ active: ['completed', 'blocked', 'paused', 'abandoned'],
37
+ blocked: ['active', 'paused', 'completed', 'abandoned'],
38
+ paused: ['active', 'abandoned'],
39
+ completed: ['active', 'abandoned'],
40
+ merged: [],
41
+ abandoned: [],
42
+ }
43
+
44
+ /**
45
+ * @param {string} status
46
+ * @returns {boolean}
47
+ */
48
+ export function isTerminal(status) {
49
+ return TERMINAL_STATUSES.has(status)
50
+ }
51
+
52
+ /**
53
+ * @param {string} from
54
+ * @param {string} to
55
+ * @returns {boolean}
56
+ */
57
+ export function canTransition(from, to) {
58
+ return (TRANSITIONS[from] ?? []).includes(to)
59
+ }
60
+
61
+ /**
62
+ * @param {string | null | undefined} [value]
63
+ * @returns {string | null}
64
+ */
65
+ export function pauseCauseError(value) {
66
+ if (value === undefined || value === null || value === '') return null
67
+ return PAUSE_CAUSES.includes(value) ? null : `pause_cause must be one of ${PAUSE_CAUSES.join('|')}`
68
+ }
@@ -0,0 +1,114 @@
1
+ // Pure worker-completion policy and check normalization.
2
+ //
3
+ // app/checkCompletion.js still loads state, verifies the worktree/commits with
4
+ // Git, and persists the transition; the rules below are repository-free.
5
+ // @ts-check
6
+ import { TowerToolError } from '../errors.js'
7
+
8
+ /** @typedef {import('../../types/index.js').Mission} Mission */
9
+ /** @typedef {import('../../types/index.js').RosterEntry} RosterEntry */
10
+
11
+ /**
12
+ * @param {string} message
13
+ * @param {string} [code]
14
+ * @returns {never}
15
+ */
16
+ function fail(message, code = 'invalid-argument') {
17
+ throw new TowerToolError(code, message)
18
+ }
19
+
20
+ /**
21
+ * @param {unknown} value
22
+ * @returns {boolean}
23
+ */
24
+ function isPlainObject(value) {
25
+ return value !== null && typeof value === 'object' && !Array.isArray(value)
26
+ }
27
+
28
+ /**
29
+ * Normalize `tower_complete` check evidence.
30
+ * @param {any} checks
31
+ * @returns {any[]}
32
+ */
33
+ export function normalizeChecks(checks) {
34
+ if (checks === undefined) return []
35
+ if (!Array.isArray(checks)) fail('checks must be an array')
36
+ if (checks.length > 20) fail('checks may contain at most 20 entries')
37
+ return checks.map((check, index) => {
38
+ if (!isPlainObject(check)) fail(`checks[${index}] must be an object`)
39
+ const name = typeof check.name === 'string' ? check.name.trim() : ''
40
+ if (!name) fail(`checks[${index}].name must be a non-empty string`)
41
+ /** @type {Record<string, any>} */
42
+ const entry = { name }
43
+ if (typeof check.command === 'string' && check.command.trim()) entry.command = check.command.trim()
44
+ if (check.exitCode !== undefined && check.exitCode !== null) {
45
+ if (!Number.isInteger(check.exitCode)) fail(`checks[${index}].exitCode must be an integer`)
46
+ entry.exitCode = check.exitCode
47
+ }
48
+ if (typeof check.evidence === 'string' && check.evidence.trim()) entry.evidence = check.evidence.trim().slice(0, 4000)
49
+ return entry
50
+ })
51
+ }
52
+
53
+ /**
54
+ * Resolve and validate the reporting worker and the mission's completion
55
+ * preconditions that need no repository access.
56
+ * @param {{ mission: Mission, roster: RosterEntry[] | undefined, input: any }} params
57
+ * @returns {RosterEntry}
58
+ */
59
+ export function assertCompletionRequest({ mission, roster, input }) {
60
+ const worker = (roster ?? []).find((entry) => entry.agentId === input.agentId)
61
+ if (!worker || worker.missionId !== mission.id) {
62
+ fail(`agent ${input.agentId} is not assigned to mission ${mission.id}`)
63
+ }
64
+ if (worker.role !== 'worker') fail(`agent ${worker.name} is a ${worker.role}, not a worker`)
65
+ if (['retired', 'failed', 'interrupted', 'missing', 'stale'].includes(worker.state)) {
66
+ fail(`agent ${worker.name} is ${worker.state}; a stopped worker cannot report completion`)
67
+ }
68
+ if (mission.owner && mission.owner !== worker.name) {
69
+ fail(`agent ${worker.name} is not the current owner of mission ${mission.id} (owner: ${mission.owner})`)
70
+ }
71
+ if (mission.status === 'completed') fail(`mission ${mission.id} is already completed`)
72
+ if (!['active', 'planned'].includes(mission.status)) {
73
+ fail(`mission ${mission.id} is ${mission.status}; it cannot be completed from that state`)
74
+ }
75
+ if (typeof input.report !== 'string' || input.report.trim().length === 0) {
76
+ fail('tower_complete requires a non-empty report')
77
+ }
78
+ const openTasks = (mission.tasks ?? []).filter((task) => task.status === 'open')
79
+ if (openTasks.length > 0) {
80
+ fail(
81
+ `mission ${mission.id} still has ${openTasks.length} open task(s): ${openTasks
82
+ .map((task) => task.text)
83
+ .join('; ')}`,
84
+ )
85
+ }
86
+ const blockers = mission.blockers ?? []
87
+ if (blockers.length > 0) {
88
+ fail(`mission ${mission.id} has unresolved blockers: ${blockers.join('; ')}`)
89
+ }
90
+ if (!mission.worktree) fail(`mission ${mission.id} has no worktree`)
91
+ return worker
92
+ }
93
+
94
+ /**
95
+ * Unread inbox messages block completion.
96
+ * @param {{ mission: Mission, worker: RosterEntry, unread: unknown[] }} params
97
+ * @returns {void}
98
+ */
99
+ export function assertCompletionInbox({ mission, worker, unread }) {
100
+ if (unread.length > 0) {
101
+ fail(
102
+ `mission ${mission.id} cannot complete with ${unread.length} unread inbox message(s) for ${worker.name}; call tower_inbox until unread is 0, incorporate them, then retry`,
103
+ )
104
+ }
105
+ }
106
+
107
+ /**
108
+ * Project the completion report stored on the mission.
109
+ * @param {{ report: string, worker: RosterEntry, checks: any[], now: number }} params
110
+ * @returns {any}
111
+ */
112
+ export function completionReport({ report, worker, checks, now }) {
113
+ return { report: report.trim(), by: worker.name, at: now, checks }
114
+ }