dsh-tower 2.6.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 (116) hide show
  1. package/CHANGELOG.md +82 -0
  2. package/COMPATIBILITY.md +1 -0
  3. package/CONTRIBUTING.md +1 -1
  4. package/README.en.md +2 -1
  5. package/README.md +6 -1
  6. package/adapters/leases.js +20 -6
  7. package/adapters/legacy.js +149 -37
  8. package/adapters/stateFile.js +11 -1
  9. package/app/archive.js +30 -5
  10. package/app/briefing.js +95 -12
  11. package/app/checkCompletion.js +107 -18
  12. package/app/enrichedMissions.js +1 -1
  13. package/app/freshness.js +77 -0
  14. package/app/gate.js +26 -12
  15. package/app/merge.js +7 -4
  16. package/app/mission.js +24 -1
  17. package/app/missionContext.js +2 -1
  18. package/app/plan.js +64 -1
  19. package/app/provision.js +133 -22
  20. package/app/read.js +38 -5
  21. package/app/rebase.js +10 -8
  22. package/app/review.js +97 -61
  23. package/app/reviewCheckout.js +133 -0
  24. package/app/sandbox.js +121 -0
  25. package/app/sharedCheckout.js +90 -0
  26. package/app/spawn-prechecks.js +25 -24
  27. package/app/spawn-support.js +59 -8
  28. package/app/spawn.js +3 -0
  29. package/app/teardown.js +119 -2
  30. package/app/tower.js +29 -1
  31. package/cordis.patch.yml +50 -0
  32. package/docs/adr/0006-mission-shapes.md +306 -0
  33. package/docs/architecture/overview.md +6 -4
  34. package/docs/guide/install-upgrade-migration.md +26 -0
  35. package/docs/operations/runbook.md +1 -1
  36. package/docs/reference/blockers.md +8 -4
  37. package/docs/reference/component-surface.md +7 -2
  38. package/docs/reference/config.generated.md +6 -4
  39. package/docs/reference/config.md +1 -2
  40. package/docs/reference/doctor.md +7 -7
  41. package/docs/reference/operations.md +1 -1
  42. package/docs/reference/shapes.md +123 -0
  43. package/docs/reference/surface-budget.md +33 -1
  44. package/docs/reference/testing.md +4 -2
  45. package/docs/reference/tools.generated.md +5 -5
  46. package/docs/reference/tools.md +2 -2
  47. package/docs/zh/reference/blockers.md +2 -2
  48. package/docs/zh/reference/config.md +1 -2
  49. package/docs/zh/reference/testing.md +3 -2
  50. package/domain/batch.js +75 -105
  51. package/domain/collab/batch.js +30 -10
  52. package/domain/collab/spawn.js +53 -16
  53. package/domain/config/schema.js +47 -17
  54. package/domain/contracts/outputs.js +0 -1
  55. package/domain/contracts/tool-inputs/missions.js +9 -1
  56. package/domain/evidence.js +283 -0
  57. package/domain/gate.js +11 -31
  58. package/domain/integration/gate.js +17 -43
  59. package/domain/integration/init.js +3 -2
  60. package/domain/integration/provision.js +51 -0
  61. package/domain/integration/rebase.js +16 -1
  62. package/domain/mission.js +16 -33
  63. package/domain/missions/completion.js +46 -8
  64. package/domain/missions/findings.js +2 -1
  65. package/domain/missions/lifecycle.js +131 -35
  66. package/domain/missions/plan.js +30 -6
  67. package/domain/missions/review.js +2 -18
  68. package/domain/planning.js +5 -5
  69. package/domain/read/missionContext.js +17 -3
  70. package/domain/read/views.js +5 -1
  71. package/domain/roles.js +222 -0
  72. package/domain/roster.js +9 -3
  73. package/domain/scope.js +51 -0
  74. package/domain/shapes/build.js +254 -0
  75. package/domain/shapes/index.js +171 -0
  76. package/domain/shapes/survey.js +445 -0
  77. package/domain/state/archive.js +28 -4
  78. package/domain/state/migrations.js +111 -4
  79. package/domain/state/schema.js +69 -7
  80. package/domain/state/semantics.js +93 -13
  81. package/domain/state.js +5 -1
  82. package/domain/summary.js +5 -11
  83. package/domain/vocabulary.js +15 -33
  84. package/host/capability-registry.js +25 -9
  85. package/host/doctor.js +2 -1
  86. package/host/preset-survey-sandbox.js +19 -0
  87. package/host/preset-survey.js +18 -0
  88. package/host/presets.js +8 -3
  89. package/host/tool-registry.js +14 -12
  90. package/locale/en.json +16 -1
  91. package/locale/preset-survey/en.json +6 -0
  92. package/locale/preset-survey/zh.json +6 -0
  93. package/locale/preset-survey-sandbox/en.json +6 -0
  94. package/locale/preset-survey-sandbox/zh.json +6 -0
  95. package/locale/zh.json +16 -1
  96. package/package.json +13 -2
  97. package/ports/state.js +8 -1
  98. package/scripts/architecture-waivers.json +3 -3
  99. package/scripts/check-docs.mjs +1 -1
  100. package/scripts/check-pack.mjs +2 -0
  101. package/scripts/component-surface.json +68 -20
  102. package/scripts/generate-capability-locale.mjs +5 -0
  103. package/scripts/generate-shapes-reference.mjs +171 -0
  104. package/scripts/mount-smoke/surface.mjs +3 -0
  105. package/scripts/pack-manifest.txt +21 -0
  106. package/scripts/shape-audit.mjs +361 -0
  107. package/scripts/surface-baseline.json +68 -4
  108. package/survey-sandbox.js +48 -0
  109. package/survey.js +40 -0
  110. package/tools/complete.js +5 -5
  111. package/tools/diff.js +5 -3
  112. package/tools/plan.js +1 -1
  113. package/tools/preflight.js +3 -2
  114. package/tools/spawn.js +47 -49
  115. package/tools/test.js +2 -1
  116. package/types/index.d.ts +66 -5
