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.
- package/CHANGELOG.md +114 -0
- package/COMPATIBILITY.md +2 -0
- package/CONTRIBUTING.md +1 -1
- package/GLOSSARY.md +6 -0
- package/PRINCIPLES.md +15 -7
- package/README.en.md +18 -16
- package/README.md +18 -11
- package/adapters/leases.js +20 -6
- package/adapters/legacy.js +149 -37
- package/adapters/stateFile.js +11 -1
- package/app/archive.js +30 -5
- package/app/briefing.js +95 -12
- package/app/checkCompletion.js +107 -18
- package/app/enrichedMissions.js +1 -1
- package/app/freshness.js +77 -0
- package/app/gate.js +26 -12
- package/app/merge.js +7 -4
- package/app/mission.js +24 -1
- package/app/missionContext.js +2 -1
- package/app/plan.js +64 -1
- package/app/provision.js +133 -22
- package/app/read.js +38 -5
- package/app/rebase.js +10 -8
- package/app/review.js +104 -61
- package/app/reviewCheckout.js +133 -0
- package/app/sandbox.js +121 -0
- package/app/sharedCheckout.js +90 -0
- package/app/spawn-prechecks.js +31 -20
- package/app/spawn-support.js +59 -8
- package/app/spawn.js +3 -0
- package/app/teardown.js +119 -2
- package/app/tower.js +34 -6
- package/client.js +321 -0
- package/cordis.patch.yml +55 -3
- package/docs/adr/0005-component-surface.md +58 -13
- package/docs/adr/0006-mission-shapes.md +306 -0
- package/docs/architecture/overview.md +18 -10
- package/docs/examples/first-mission.md +3 -2
- package/docs/faq.md +9 -7
- package/docs/guide/install-upgrade-migration.md +58 -7
- package/docs/guide/quickstart.md +20 -10
- package/docs/guide/scenarios-and-philosophy.md +35 -28
- package/docs/operations/runbook.md +1 -1
- package/docs/operations/security.md +11 -10
- package/docs/reference/blockers.md +20 -11
- package/docs/reference/component-surface.md +20 -8
- package/docs/reference/config.generated.md +8 -5
- package/docs/reference/config.md +30 -5
- package/docs/reference/doctor.md +7 -7
- package/docs/reference/errors.generated.md +1 -0
- package/docs/reference/errors.md +2 -1
- package/docs/reference/operations.md +1 -1
- package/docs/reference/shapes.md +123 -0
- package/docs/reference/surface-budget.md +53 -4
- package/docs/reference/testing.md +5 -3
- package/docs/reference/tools.generated.md +7 -7
- package/docs/reference/tools.md +5 -5
- package/docs/zh/faq.md +7 -5
- package/docs/zh/guide/install-upgrade-migration.md +25 -6
- package/docs/zh/guide/quickstart.md +17 -10
- package/docs/zh/guide/scenarios-and-philosophy.md +23 -15
- package/docs/zh/operations/security.md +9 -8
- package/docs/zh/reference/blockers.md +13 -9
- package/docs/zh/reference/config.md +22 -5
- package/docs/zh/reference/errors.md +2 -1
- package/docs/zh/reference/testing.md +5 -4
- package/docs/zh/reference/tools.md +3 -3
- package/domain/batch.js +75 -105
- package/domain/collab/batch.js +30 -10
- package/domain/collab/spawn.js +53 -16
- package/domain/components.js +12 -4
- package/domain/config/schema.js +87 -22
- package/domain/contracts/outputs.js +2 -1
- package/domain/contracts/tool-inputs/missions.js +10 -2
- package/domain/errors.js +6 -0
- package/domain/evidence.js +283 -0
- package/domain/gate.js +17 -34
- package/domain/integration/gate.js +17 -43
- package/domain/integration/init.js +3 -2
- package/domain/integration/provision.js +51 -0
- package/domain/integration/rebase.js +16 -1
- package/domain/integration/review.js +203 -9
- package/domain/mission.js +16 -33
- package/domain/missions/completion.js +46 -8
- package/domain/missions/findings.js +2 -1
- package/domain/missions/lifecycle.js +131 -35
- package/domain/missions/plan.js +30 -6
- package/domain/missions/review.js +2 -18
- package/domain/planning.js +5 -5
- package/domain/read/missionContext.js +17 -3
- package/domain/read/views.js +5 -1
- package/domain/roles.js +222 -0
- package/domain/roster.js +9 -3
- package/domain/scope.js +51 -0
- package/domain/shapes/build.js +254 -0
- package/domain/shapes/index.js +171 -0
- package/domain/shapes/survey.js +445 -0
- package/domain/state/archive.js +28 -4
- package/domain/state/migrations.js +111 -4
- package/domain/state/schema.js +69 -7
- package/domain/state/semantics.js +93 -13
- package/domain/state.js +5 -1
- package/domain/summary.js +5 -11
- package/domain/vocabulary.js +15 -33
- package/host/capability-registry.js +25 -9
- package/host/config.js +40 -3
- package/host/doctor.js +20 -8
- package/host/preset-survey-sandbox.js +19 -0
- package/host/preset-survey.js +18 -0
- package/host/presets.js +17 -11
- package/host/tool-registry.js +23 -13
- package/locale/en.json +16 -1
- package/locale/preset-survey/en.json +6 -0
- package/locale/preset-survey/zh.json +6 -0
- package/locale/preset-survey-sandbox/en.json +6 -0
- package/locale/preset-survey-sandbox/zh.json +6 -0
- package/locale/zh.json +16 -1
- package/package.json +19 -2
- package/ports/state.js +8 -1
- package/scripts/architecture-waivers.json +3 -3
- package/scripts/check-components.mjs +120 -6
- package/scripts/check-docs.mjs +1 -1
- package/scripts/check-pack.mjs +5 -0
- package/scripts/component-surface.json +83 -29
- package/scripts/generate-capability-locale.mjs +5 -0
- package/scripts/generate-shapes-reference.mjs +171 -0
- package/scripts/mount-smoke/surface.mjs +3 -0
- package/scripts/pack-manifest.txt +22 -0
- package/scripts/shape-audit.mjs +361 -0
- package/scripts/surface-baseline.json +75 -4
- package/scripts/types-coverage.mjs +7 -0
- package/survey-sandbox.js +48 -0
- package/survey.js +40 -0
- package/tools/complete.js +5 -5
- package/tools/diff.js +5 -3
- package/tools/plan.js +1 -1
- package/tools/preflight.js +3 -2
- package/tools/review.js +1 -1
- package/tools/spawn.js +48 -50
- package/tools/status.js +8 -1
- package/tools/test.js +2 -1
- 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
|
+
}
|