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.
- package/CHANGELOG.md +82 -0
- package/COMPATIBILITY.md +1 -0
- package/CONTRIBUTING.md +1 -1
- package/README.en.md +2 -1
- package/README.md +6 -1
- 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 +97 -61
- package/app/reviewCheckout.js +133 -0
- package/app/sandbox.js +121 -0
- package/app/sharedCheckout.js +90 -0
- package/app/spawn-prechecks.js +25 -24
- package/app/spawn-support.js +59 -8
- package/app/spawn.js +3 -0
- package/app/teardown.js +119 -2
- package/app/tower.js +29 -1
- package/cordis.patch.yml +50 -0
- package/docs/adr/0006-mission-shapes.md +306 -0
- package/docs/architecture/overview.md +6 -4
- package/docs/guide/install-upgrade-migration.md +26 -0
- package/docs/operations/runbook.md +1 -1
- package/docs/reference/blockers.md +8 -4
- package/docs/reference/component-surface.md +7 -2
- package/docs/reference/config.generated.md +6 -4
- package/docs/reference/config.md +1 -2
- package/docs/reference/doctor.md +7 -7
- package/docs/reference/operations.md +1 -1
- package/docs/reference/shapes.md +123 -0
- package/docs/reference/surface-budget.md +33 -1
- package/docs/reference/testing.md +4 -2
- package/docs/reference/tools.generated.md +5 -5
- package/docs/reference/tools.md +2 -2
- package/docs/zh/reference/blockers.md +2 -2
- package/docs/zh/reference/config.md +1 -2
- package/docs/zh/reference/testing.md +3 -2
- package/domain/batch.js +75 -105
- package/domain/collab/batch.js +30 -10
- package/domain/collab/spawn.js +53 -16
- package/domain/config/schema.js +47 -17
- package/domain/contracts/outputs.js +0 -1
- package/domain/contracts/tool-inputs/missions.js +9 -1
- package/domain/evidence.js +283 -0
- package/domain/gate.js +11 -31
- 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/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/doctor.js +2 -1
- package/host/preset-survey-sandbox.js +19 -0
- package/host/preset-survey.js +18 -0
- package/host/presets.js +8 -3
- package/host/tool-registry.js +14 -12
- 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 +13 -2
- package/ports/state.js +8 -1
- package/scripts/architecture-waivers.json +3 -3
- package/scripts/check-docs.mjs +1 -1
- package/scripts/check-pack.mjs +2 -0
- package/scripts/component-surface.json +68 -20
- 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 +21 -0
- package/scripts/shape-audit.mjs +361 -0
- package/scripts/surface-baseline.json +68 -4
- 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/spawn.js +47 -49
- package/tools/test.js +2 -1
- package/types/index.d.ts +66 -5
package/domain/roles.js
ADDED
|
@@ -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
|
|
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
|
|
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) =>
|
|
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
|
+
})
|