@@ -0,0 +1,222 @@
1
+ // Role vocabulary (#149). Roles are decided from one place so generic layers do
2
+ // not compare against role strings: the shape audit refuses `role === 'worker'`
3
+ // outside this module. `domain/vocabulary.js` re-exports these constants for the
4
+ // public vocabulary surface; `domain/roster.js` imports them directly so the
5
+ // roster/vocabulary cycle does not exist.
6
+ //
7
+ // #149 W4: a role is not a shape kind. This module owns the role contract; a
8
+ // shape supplies facts (can it merge? does it own a worktree? which model slot?)
9
+ // and this contract decides what a role requires, where it runs, which preset
10
+ // row and model route it resolves, and whether it consumes a worker slot. Spawn,
11
+ // presets, roster and gates resolve role orthogonally to shape through these
12
+ // functions instead of combining `isReviewerRole` with `shape.*` at each site.
13
+ export const ROLE = Object.freeze({
14
+ tower: 'tower',
15
+ worker: 'worker',
16
+ reviewer: 'reviewer',
17
+ unknown: 'unknown',
18
+ })
19
+
20
+ /** Roles a spawned child may have. */
21
+ export const ROLES = Object.freeze([ROLE.worker, ROLE.reviewer])
22
+
23
+ /** Every actor role a tool may see. */
24
+ export const ACTOR_ROLES = Object.freeze([ROLE.tower, ROLE.worker, ROLE.reviewer, ROLE.unknown])
25
+
26
+ export const DEFAULT_ROLE = ROLE.worker
27
+
28
+ /** @param {unknown} role */
29
+ export function isTowerRole(role) {
30
+ return role === ROLE.tower
31
+ }
32
+
33
+ /** @param {unknown} role */
34
+ export function isWorkerRole(role) {
35
+ return role === ROLE.worker
36
+ }
37
+
38
+ /** @param {unknown} role */
39
+ export function isReviewerRole(role) {
40
+ return role === ROLE.reviewer
41
+ }
42
+
43
+ /** @param {unknown} role */
44
+ export function isUnknownRole(role) {
45
+ return role === ROLE.unknown
46
+ }
47
+
48
+ /**
49
+ * The one role contract. Every field is a role fact, never a shape fact:
50
+ *
51
+ * preset: 'shape' uses the shape's resolved default preset, otherwise the
52
+ * reviewer preset row; `model` names the config.spawn.<slot>Model
53
+ * route (a shape supplies its own slot through shape.spawn.model);
54
+ * placement: 'mission' provisions the mission placement (branch worktree,
55
+ * shared checkout or detached sandbox), 'review-checkout' creates
56
+ * the disposable detached review checkout;
57
+ * `tracksMission`/`countsTowardBudget` answer ownership and resource questions;
58
+ * `requires` lists what the mission must satisfy (`statuses: 'shape'` defers the
59
+ * status set to the shape's spawn contract).
60
+ * @type {Readonly<Record<string, {
61
+ * preset: 'shape' | 'reviewer',
62
+ * model: 'shape' | 'reviewer',
63
+ * placement: 'mission' | 'review-checkout',
64
+ * briefing: 'worker' | 'reviewer',
65
+ * tracksMission: boolean,
66
+ * countsTowardBudget: 'shape' | boolean,
67
+ * spawnDiscovery: 'worktree' | 'never',
68
+ * requires: { review: boolean, canMerge: boolean, worktree: boolean, statuses: 'shape' | readonly string[] },
69
+ * }>>}
70
+ */
71
+ export const ROLE_CONTRACT = Object.freeze({
72
+ [ROLE.worker]: Object.freeze({
73
+ preset: 'shape',
74
+ model: 'shape',
75
+ placement: 'mission',
76
+ briefing: 'worker',
77
+ tracksMission: true,
78
+ countsTowardBudget: 'shape',
79
+ spawnDiscovery: 'worktree',
80
+ requires: Object.freeze({ review: false, canMerge: false, worktree: false, statuses: 'shape' }),
81
+ }),
82
+ [ROLE.reviewer]: Object.freeze({
83
+ preset: 'reviewer',
84
+ model: 'reviewer',
85
+ placement: 'review-checkout',
86
+ briefing: 'reviewer',
87
+ tracksMission: false,
88
+ countsTowardBudget: false,
89
+ spawnDiscovery: 'never',
90
+ requires: Object.freeze({ review: true, canMerge: true, worktree: true, statuses: Object.freeze(['completed']) }),
91
+ }),
92
+ })
93
+
94
+ /** @param {unknown} role */
95
+ export function roleContract(role) {
96
+ return typeof role === 'string' && Object.prototype.hasOwnProperty.call(ROLE_CONTRACT, role)
97
+ ? ROLE_CONTRACT[role]
98
+ : null
99
+ }
100
+
101
+ /** @param {unknown} role */
102
+ export function roleTracksMission(role) {
103
+ return roleContract(role)?.tracksMission === true
104
+ }
105
+
106
+ /** @param {unknown} role */
107
+ export function roleBriefingKind(role) {
108
+ return roleContract(role)?.briefing ?? null
109
+ }
110
+
111
+ /** @param {unknown} role */
112
+ export function rolePlacement(role) {
113
+ return roleContract(role)?.placement ?? null
114
+ }
115
+
116
+ /** @param {unknown} role */
117
+ export function rolePresetSlot(role) {
118
+ return roleContract(role)?.preset ?? null
119
+ }
120
+
121
+ /**
122
+ * Which config key supplies a role's preset, and the schema fallback. A role
123
+ * whose `preset` is `shape` takes the shape-resolved preset when one exists and
124
+ * falls back to `spawn.workerPreset`; the reviewer uses its own row.
125
+ * @param {unknown} role
126
+ * @returns {{ slot: 'workerPreset' | 'reviewerPreset', fallback: string } | null}
127
+ */
128
+ export function rolePresetConfig(role) {
129
+ const spec = roleContract(role)
130
+ if (!spec) return null
131
+ return spec.preset === 'shape'
132
+ ? { slot: 'workerPreset', fallback: 'tower-worker' }
133
+ : { slot: 'reviewerPreset', fallback: 'tower-reviewer' }
134
+ }
135
+
136
+ /**
137
+ * Does this role consume a worker slot for a mission of this shape?
138
+ * @param {unknown} role
139
+ * @param {{ spawn?: { countsTowardWorkerBudget?: boolean } } | null | undefined} shape
140
+ */
141
+ export function roleCountsTowardBudget(role, shape) {
142
+ const spec = roleContract(role)
143
+ if (!spec) return false
144
+ if (spec.countsTowardBudget === 'shape') return shape?.spawn?.countsTowardWorkerBudget !== false
145
+ return spec.countsTowardBudget === true
146
+ }
147
+
148
+ /**
149
+ * Should this spawn run test discovery before activation? A worker on a shape
150
+ * with a worktree can; a reviewer never runs repository code.
151
+ * @param {unknown} role
152
+ * @param {{ fields?: { worktree?: boolean } } | null | undefined} shape
153
+ */
154
+ export function roleSpawnDiscovery(role, shape) {
155
+ const spec = roleContract(role)
156
+ if (!spec) return false
157
+ return spec.spawnDiscovery === 'worktree' && shape?.fields?.worktree === true
158
+ }
159
+
160
+ /**
161
+ * Resolve the role-controlled spawn facts: preset, model route, placement and
162
+ * briefing. The shape supplies `canMerge`/`worktree`/`spawn.model`; the role
163
+ * decides how they are used.
164
+ * @param {string} role
165
+ * @param {{ config: any, shape: any, shapePreset?: string | null }} input
166
+ */
167
+ export function resolveRoleSpawn(role, { config, shape, shapePreset = null }) {
168
+ const spec = roleContract(role)
169
+ const spawnConfig = config?.spawn ?? {}
170
+ if (!spec) return null
171
+ const preset = spec.preset === 'shape' ? (shapePreset ?? spawnConfig.workerPreset) : spawnConfig.reviewerPreset
172
+ const modelSlot = spec.model === 'shape' ? (shape?.spawn?.model ?? 'worker') : 'reviewer'
173
+ const model = spawnConfig[`${modelSlot}Model`]
174
+ return { preset, model, placement: spec.placement, briefing: spec.briefing }
175
+ }
176
+
177
+ /**
178
+ * Role prerequisites for one mission. The caller checks service-level facts
179
+ * (`reviewAvailable`); shape facts arrive on `shape`/`mission`. Returns
180
+ * blockers in the order a human would fix them; the caller throws the first.
181
+ * @param {string} role
182
+ * @param {{ shape: any, mission: any, reviewAvailable?: boolean }} input
183
+ * @returns {Array<{ code: string, message: string }>}
184
+ */
185
+ export function roleMissionBlockers(role, { shape, mission, reviewAvailable = true }) {
186
+ const spec = roleContract(role)
187
+ if (!spec) return [{ code: 'invalid-argument', message: `role must be one of ${ROLES.join('|')}` }]
188
+ const blockers = []
189
+ if (spec.requires.review && !reviewAvailable) {
190
+ blockers.push({
191
+ code: 'review-disabled',
192
+ message: 'review.mode is off, so review is unavailable and a reviewer cannot be spawned',
193
+ })
194
+ }
195
+ if (spec.requires.canMerge && !shape?.canMerge) {
196
+ blockers.push({
197
+ code: 'invalid-transition',
198
+ message: `mission ${mission?.id} is a ${shape?.kind}: it has no branch to review and is never reviewed`,
199
+ })
200
+ }
201
+ if (spec.requires.worktree && !mission?.worktree) {
202
+ blockers.push({
203
+ code: 'reviewer-prerequisite',
204
+ message: 'reviewer needs an existing mission worktree; spawn a worker first',
205
+ })
206
+ }
207
+ const statuses = spec.requires.statuses === 'shape' ? (shape?.spawn?.missionStatuses ?? []) : spec.requires.statuses
208
+ if (!statuses.includes(mission?.status)) {
209
+ blockers.push(
210
+ spec.requires.statuses === 'shape'
211
+ ? {
212
+ code: 'invalid-transition',
213
+ message: `mission ${mission?.id} is ${mission?.status}; a ${role} can only be spawned for a planned or active mission`,
214
+ }
215
+ : {
216
+ code: 'reviewer-prerequisite',
217
+ message: `reviewer requires a completed mission; ${mission?.id} is ${mission?.status}`,
218
+ },
219
+ )
220
+ }
221
+ return blockers
222
+ }
package/domain/roster.js CHANGED
@@ -1,5 +1,7 @@
1
1
  // Pure roster predicates.
