dsh-tower 2.5.0 → 2.7.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 (142) hide show
  1. package/CHANGELOG.md +114 -0
  2. package/COMPATIBILITY.md +2 -0
  3. package/CONTRIBUTING.md +1 -1
  4. package/GLOSSARY.md +6 -0
  5. package/PRINCIPLES.md +15 -7
  6. package/README.en.md +18 -16
  7. package/README.md +18 -11
  8. package/adapters/leases.js +20 -6
  9. package/adapters/legacy.js +149 -37
  10. package/adapters/stateFile.js +11 -1
  11. package/app/archive.js +30 -5
  12. package/app/briefing.js +95 -12
  13. package/app/checkCompletion.js +107 -18
  14. package/app/enrichedMissions.js +1 -1
  15. package/app/freshness.js +77 -0
  16. package/app/gate.js +26 -12
  17. package/app/merge.js +7 -4
  18. package/app/mission.js +24 -1
  19. package/app/missionContext.js +2 -1
  20. package/app/plan.js +64 -1
  21. package/app/provision.js +133 -22
  22. package/app/read.js +38 -5
  23. package/app/rebase.js +10 -8
  24. package/app/review.js +104 -61
  25. package/app/reviewCheckout.js +133 -0
  26. package/app/sandbox.js +121 -0
  27. package/app/sharedCheckout.js +90 -0
  28. package/app/spawn-prechecks.js +31 -20
  29. package/app/spawn-support.js +59 -8
  30. package/app/spawn.js +3 -0
  31. package/app/teardown.js +119 -2
  32. package/app/tower.js +34 -6
  33. package/client.js +321 -0
  34. package/cordis.patch.yml +55 -3
  35. package/docs/adr/0005-component-surface.md +58 -13
  36. package/docs/adr/0006-mission-shapes.md +306 -0
  37. package/docs/architecture/overview.md +18 -10
  38. package/docs/examples/first-mission.md +3 -2
  39. package/docs/faq.md +9 -7
  40. package/docs/guide/install-upgrade-migration.md +58 -7
  41. package/docs/guide/quickstart.md +20 -10
  42. package/docs/guide/scenarios-and-philosophy.md +35 -28
  43. package/docs/operations/runbook.md +1 -1
  44. package/docs/operations/security.md +11 -10
  45. package/docs/reference/blockers.md +20 -11
  46. package/docs/reference/component-surface.md +20 -8
  47. package/docs/reference/config.generated.md +8 -5
  48. package/docs/reference/config.md +30 -5
  49. package/docs/reference/doctor.md +7 -7
  50. package/docs/reference/errors.generated.md +1 -0
  51. package/docs/reference/errors.md +2 -1
  52. package/docs/reference/operations.md +1 -1
  53. package/docs/reference/shapes.md +123 -0
  54. package/docs/reference/surface-budget.md +53 -4
  55. package/docs/reference/testing.md +5 -3
  56. package/docs/reference/tools.generated.md +7 -7
  57. package/docs/reference/tools.md +5 -5
  58. package/docs/zh/faq.md +7 -5
  59. package/docs/zh/guide/install-upgrade-migration.md +25 -6
  60. package/docs/zh/guide/quickstart.md +17 -10
  61. package/docs/zh/guide/scenarios-and-philosophy.md +23 -15
  62. package/docs/zh/operations/security.md +9 -8
  63. package/docs/zh/reference/blockers.md +13 -9
  64. package/docs/zh/reference/config.md +22 -5
  65. package/docs/zh/reference/errors.md +2 -1
  66. package/docs/zh/reference/testing.md +5 -4
  67. package/docs/zh/reference/tools.md +3 -3
  68. package/domain/batch.js +75 -105
  69. package/domain/collab/batch.js +30 -10
  70. package/domain/collab/spawn.js +53 -16
  71. package/domain/components.js +12 -4
  72. package/domain/config/schema.js +87 -22
  73. package/domain/contracts/outputs.js +2 -1
  74. package/domain/contracts/tool-inputs/missions.js +10 -2
  75. package/domain/errors.js +6 -0
  76. package/domain/evidence.js +283 -0
  77. package/domain/gate.js +17 -34
  78. package/domain/integration/gate.js +17 -43
  79. package/domain/integration/init.js +3 -2
  80. package/domain/integration/provision.js +51 -0
  81. package/domain/integration/rebase.js +16 -1
  82. package/domain/integration/review.js +203 -9
  83. package/domain/mission.js +16 -33
  84. package/domain/missions/completion.js +46 -8
  85. package/domain/missions/findings.js +2 -1
  86. package/domain/missions/lifecycle.js +131 -35
  87. package/domain/missions/plan.js +30 -6
  88. package/domain/missions/review.js +2 -18
  89. package/domain/planning.js +5 -5
  90. package/domain/read/missionContext.js +17 -3
  91. package/domain/read/views.js +5 -1
  92. package/domain/roles.js +222 -0
  93. package/domain/roster.js +9 -3
  94. package/domain/scope.js +51 -0
  95. package/domain/shapes/build.js +254 -0
  96. package/domain/shapes/index.js +171 -0
  97. package/domain/shapes/survey.js +445 -0
  98. package/domain/state/archive.js +28 -4
  99. package/domain/state/migrations.js +111 -4
  100. package/domain/state/schema.js +69 -7
  101. package/domain/state/semantics.js +93 -13
  102. package/domain/state.js +5 -1
  103. package/domain/summary.js +5 -11
  104. package/domain/vocabulary.js +15 -33
  105. package/host/capability-registry.js +25 -9
  106. package/host/config.js +40 -3
  107. package/host/doctor.js +20 -8
  108. package/host/preset-survey-sandbox.js +19 -0
  109. package/host/preset-survey.js +18 -0
  110. package/host/presets.js +17 -11
  111. package/host/tool-registry.js +23 -13
  112. package/locale/en.json +16 -1
  113. package/locale/preset-survey/en.json +6 -0
  114. package/locale/preset-survey/zh.json +6 -0
  115. package/locale/preset-survey-sandbox/en.json +6 -0
  116. package/locale/preset-survey-sandbox/zh.json +6 -0
  117. package/locale/zh.json +16 -1
  118. package/package.json +19 -2
  119. package/ports/state.js +8 -1
  120. package/scripts/architecture-waivers.json +3 -3
  121. package/scripts/check-components.mjs +120 -6
  122. package/scripts/check-docs.mjs +1 -1
  123. package/scripts/check-pack.mjs +5 -0
  124. package/scripts/component-surface.json +83 -29
  125. package/scripts/generate-capability-locale.mjs +5 -0
  126. package/scripts/generate-shapes-reference.mjs +171 -0
  127. package/scripts/mount-smoke/surface.mjs +3 -0
  128. package/scripts/pack-manifest.txt +22 -0
  129. package/scripts/shape-audit.mjs +361 -0
  130. package/scripts/surface-baseline.json +75 -4
  131. package/scripts/types-coverage.mjs +7 -0
  132. package/survey-sandbox.js +48 -0
  133. package/survey.js +40 -0
  134. package/tools/complete.js +5 -5
  135. package/tools/diff.js +5 -3
  136. package/tools/plan.js +1 -1
  137. package/tools/preflight.js +3 -2
  138. package/tools/review.js +1 -1
  139. package/tools/spawn.js +48 -50
  140. package/tools/status.js +8 -1
  141. package/tools/test.js +2 -1
  142. package/types/index.d.ts +76 -5
@@ -0,0 +1,254 @@
1
+ // The build shape (#148/#149): a mission that owns a private branch and
2
+ // worktree, writes code, and terminates by being merged or abandoned.
3
+ //
4
+ // This module is the *only* place build-specific policy lives. Generic layers
5
+ // resolve a shape through `./index.js` and never compare `mission.kind`.
6
+ // @ts-check
7
+ import { TowerToolError } from '../errors.js'
8
+ import { hasCheckEvidence } from '../evidence.js'
9
+
10
+ /** @typedef {import('../../types/index.js').Mission} Mission */
11
+ /** @typedef {import('../../types/index.js').Blocker} Blocker */
12
+ /** @typedef {import('../../types/index.js').TowerConfig} TowerConfig */
13
+
14
+ /** @type {readonly string[]} */
15
+ export const BUILD_STATUSES = Object.freeze(['planned', 'active', 'completed', 'blocked', 'paused', 'merged', 'abandoned'])
16
+
17
+ /**
18
+ * The evidence this shape accepts (#149 W4). A build's discovery receipt is
19
+ * evidence only for the tip it ran on; a completion check is the caller's
20
+ * assertion. Both are read through `domain/evidence.js`, never here.
21
+ * @type {import('../evidence.js').EvidenceContract}
22
+ */
23
+ export const EVIDENCE = Object.freeze({
24
+ completionChecks: true,
25
+ testReceipts: /** @type {const} */ ('tip-bound'),
26
+ })
27
+
28
+ /**
29
+ * The build shape's path reach (#149 W4): a build owns the scope it declared,
30
+ * so every changed path must match it. This is the only place the permission
31
+ * question is answered; gates and lifecycle rules call domain/scope.js with it.
32
+ * @type {{ ownsPaths: boolean, fence: 'scope' | 'read-only' }}
33
+ */
34
+ export const REACH = Object.freeze({
35
+ ownsPaths: true,
36
+ fence: /** @type {const} */ ('scope'),
37
+ })
38
+
39
+ /** @type {Readonly<Record<string, readonly string[]>>} */
40
+ export const BUILD_TRANSITIONS = Object.freeze({
41
+ planned: Object.freeze(['active', 'blocked', 'abandoned']),
42
+ active: Object.freeze(['completed', 'blocked', 'paused', 'abandoned']),
43
+ // `blocked` is derived from open blockers (W3): a blocked mission resumes
44
+ // only by clearing them, never by pausing or completing through the state.
45
+ blocked: Object.freeze(['active', 'abandoned']),
46
+ // A blocker always wins over a pause, which is why paused -> blocked exists.
47
+ paused: Object.freeze(['active', 'blocked', 'abandoned']),
48
+ completed: Object.freeze(['active', 'blocked', 'abandoned']),
49
+ merged: Object.freeze([]),
50
+ abandoned: Object.freeze([]),
51
+ })
52
+
53
+ /**
54
+ * @param {string} status
55
+ * @returns {boolean}
56
+ */
57
+ export function isTerminal(status) {
58
+ return status === 'merged' || status === 'abandoned'
59
+ }
60
+
61
+ /**
62
+ * @param {string} from
63
+ * @param {string} to
64
+ * @returns {boolean}
65
+ */
66
+ export function canTransition(from, to) {
67
+ return (BUILD_TRANSITIONS[from] ?? []).includes(to)
68
+ }
69
+
70
+ /**
71
+ * @param {Record<string, any>} context
72
+ * @returns {Record<string, any>}
73
+ */
74
+ export function planFields(context) {
75
+ return { scope: context.scope }
76
+ }
77
+
78
+ /**
79
+ * Completion invariants for a build mission: at least one commit on the mission
80
+ * branch since its base and a clean worktree. The app layer has already resolved
81
+ * the worktree and collected the primitives.
82
+ * @param {{ mission: Mission, commits: number, clean: boolean, dirtyPaths: string[] }} context
83
+ * @returns {{ commits: number }}
84
+ */
85
+ export function checkCompletion({ mission, commits, clean, dirtyPaths }) {
86
+ const base = mission.worktree?.baseCommit ?? ''
87
+ if (commits < 1) {
88
+ throw new TowerToolError('invalid-argument', `mission branch has no commits since ${String(base).slice(0, 12)}`)
89
+ }
90
+ if (!clean) {
91
+ const shown = dirtyPaths.slice(0, 8).join(', ')
92
+ const more = dirtyPaths.length > 8 ? ', …' : ''
93
+ throw new TowerToolError(
94
+ 'worktree-dirty',
95
+ `mission ${mission.id} worktree has uncommitted changes (${shown}${more}); commit, remove, or record them before tower_complete`,
96
+ )
97
+ }
98
+ return { commits }
99
+ }
100
+
101
+ /**
102
+ * Pre-merge completion blockers (the merge gate half of completion).
103
+ * @param {Mission} mission
104
+ * @param {TowerConfig} [config]
105
+ * @returns {Blocker[]}
106
+ */
107
+ export function completionBlockers(mission, config = /** @type {TowerConfig} */ ({})) {
108
+ /** @type {Blocker[]} */
109
+ const blockers = []
110
+ if (isTerminal(mission.status)) {
111
+ blockers.push({
112
+ code: 'branch-owned-by-closed-missions',
113
+ detail: `mission ${mission.id} is ${mission.status}; a merge never flips a closed mission — re-plan the work under a new title`,
114
+ })
115
+ }
116
+ if (mission.status !== 'completed') {
117
+ blockers.push({
118
+ code: 'mission-not-completed',
119
+ detail:
120
+ mission.status === 'blocked'
121
+ ? `mission ${mission.id} is blocked: ${(mission.blockers ?? []).join('; ')}; clear the blockers to resume it`
122
+ : `mission ${mission.id} is ${mission.status}; completion is required`,
123
+ })
124
+ }
125
+ const open = (mission.tasks ?? []).filter((task) => task.status === 'open')
126
+ if (open.length > 0) {
127
+ blockers.push({ code: 'tasks-open', detail: `${open.length} open task(s): ${open.map((task) => task.text).join('; ')}` })
128
+ }
129
+ if ((mission.blockers ?? []).length > 0) {
130
+ blockers.push({ code: 'blockers-present', detail: (mission.blockers ?? []).join('; ') })
131
+ }
132
+ if (config.gates?.requireChecks && !hasCheckEvidence(EVIDENCE, mission)) {
133
+ blockers.push({
134
+ code: 'no-check-evidence',
135
+ detail: `mission ${mission.id} has no completion checks; gates.requireChecks is enabled`,
136
+ })
137
+ }
138
+ return blockers
139
+ }
140
+
141
+ /**
142
+ * A build mission's dependency is satisfied once it has landed or been dropped.
143
+ * @param {Pick<Mission, 'status'>} dep
144
+ * @returns {boolean}
145
+ */
146
+ export function dependencySatisfied(dep) {
147
+ return dep.status === 'merged' || dep.status === 'abandoned'
148
+ }
149
+
150
+ /**
151
+ * Worker spawn constraints: a build worker gets its own branch and worktree.
152
+ * @type {{ provisioning: 'branch-worktree', missionStatuses: readonly string[], worktree: true, countsTowardWorkerBudget: boolean, model: string }}
153
+ */
154
+ export const spawn = Object.freeze({
155
+ provisioning: /** @type {const} */ ('branch-worktree'),
156
+ missionStatuses: Object.freeze(['planned', 'active', 'completed', 'blocked', 'paused']),
157
+ worktree: /** @type {const} */ (true),
158
+ countsTowardWorkerBudget: true,
159
+ // Optional model route slot: config.spawn.<model>Model. The shape supplies the
160
+ // fact; the role contract decides whether it is used.
161
+ model: 'worker',
162
+ })
163
+
164
+ /**
165
+ * Mission briefing lines that are specific to the build shape.
166
+ * @param {Mission} mission
167
+ * @param {{ worktree?: { path: string, branch: string, baseCommit?: string } | null, base: string, repoRoot?: string, readCommit?: string, readDirty?: string[] }} context
168
+ * @returns {string[]}
169
+ */
170
+ export function briefing(mission, context) {
171
+ const worktree = context.worktree
172
+ if (!worktree) return []
173
+ return [
174
+ `Work ONLY in this git worktree: ${worktree.path}`,
175
+ `Branch: ${worktree.branch} (based on ${context.base} @ ${String(worktree.baseCommit ?? '').slice(0, 12)})`,
176
+ ]
177
+ }
178
+
179
+ /**
180
+ * Projection of the build-specific mission fields for summaries/board rows.
181
+ * @param {Mission} mission
182
+ * @returns {Record<string, any>}
183
+ */
184
+ export function summary(mission) {
185
+ return {
186
+ worktree: mission.worktree ? { path: mission.worktree.path, branch: mission.worktree.branch, baseCommit: mission.worktree.baseCommit } : null,
187
+ mergedCommit: mission.mergedCommit ?? null,
188
+ mergedAt: mission.mergedAt ?? null,
189
+ }
190
+ }
191
+
192
+ /**
193
+ * One-line human description of where the mission reads/writes, used by reports.
194
+ * @param {Mission} mission
195
+ * @returns {string | null}
196
+ */
197
+ export function describe(mission) {
198
+ return mission.worktree?.branch ? `branch ${mission.worktree.branch}` : null
199
+ }
200
+
201
+ /**
202
+ * v3 -> v4 migration hook (#148). A build mission's document keeps its shape;
203
+ * only the kind discriminant is stamped. Documents written before `kind` existed
204
+ * are builds.
205
+ * @param {Record<string, any>} mission
206
+ * @returns {boolean}
207
+ */
208
+ export function isLegacy(mission) {
209
+ return mission.kind === undefined || mission.kind === null || mission.kind === '' || mission.kind === 'build'
210
+ }
211
+
212
+ /**
213
+ * @param {Record<string, any>} mission
214
+ * @param {Record<string, any>} [_options] shared migration context; a build needs none
215
+ * @returns {Record<string, any>}
216
+ */
217
+ export function migrateLegacy(mission, _options = {}) {
218
+ return { ...mission, kind: 'build' }
219
+ }
220
+
221
+ export const buildShape = Object.freeze({
222
+ kind: /** @type {const} */ ('build'),
223
+ statuses: BUILD_STATUSES,
224
+ transitions: BUILD_TRANSITIONS,
225
+ isTerminal,
226
+ canTransition,
227
+ isLegacy,
228
+ needsLegacyRead: () => false,
229
+ migrateLegacy,
230
+ canMerge: true,
231
+ canRebase: true,
232
+ provisioning: /** @type {const} */ ('branch-worktree'),
233
+ reach: REACH,
234
+ // Document dimensions this shape owns; the state validator reads these.
235
+ fields: Object.freeze({ worktree: true, readCommit: false, merge: true, sandbox: false }),
236
+ requiredComponent: null,
237
+ acceptsResult: false,
238
+ evidence: EVIDENCE,
239
+ planFields,
240
+ completion: Object.freeze({
241
+ requiresWorktree: true,
242
+ requiresCommits: true,
243
+ requiresClean: true,
244
+ requiresHeadMatch: false,
245
+ requiresTrackedStatus: false,
246
+ check: /** @type {(ctx: any) => { commits: number }} */ (checkCompletion),
247
+ }),
248
+ completionBlockers,
249
+ dependencySatisfied,
250
+ spawn,
251
+ briefing,
252
+ summary,
253
+ describe,
254
+ })
@@ -0,0 +1,171 @@
1
+ // The one shape registry (#149). A *shape* is the contract a mission kind
2
+ // carries: its status vocabulary and transitions, what it provisions, how it
3
+ // completes, how dependencies are satisfied, and what its briefing and summary
4
+ // say. Generic layers resolve a shape once through `shapeFor()` and never
5
+ // compare `mission.kind`.
6
+ //
7
+ // `isTerminal(kind, status)` and `canTransition(kind, from, to)` are the only
8
+ // entry points for status policy; the old global `TERMINAL_STATUSES` and
9
+ // `TRANSITIONS` tables are gone.
10
+ // @ts-check
11
+ import { TowerToolError } from '../errors.js'
12
+ import { buildShape } from './build.js'
13
+ import { surveyShape } from './survey.js'
14
+
15
+ /** @typedef {import('../../types/index.js').Mission} Mission */
16
+
17
+ /** @type {Readonly<Record<string, typeof buildShape | typeof surveyShape>>} */
18
+ export const SHAPES = Object.freeze({
19
+ build: buildShape,
20
+ survey: surveyShape,
21
+ })
22
+
23
+ /** Ordered kind vocabulary; the first kind is the default for absent kinds. */
24
+ /** @type {readonly string[]} */
25
+ export const SHAPE_KINDS = Object.freeze(Object.keys(SHAPES))
26
+
27
+ /**
28
+ * Resolve a mission kind to its shape.
29
+ *
30
+ * A document written before schema v4 may omit `kind`; the v3 -> v4 migration
31
+ * stamps `build`, and pure helpers that tolerate partial mission snapshots still
32
+ * resolve an absent kind as the build shape. An unknown non-empty kind is never
33
+ * guessed.
34
+ * @param {string | null | undefined} kind
35
+ * @returns {typeof buildShape | typeof surveyShape}
36
+ */
37
+ export function shapeFor(kind) {
38
+ if (kind === undefined || kind === null || kind === '') return SHAPES.build
39
+ const shape = SHAPES[kind]
40
+ if (!shape) {
41
+ throw new TowerToolError('invalid-argument', `unknown mission kind "${kind}"; expected one of ${SHAPE_KINDS.join('|')}`)
42
+ }
43
+ return shape
44
+ }
45
+
46
+ /**
47
+ * @param {unknown} kind
48
+ * @returns {boolean}
49
+ */
50
+ export function isShapeKind(kind) {
51
+ return typeof kind === 'string' && Object.hasOwn(SHAPES, kind)
52
+ }
53
+
54
+ /**
55
+ * The only terminality entry point.
56
+ * @param {string | null | undefined} kind
57
+ * @param {string} status
58
+ * @returns {boolean}
59
+ */
60
+ export function isTerminal(kind, status) {
61
+ return shapeFor(kind).isTerminal(status)
62
+ }
63
+
64
+ /**
65
+ * The only transition entry point.
66
+ * @param {string | null | undefined} kind
67
+ * @param {string} from
68
+ * @param {string} to
69
+ * @returns {boolean}
70
+ */
71
+ export function canTransition(kind, from, to) {
72
+ return shapeFor(kind).canTransition(from, to)
73
+ }
74
+
75
+ /**
76
+ * @param {string | null | undefined} kind
77
+ * @returns {boolean}
78
+ */
79
+ export function canMerge(kind) {
80
+ return shapeFor(kind).canMerge
81
+ }
82
+
83
+ /**
84
+ * @param {string | null | undefined} kind
85
+ * @returns {boolean}
86
+ */
87
+ export function canRebase(kind) {
88
+ return shapeFor(kind).canRebase
89
+ }
90
+
91
+ /**
92
+ * Resolve how a mission of this kind is provisioned. A shape may resolve the
93
+ * placement from tuning config (#148 W2: a survey is `shared` by default and
94
+ * `detached` with `survey.sandbox: 'detached'`); a shape without a resolver keeps
95
+ * its static placement. When a mission is supplied, a placement already recorded
96
+ * on it (a provisioned detached sandbox) wins over config.
97
+ * @param {string | null | undefined} kind
98
+ * @param {import('../../types/index.js').TowerConfig} [config]
99
+ * @param {import('../../types/index.js').Mission | null | undefined} [mission]
100
+ * @returns {'branch-worktree' | 'shared' | 'detached'}
101
+ */
102
+ export function provisioningFor(kind, config = {}, mission = null) {
103
+ const shape = shapeFor(kind)
104
+ if ('resolveProvisioning' in shape && typeof shape.resolveProvisioning === 'function') {
105
+ return shape.resolveProvisioning(mission, config)
106
+ }
107
+ return shape.provisioning
108
+ }
109
+
110
+ /**
111
+ * Resolve the agent preset that executes this kind in the configured placement.
112
+ * A build keeps its `spawn.defaultPreset`; a survey switches between the
113
+ * read-only preset and the sandbox preset so the allow-list follows the
114
+ * resolved placement rather than a hard-coded kind.
115
+ * @param {string | null | undefined} kind
116
+ * @param {import('../../types/index.js').TowerConfig} [config]
117
+ * @param {import('../../types/index.js').Mission | null | undefined} [mission]
118
+ * @returns {string | null}
119
+ */
120
+ export function defaultPresetFor(kind, config = {}, mission = null) {
121
+ const shape = shapeFor(kind)
122
+ if ('resolvePreset' in shape && typeof shape.resolvePreset === 'function') {
123
+ return shape.resolvePreset(mission, config)
124
+ }
125
+ return 'defaultPreset' in shape.spawn ? shape.spawn.defaultPreset : null
126
+ }
127
+
128
+ /**
129
+ * Refuse branch-shaped actions for a shape that has no branch. The message tells
130
+ * the caller what to do instead, so the refusal is actionable rather than a bare
131
+ * "invalid transition".
132
+ * @param {Mission} mission
133
+ * @param {'merge' | 'rebase' | 'preflight' | 'diff'} action
134
+ * @returns {void}
135
+ */
136
+ export function assertActionSupported(mission, action) {
137
+ const shape = shapeFor(mission.kind)
138
+ // Derived from the shape, never from a hard-coded kind: a new shape that has
139
+ // no branch gets the same refusals without touching this function.
140
+ const evidence = shape.fields.readCommit
141
+ ? `readCommit ${String(mission.readCommit ?? '').slice(0, 12)}`
142
+ : 'recorded evidence'
143
+ if (action === 'merge' && !shape.canMerge) {
144
+ throw new TowerToolError(
145
+ 'invalid-transition',
146
+ `mission ${mission.id} is a ${shape.kind}: it has no branch to merge and is never merged; a completed ${shape.kind} already satisfies its dependents`,
147
+ { nextAction: `use tower_complete to finish the ${shape.kind}; its ${evidence} is the evidence` },
148
+ )
149
+ }
150
+ if (action === 'rebase' && !shape.canRebase) {
151
+ throw new TowerToolError(
152
+ 'invalid-transition',
153
+ `mission ${mission.id} is a ${shape.kind}: it has no branch to rebase; a ${shape.kind} does not move`,
154
+ { nextAction: `re-plan the ${shape.kind} if the base moved; the recorded ${evidence} is immutable` },
155
+ )
156
+ }
157
+ if (action === 'preflight' && !shape.canMerge) {
158
+ throw new TowerToolError(
159
+ 'invalid-transition',
160
+ `mission ${mission.id} is a ${shape.kind}: it has no merge gate; tower_complete checks HEAD, tracked files and zero commits`,
161
+ { nextAction: `use tower_complete to finish the ${shape.kind}; ${shape.kind}s are not preflighted or merged` },
162
+ )
163
+ }
164
+ if (action === 'diff' && !shape.canMerge) {
165
+ throw new TowerToolError(
166
+ 'invalid-transition',
167
+ `mission ${mission.id} is a ${shape.kind}: it has no branch diff; its ${evidence} is the evidence`,
168
+ { nextAction: `inspect the recorded ${evidence} directly, or the ${shape.kind} result recorded at completion` },
169
+ )
170
+ }
171
+ }