2
2
  // @ts-check
3
+ import { isWorkerRole, roleCountsTowardBudget } from './roles.js'
4
+ import { shapeFor } from './shapes/index.js'
3
5
 
4
6
  /** @typedef {import('../types/index.js').RosterEntry} RosterEntry */
5
7
  /** @typedef {import('../types/index.js').TowerState} TowerState */
@@ -16,7 +18,7 @@ export const ROSTER_INACTIVE = new Set(ROSTER_INACTIVE_STATES)
16
18
  */
17
19
  export function liveWorkerEntry(state, missionId) {
18
20
  return (state.roster ?? []).find(
19
- (entry) => entry.missionId === missionId && entry.role === 'worker' && !ROSTER_INACTIVE.has(entry.state),
21
+ (entry) => entry.missionId === missionId && isWorkerRole(entry.role) && !ROSTER_INACTIVE.has(entry.state),
20
22
  )
21
23
  }
22
24
 
@@ -26,7 +28,7 @@ export function liveWorkerEntry(state, missionId) {
26
28
  * @returns {RosterEntry[]}
27
29
  */
28
30
  export function workerEntries(state, missionId) {
29
- return (state.roster ?? []).filter((entry) => entry.missionId === missionId && entry.role === 'worker')
31
+ return (state.roster ?? []).filter((entry) => entry.missionId === missionId && isWorkerRole(entry.role))
30
32
  }
31
33
 
32
34
  /**
@@ -34,7 +36,11 @@ export function workerEntries(state, missionId) {
34
36
  * @returns {number}
35
37
  */
36
38
  export function countLiveWorkers(state) {
39
+ const kinds = new Map((state.missions ?? []).map((mission) => [mission.id, mission.kind]))
37
40
  return (state.roster ?? []).filter(
38
- (entry) => entry.role === 'worker' && !ROSTER_INACTIVE.has(entry.state) && entry.state !== 'reserved',
41
+ (entry) =>
42
+ roleCountsTowardBudget(entry.role, shapeFor(kinds.get(entry.missionId ?? ''))) &&
43
+ !ROSTER_INACTIVE.has(entry.state) &&
44
+ entry.state !== 'reserved',
39
45
  ).length
40
46
  }
package/domain/scope.js CHANGED
@@ -85,6 +85,57 @@ export function comparisonMode(caseInsensitive) {
85
85
  return caseInsensitive === true ? 'case-insensitive' : 'case-sensitive'
86
86
  }
87
87
 
88
+ /** @typedef {{ code: string, detail: string }} ScopeBlocker */
89
+ /** @typedef {{ ownsPaths: boolean, fence: 'scope' | 'read-only' }} Reach */
90
+
91
+ /**
92
+ * The one path-permission fence (#149 W4). A shape's `reach` is the single
93
+ * definition of whether a mission may change paths at all:
94
+ *
95
+ * - `ownsPaths: true` + `fence: 'scope'`: every changed file must match the
96
+ * mission scope (case folding is a checkout fact, not a scope rewrite);
97
+ * - anything else (`fence: 'read-only'`): the mission permits no file change,
98
+ * so any changed path is a fence violation.
99
+ *
100
+ * The merge gate, the planning/lifecycle rules and the spawn briefing derive
101
+ * from the same declaration; none of them re-reads `mission.scope` to decide
102
+ * permission on its own.
103
+ *
104
+ * @param {{ mission: { id?: string, scope?: string[] | null }, reach: Reach | null | undefined, changedFiles?: string[], caseInsensitive?: boolean }} input
105
+ * @returns {ScopeBlocker | null}
106
+ */
107
+ export function scopeFenceBlocker({ mission, reach, changedFiles = [], caseInsensitive = false }) {
108
+ const files = Array.isArray(changedFiles) ? changedFiles : []
109
+ const ownsPaths = reach?.ownsPaths === true && reach?.fence !== 'read-only'
110
+ if (!ownsPaths) {
111
+ if (files.length === 0) return null
112
+ return {
113
+ code: 'out-of-scope',
114
+ detail: `mission ${mission?.id ?? '(unknown)'} is read-only and changed ${files.length} tracked path(s): ${files.slice(0, 8).join(', ')}`,
115
+ }
116
+ }
117
+ const scope = mission?.scope ?? []
118
+ const outOfScope = files.filter((file) => !fileInScope(scope, file, { caseInsensitive }))
119
+ if (outOfScope.length === 0) return null
120
+ const parts = [`changed files outside scope (${scope.join(', ')}): ${outOfScope.slice(0, 8).join(', ')}`]
121
+ // On a case-sensitive checkout a case-only mismatch is a human error worth
122
+ // naming, but it stays a rejection: folding here would make src/Api and
123
+ // src/api the same directory on a filesystem where they are not (#90).
124
+ if (!caseInsensitive) {
125
+ const hints = outOfScope
126
+ .map((file) => {
127
+ const pattern = caseInsensitiveScopeMatch(scope, file)
128
+ return pattern
129
+ ? `${file} matches "${pattern}" only when case is folded; this checkout is case-sensitive`
130
+ : null
131
+ })
132
+ .filter(Boolean)
133
+ .slice(0, 3)
134
+ if (hints.length > 0) parts.push(`case mismatch: ${hints.join('; ')}`)
135
+ }
136
+ return { code: 'out-of-scope', detail: parts.join('. ') }
137
+ }
138
+
88
139
  // Mission titles are printable Unicode, not printable ASCII (#88). The hazards
89
140
  // the old ASCII range stood in for are made explicit: control/format/surrogate
90
141
  // code points, bidi overrides, zero-width characters, and unbounded length.
@@ -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
+ })