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
|
@@ -5,6 +5,8 @@
|
|
|
5
5
|
// @ts-check
|
|
6
6
|
import { TowerToolError } from '../errors.js'
|
|
7
7
|
import { completedEvent } from '../events.js'
|
|
8
|
+
import { shapeFor } from '../shapes/index.js'
|
|
9
|
+
import { isWorkerRole } from '../vocabulary.js'
|
|
8
10
|
|
|
9
11
|
/** @typedef {import('../../types/index.js').Mission} Mission */
|
|
10
12
|
/** @typedef {import('../../types/index.js').RosterEntry} RosterEntry */
|
|
@@ -113,7 +115,7 @@ export function assertCompletionRequest({ mission, roster, input, maxReportBytes
|
|
|
113
115
|
if (!worker || worker.missionId !== mission.id) {
|
|
114
116
|
fail(`agent ${input.agentId} is not assigned to mission ${mission.id}`)
|
|
115
117
|
}
|
|
116
|
-
if (worker.role
|
|
118
|
+
if (!isWorkerRole(worker.role)) fail(`agent ${worker.name} is a ${worker.role}, not a worker`)
|
|
117
119
|
if (['retired', 'failed', 'interrupted', 'missing', 'stale'].includes(worker.state)) {
|
|
118
120
|
fail(`agent ${worker.name} is ${worker.state}; a stopped worker cannot report completion`)
|
|
119
121
|
}
|
|
@@ -121,6 +123,12 @@ export function assertCompletionRequest({ mission, roster, input, maxReportBytes
|
|
|
121
123
|
fail(`agent ${worker.name} is not the current owner of mission ${mission.id} (owner: ${mission.owner})`)
|
|
122
124
|
}
|
|
123
125
|
if (mission.status === 'completed') fail(`mission ${mission.id} is already completed`)
|
|
126
|
+
// `blocked` is derived from open blockers, so name them instead of a generic
|
|
127
|
+
// status error: clearing them is the one recovery path (#149 W3).
|
|
128
|
+
const blockers = mission.blockers ?? []
|
|
129
|
+
if (blockers.length > 0) {
|
|
130
|
+
fail(`mission ${mission.id} has unresolved blockers: ${blockers.join('; ')}; clear the blockers to resume it`)
|
|
131
|
+
}
|
|
124
132
|
if (!['active', 'planned'].includes(mission.status)) {
|
|
125
133
|
fail(`mission ${mission.id} is ${mission.status}; it cannot be completed from that state`)
|
|
126
134
|
}
|
|
@@ -136,11 +144,6 @@ export function assertCompletionRequest({ mission, roster, input, maxReportBytes
|
|
|
136
144
|
.join('; ')}`,
|
|
137
145
|
)
|
|
138
146
|
}
|
|
139
|
-
const blockers = mission.blockers ?? []
|
|
140
|
-
if (blockers.length > 0) {
|
|
141
|
-
fail(`mission ${mission.id} has unresolved blockers: ${blockers.join('; ')}`)
|
|
142
|
-
}
|
|
143
|
-
if (!mission.worktree) fail(`mission ${mission.id} has no worktree`)
|
|
144
147
|
return worker
|
|
145
148
|
}
|
|
146
149
|
|
|
@@ -167,13 +170,48 @@ export function completionReport({ report, worker, checks, now, maxReportBytes }
|
|
|
167
170
|
return { report: text, reportBytes: assertReportSize(text, maxReportBytes), by: worker.name, at: now, checks }
|
|
168
171
|
}
|
|
169
172
|
|
|
173
|
+
/**
|
|
174
|
+
* Normalize the optional survey result (#148 D6). Only a shape that accepts a
|
|
175
|
+
* result may carry one; the value stays small so the state document does not
|
|
176
|
+
* become an evidence dump.
|
|
177
|
+
* @param {unknown} result
|
|
178
|
+
* @returns {any}
|
|
179
|
+
*/
|
|
180
|
+
export function normalizeResult(result) {
|
|
181
|
+
if (result === undefined || result === null) return null
|
|
182
|
+
if (!isPlainObject(result)) fail('result must be an object')
|
|
183
|
+
const value = /** @type {Record<string, any>} */ (result)
|
|
184
|
+
/** @type {Record<string, any>} */
|
|
185
|
+
const normalized = {}
|
|
186
|
+
for (const key of ['paths', 'symbols', 'plan']) {
|
|
187
|
+
if (value[key] === undefined) continue
|
|
188
|
+
if (!Array.isArray(value[key])) fail(`result.${key} must be an array of strings`)
|
|
189
|
+
if (value[key].length > 200) fail(`result.${key} supports at most 200 entries`)
|
|
190
|
+
normalized[key] = value[key].map((entry) => {
|
|
191
|
+
if (typeof entry !== 'string' || entry.trim().length === 0) fail(`result.${key} entries must be non-empty strings`)
|
|
192
|
+
return entry.trim()
|
|
193
|
+
})
|
|
194
|
+
}
|
|
195
|
+
if (value.notes !== undefined) {
|
|
196
|
+
if (typeof value.notes !== 'string') fail('result.notes must be a string')
|
|
197
|
+
normalized.notes = value.notes.trim().slice(0, 4000)
|
|
198
|
+
}
|
|
199
|
+
return normalized
|
|
200
|
+
}
|
|
201
|
+
|
|
170
202
|
/**
|
|
171
203
|
* Apply the completion transition to the mission draft and describe the domain
|
|
172
204
|
* fact. Persistence stays in app/checkCompletion.js.
|
|
173
|
-
* @param {{ mission: Mission, report: any, now: number }} params
|
|
205
|
+
* @param {{ mission: Mission, report: any, now: number, result?: any }} params
|
|
174
206
|
* @returns {{ state: Mission, events: Array<{ type: string, missionId: string }> }}
|
|
175
207
|
*/
|
|
176
|
-
export function applyCompletion({ mission, report, now }) {
|
|
208
|
+
export function applyCompletion({ mission, report, now, result }) {
|
|
209
|
+
const shape = shapeFor(mission.kind)
|
|
210
|
+
const normalized = normalizeResult(result)
|
|
211
|
+
if (normalized !== null) {
|
|
212
|
+
if (!shape.acceptsResult) fail(`mission ${mission.id} is a ${shape.kind}; a completion result is survey evidence only`)
|
|
213
|
+
mission.result = normalized
|
|
214
|
+
}
|
|
177
215
|
mission.status = 'completed'
|
|
178
216
|
mission.completionReport = report
|
|
179
217
|
mission.updatedAt = now
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
// @ts-check
|
|
6
6
|
import { TowerToolError } from '../errors.js'
|
|
7
7
|
import { FINDING_SEVERITIES, FINDING_STATUSES, FINDING_TRANSITIONS, FINDING_TYPES } from '../mission.js'
|
|
8
|
+
import { isReviewerRole } from '../roles.js'
|
|
8
9
|
import { countFindings, findingSummary } from '../summary.js'
|
|
9
10
|
|
|
10
11
|
/** @typedef {import('../../types/index.js').Finding} Finding */
|
|
@@ -158,7 +159,7 @@ export function readFindingsView(findings, input) {
|
|
|
158
159
|
* @returns {{ noteAdded: boolean }}
|
|
159
160
|
*/
|
|
160
161
|
export function applyFindingUpdate({ finding, input, actor, now }) {
|
|
161
|
-
if (actor.role
|
|
162
|
+
if (isReviewerRole(actor.role)) {
|
|
162
163
|
fail('reviewers may list and read findings, but only the tower or a worker may change finding status', 'not-authorized')
|
|
163
164
|
}
|
|
164
165
|
let noteAdded = false
|
|
@@ -6,14 +6,14 @@
|
|
|
6
6
|
// @ts-check
|
|
7
7
|
import { TowerToolError } from '../errors.js'
|
|
8
8
|
import { abandonedEvent, completedEvent } from '../events.js'
|
|
9
|
-
import {
|
|
9
|
+
import { pauseCauseError } from '../mission.js'
|
|
10
10
|
import { normalizeScope, scopesOverlap } from '../scope.js'
|
|
11
|
+
import { shapeFor } from '../shapes/index.js'
|
|
12
|
+
import { isReviewerRole, isWorkerRole } from '../vocabulary.js'
|
|
11
13
|
|
|
12
14
|
/** @typedef {import('../../types/index.js').Mission} Mission */
|
|
13
15
|
/** @typedef {import('../../types/index.js').TowerState} TowerState */
|
|
14
16
|
|
|
15
|
-
const TERMINAL = TERMINAL_STATUSES
|
|
16
|
-
|
|
17
17
|
/**
|
|
18
18
|
* @param {string} message
|
|
19
19
|
* @param {string} [code]
|
|
@@ -42,8 +42,8 @@ export function normalizePauseCause(value) {
|
|
|
42
42
|
* @returns {void}
|
|
43
43
|
*/
|
|
44
44
|
export function assertMissionActor(mission, actor, input) {
|
|
45
|
-
const workerRequest = actor.role
|
|
46
|
-
if (actor.role
|
|
45
|
+
const workerRequest = isWorkerRole(actor.role)
|
|
46
|
+
if (isReviewerRole(actor.role)) {
|
|
47
47
|
fail('only the tower or a worker may update a mission; reviewers submit tower_review', 'not-authorized')
|
|
48
48
|
}
|
|
49
49
|
if (workerRequest && mission.owner && mission.owner !== actor.name) {
|
|
@@ -61,6 +61,48 @@ export function assertMissionActor(mission, actor, input) {
|
|
|
61
61
|
}
|
|
62
62
|
}
|
|
63
63
|
|
|
64
|
+
/**
|
|
65
|
+
* Mark a mission blocked from a machine-derived failure (a merge or rebase
|
|
66
|
+
* conflict). `blocked` is derived from open blockers, so the blocker is recorded
|
|
67
|
+
* with the status in one write path; no caller assigns the status directly
|
|
68
|
+
* (#149 W3). Repeating the same failure does not duplicate the blocker.
|
|
69
|
+
* @param {Mission} mission
|
|
70
|
+
* @param {string} blocker
|
|
71
|
+
* @param {number} [now]
|
|
72
|
+
* @returns {Mission}
|
|
73
|
+
*/
|
|
74
|
+
export function markBlocked(mission, blocker, now = Date.now()) {
|
|
75
|
+
if (typeof blocker !== 'string' || blocker.trim().length === 0) fail('a blocker must be a non-empty string')
|
|
76
|
+
const text = blocker.trim()
|
|
77
|
+
const blockers = /** @type {string[]} */ (mission.blockers)
|
|
78
|
+
if (!blockers.includes(text)) blockers.push(text)
|
|
79
|
+
mission.status = 'blocked'
|
|
80
|
+
// A blocker wins over a pause: the human cause is superseded by the failure.
|
|
81
|
+
delete mission.pauseCause
|
|
82
|
+
mission.updatedAt = now
|
|
83
|
+
return mission
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Resolve blockers that a successful machine operation has cleared (a rebase
|
|
88
|
+
* conflict that the retried rebase resolved). Blockers that do not match stay,
|
|
89
|
+
* and the mission stays blocked while any remain — recovery is always "the
|
|
90
|
+
* recorded blockers are gone", never a status assignment.
|
|
91
|
+
* @param {Mission} mission
|
|
92
|
+
* @param {(blocker: string) => boolean} matches
|
|
93
|
+
* @param {number} [now]
|
|
94
|
+
* @returns {boolean} whether any blocker was removed
|
|
95
|
+
*/
|
|
96
|
+
export function resolveBlockers(mission, matches, now = Date.now()) {
|
|
97
|
+
const blockers = /** @type {string[]} */ (mission.blockers ?? [])
|
|
98
|
+
const kept = blockers.filter((blocker) => !matches(blocker))
|
|
99
|
+
if (kept.length === blockers.length) return false
|
|
100
|
+
mission.blockers = kept
|
|
101
|
+
if (kept.length === 0 && mission.status === 'blocked') mission.status = 'active'
|
|
102
|
+
mission.updatedAt = now
|
|
103
|
+
return true
|
|
104
|
+
}
|
|
105
|
+
|
|
64
106
|
/**
|
|
65
107
|
* Apply one validated `tower_mission` patch to the mission draft and return
|
|
66
108
|
* the human-readable change list used by the activity log plus the domain
|
|
@@ -82,34 +124,80 @@ export function applyMissionUpdate({ mission, state, input, config }) {
|
|
|
82
124
|
/** @type {Array<{ type: string, missionId: string }>} */
|
|
83
125
|
const events = []
|
|
84
126
|
|
|
127
|
+
const shape = shapeFor(mission.kind)
|
|
128
|
+
// W3: `blocked` is derived state, `paused` is a deliberate stop. Clearing the
|
|
129
|
+
// blockers is what resumes a blocked mission, so it is applied before any
|
|
130
|
+
// status patch: `{clear_blockers:true,status:"active"}` and
|
|
131
|
+
// `{clear_blockers:true,status:"completed"}` both describe one coherent step.
|
|
132
|
+
if (input.clear_blockers === true) {
|
|
133
|
+
if ((mission.blockers ?? []).length > 0) {
|
|
134
|
+
mission.blockers = []
|
|
135
|
+
changes.push('blockers cleared')
|
|
136
|
+
}
|
|
137
|
+
if (mission.status === 'blocked') {
|
|
138
|
+
mission.status = 'active'
|
|
139
|
+
changes.push('status=active (blocked until the blockers were cleared)')
|
|
140
|
+
}
|
|
141
|
+
}
|
|
85
142
|
if (input.status !== undefined) {
|
|
86
|
-
if (typeof input.status !== 'string' || !
|
|
87
|
-
fail(`unknown mission status "${input.status}"`)
|
|
143
|
+
if (typeof input.status !== 'string' || !shape.statuses.includes(input.status)) {
|
|
144
|
+
fail(`unknown mission status "${input.status}" for a ${shape.kind} mission`)
|
|
88
145
|
}
|
|
89
146
|
if (input.status === 'merged') {
|
|
90
147
|
fail('merged is recorded only by tower_merge, not by a generic mission patch', 'invalid-transition')
|
|
91
148
|
}
|
|
92
|
-
if (
|
|
93
|
-
fail(
|
|
149
|
+
if (input.status === 'blocked') {
|
|
150
|
+
fail(
|
|
151
|
+
'blocked is derived from open blockers: record blocker:"..." instead of setting status "blocked"',
|
|
152
|
+
'invalid-transition',
|
|
153
|
+
)
|
|
94
154
|
}
|
|
95
|
-
|
|
96
|
-
|
|
155
|
+
const openBlockers = mission.blockers ?? []
|
|
156
|
+
if (mission.status === 'blocked' && input.status !== 'abandoned') {
|
|
157
|
+
// A blocked mission is resumed by clearing its blockers, never by a
|
|
158
|
+
// status patch that skips the blocker record (#149 W3).
|
|
159
|
+
fail(
|
|
160
|
+
`mission ${mission.id} is blocked (${openBlockers.join('; ')}); clear_blockers resumes it — it cannot move to ${input.status} directly`,
|
|
161
|
+
'invalid-transition',
|
|
162
|
+
)
|
|
97
163
|
}
|
|
98
|
-
if (
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
164
|
+
if (openBlockers.length > 0 && ['active', 'completed', 'paused'].includes(input.status)) {
|
|
165
|
+
fail(
|
|
166
|
+
`mission ${mission.id} has open blockers (${openBlockers.join('; ')}); clear_blockers resumes it — do not move it to ${input.status}`,
|
|
167
|
+
'invalid-transition',
|
|
168
|
+
)
|
|
169
|
+
}
|
|
170
|
+
// A status patch is a real move: the target must differ and be a legal edge.
|
|
171
|
+
// `paused` is re-entered only from a different status (a pause cause is
|
|
172
|
+
// meaningful when the pause begins, not as an in-place metadata edit).
|
|
173
|
+
// `{clear_blockers:true,status:"active"}` is one coherent resume step: the
|
|
174
|
+
// clear already moved the mission, so the redundant patch is not a second
|
|
175
|
+
// transition.
|
|
176
|
+
const redundantResume =
|
|
177
|
+
input.clear_blockers === true && input.status === 'active' && mission.status === 'active'
|
|
178
|
+
if (!redundantResume) {
|
|
179
|
+
if (!shape.canTransition(mission.status, input.status)) {
|
|
180
|
+
fail(`mission ${mission.id} cannot move from ${mission.status} to ${input.status}`, 'invalid-transition')
|
|
104
181
|
}
|
|
105
|
-
if (
|
|
106
|
-
mission.
|
|
182
|
+
if (input.status === 'paused') {
|
|
183
|
+
mission.pauseCause = normalizePauseCause(input.pause_cause)
|
|
107
184
|
}
|
|
185
|
+
if (mission.status === 'paused' && input.status === 'active') {
|
|
186
|
+
const requireAuthorization = config.mode.requireResumeAuthorization === true
|
|
187
|
+
if (requireAuthorization && (mission.pauseCause ?? 'user') === 'user') {
|
|
188
|
+
if (typeof input.authorization !== 'string' || input.authorization.trim().length === 0) {
|
|
189
|
+
fail('resuming a user-paused mission requires a non-empty authorization reference')
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
if (typeof input.authorization === 'string' && input.authorization.trim()) {
|
|
193
|
+
mission.authorizationRef = input.authorization.trim()
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
mission.status = input.status
|
|
197
|
+
changes.push(`status=${input.status}`)
|
|
198
|
+
if (input.status === 'completed') events.push(completedEvent(mission))
|
|
199
|
+
if (input.status === 'abandoned') events.push(abandonedEvent(mission))
|
|
108
200
|
}
|
|
109
|
-
mission.status = input.status
|
|
110
|
-
changes.push(`status=${input.status}`)
|
|
111
|
-
if (input.status === 'completed') events.push(completedEvent(mission))
|
|
112
|
-
if (input.status === 'abandoned') events.push(abandonedEvent(mission))
|
|
113
201
|
}
|
|
114
202
|
|
|
115
203
|
if (input.task_done !== undefined) {
|
|
@@ -144,20 +232,23 @@ export function applyMissionUpdate({ mission, state, input, config }) {
|
|
|
144
232
|
if (typeof input.blocker !== 'string' || input.blocker.trim().length === 0) {
|
|
145
233
|
fail('blocker must be a non-empty string')
|
|
146
234
|
}
|
|
147
|
-
if (
|
|
235
|
+
if (shape.isTerminal(mission.status)) {
|
|
148
236
|
fail(`mission ${mission.id} is ${mission.status}; a blocker cannot reopen a terminal mission`)
|
|
149
237
|
}
|
|
150
238
|
const blockers = /** @type {string[]} */ (mission.blockers)
|
|
151
239
|
blockers.push(input.blocker.trim())
|
|
152
|
-
|
|
240
|
+
// W3: a blocker always wins over a pause; the paused cause is superseded and
|
|
241
|
+
// the mission is blocked until the blockers are cleared.
|
|
242
|
+
if (mission.status !== 'blocked') {
|
|
243
|
+
if (!shape.canTransition(mission.status, 'blocked')) {
|
|
244
|
+
fail(`mission ${mission.id} cannot move from ${mission.status} to blocked`, 'invalid-transition')
|
|
245
|
+
}
|
|
246
|
+
if (mission.status === 'paused') delete mission.pauseCause
|
|
247
|
+
mission.status = 'blocked'
|
|
248
|
+
}
|
|
153
249
|
changes.push('blocker added')
|
|
154
250
|
}
|
|
155
251
|
|
|
156
|
-
if (input.clear_blockers === true) {
|
|
157
|
-
mission.blockers = []
|
|
158
|
-
changes.push('blockers cleared')
|
|
159
|
-
}
|
|
160
|
-
|
|
161
252
|
if (input.note !== undefined) {
|
|
162
253
|
if (typeof input.note !== 'string' || input.note.trim().length === 0) {
|
|
163
254
|
fail('note must be a non-empty string')
|
|
@@ -168,16 +259,21 @@ export function applyMissionUpdate({ mission, state, input, config }) {
|
|
|
168
259
|
}
|
|
169
260
|
|
|
170
261
|
if (input.scope !== undefined) {
|
|
262
|
+
if (!shape.reach.ownsPaths) {
|
|
263
|
+
fail(`mission ${mission.id} is a ${shape.kind}: it reads a commit and does not own scope`)
|
|
264
|
+
}
|
|
171
265
|
if (!['active', 'completed'].includes(mission.status)) {
|
|
172
266
|
fail(`scope can only be widened for active or completed missions, not ${mission.status}`)
|
|
173
267
|
}
|
|
174
|
-
const scope = normalizeScope(input.scope
|
|
175
|
-
const others = state.missions.filter(
|
|
176
|
-
(entry
|
|
177
|
-
|
|
268
|
+
const scope = normalizeScope(input.scope)
|
|
269
|
+
const others = state.missions.filter((entry) => {
|
|
270
|
+
if (entry.id === mission.id) return false
|
|
271
|
+
const other = shapeFor(entry.kind)
|
|
272
|
+
return other.reach.ownsPaths && !other.isTerminal(entry.status)
|
|
273
|
+
})
|
|
178
274
|
const caseInsensitive = state.caseInsensitive === true
|
|
179
275
|
for (const other of others) {
|
|
180
|
-
if (
|
|
276
|
+
if (scopesOverlap(scope, other.scope, { caseInsensitive })) {
|
|
181
277
|
fail(`widened scope overlaps mission ${other.id}; retire it or add an explicit ordering`)
|
|
182
278
|
}
|
|
183
279
|
}
|
package/domain/missions/plan.js
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
import { TowerToolError } from '../errors.js'
|
|
7
7
|
import { plannedEvents } from '../events.js'
|
|
8
8
|
import { normalizeScope, validateTitle } from '../scope.js'
|
|
9
|
+
import { SHAPE_KINDS, shapeFor } from '../shapes/index.js'
|
|
9
10
|
|
|
10
11
|
/** @typedef {import('../../types/index.js').Mission} Mission */
|
|
11
12
|
/** @typedef {import('../../types/index.js').TowerState} TowerState */
|
|
@@ -28,15 +29,30 @@ function isPlainObject(value) {
|
|
|
28
29
|
|
|
29
30
|
/**
|
|
30
31
|
* Validate and normalize one `tower_plan` mission entry.
|
|
32
|
+
*
|
|
33
|
+
* The shape owns the kind-specific plan fields: a build normalizes its scope, a
|
|
34
|
+
* survey records the commit it reads (`read.commit`, resolved by app/plan from
|
|
35
|
+
* `state.base`) plus the tracked-status fingerprint captured at the same moment.
|
|
31
36
|
* @param {any} input
|
|
32
37
|
* @param {number} index
|
|
38
|
+
* @param {{ commit?: string, dirty?: string[] }} [read]
|
|
33
39
|
* @returns {any}
|
|
34
40
|
*/
|
|
35
|
-
export function normalizeMissionInput(input, index) {
|
|
41
|
+
export function normalizeMissionInput(input, index, read = {}) {
|
|
36
42
|
if (!isPlainObject(input)) fail(`missions[${index}] must be an object`)
|
|
37
43
|
const title = validateTitle(input.title)
|
|
38
|
-
const kind = input.kind ===
|
|
39
|
-
|
|
44
|
+
const kind = input.kind === undefined ? 'build' : input.kind
|
|
45
|
+
if (typeof kind !== 'string' || !SHAPE_KINDS.includes(kind)) {
|
|
46
|
+
fail(`missions[${index}].kind must be one of ${SHAPE_KINDS.join('|')}`)
|
|
47
|
+
}
|
|
48
|
+
const shape = shapeFor(kind)
|
|
49
|
+
/** @type {string[]} */
|
|
50
|
+
const scopeInput = input.scope ?? []
|
|
51
|
+
if (!Array.isArray(scopeInput)) fail(`missions[${index}].scope must be an array of globs`)
|
|
52
|
+
if (!shape.reach.ownsPaths && scopeInput.length > 0) {
|
|
53
|
+
fail(`missions[${index}] is a ${shape.kind}: it reads a commit and does not own a write scope`)
|
|
54
|
+
}
|
|
55
|
+
const scope = shape.reach.ownsPaths ? normalizeScope(scopeInput) : []
|
|
40
56
|
/** @type {Array<{ text: string, status: string }>} */
|
|
41
57
|
const tasks = []
|
|
42
58
|
if (input.tasks !== undefined) {
|
|
@@ -80,19 +96,25 @@ export function normalizeMissionInput(input, index) {
|
|
|
80
96
|
if (reviewPolicy === 'none' && reviewExemptionReason.length === 0) {
|
|
81
97
|
fail(`missions[${index}].reviewPolicy "none" requires a non-empty reviewExemptionReason`)
|
|
82
98
|
}
|
|
83
|
-
|
|
99
|
+
const fields = /** @type {any} */ (/** @type {any} */ (shape).planFields)({
|
|
100
|
+
scope,
|
|
101
|
+
readCommit: read.commit,
|
|
102
|
+
readDirty: read.dirty,
|
|
103
|
+
})
|
|
104
|
+
return { title, kind, ...fields, tasks, deps, context, references, reviewPolicy, reviewExemptionReason }
|
|
84
105
|
}
|
|
85
106
|
|
|
86
107
|
/**
|
|
87
108
|
* Validate the plan envelope and normalize every mission entry.
|
|
88
109
|
* @param {any} input
|
|
110
|
+
* @param {{ commit?: string, dirty?: string[] }} [read]
|
|
89
111
|
* @returns {any[]}
|
|
90
112
|
*/
|
|
91
|
-
export function normalizePlanMissions(input) {
|
|
113
|
+
export function normalizePlanMissions(input, read = {}) {
|
|
92
114
|
if (!Array.isArray(input.missions) || input.missions.length === 0) {
|
|
93
115
|
fail('tower_plan needs at least one mission')
|
|
94
116
|
}
|
|
95
|
-
return input.missions.map((mission, index) => normalizeMissionInput(mission, index))
|
|
117
|
+
return input.missions.map((mission, index) => normalizeMissionInput(mission, index, read))
|
|
96
118
|
}
|
|
97
119
|
|
|
98
120
|
/**
|
|
@@ -147,6 +169,8 @@ export function newMission(id, input, now) {
|
|
|
147
169
|
scopeGeneration: 1,
|
|
148
170
|
createdAt: now,
|
|
149
171
|
updatedAt: now,
|
|
172
|
+
...(input.readCommit === undefined ? {} : { readCommit: input.readCommit }),
|
|
173
|
+
...(input.readDirty === undefined ? {} : { readDirty: input.readDirty }),
|
|
150
174
|
}))
|
|
151
175
|
}
|
|
152
176
|
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
import { TowerToolError } from '../errors.js'
|
|
8
8
|
import { reviewedEvent } from '../events.js'
|
|
9
9
|
import { MERGE_VERDICTS, REVIEW_STATUS_RE } from '../mission.js'
|
|
10
|
+
import { isReviewerRole } from '../roles.js'
|
|
10
11
|
|
|
11
12
|
/** @typedef {import('../../types/index.js').Mission} Mission */
|
|
12
13
|
/** @typedef {import('../../types/index.js').RosterEntry} RosterEntry */
|
|
@@ -47,7 +48,7 @@ export function resolveReviewActor({ mission, caller, actorRole, agentId, breakG
|
|
|
47
48
|
return { reviewerName: 'tower', reviewerRole: 'tower' }
|
|
48
49
|
}
|
|
49
50
|
if (caller) {
|
|
50
|
-
if (caller.role
|
|
51
|
+
if (!isReviewerRole(caller.role)) {
|
|
51
52
|
fail(`agent ${caller.name} is a ${caller.role}; only an assigned reviewer or the tower (break-glass) may review`)
|
|
52
53
|
}
|
|
53
54
|
if (caller.missionId !== mission.id) {
|
|
@@ -92,23 +93,6 @@ export function normalizeReviewVerdict(input) {
|
|
|
92
93
|
return { status: input.status, merge: input.merge, findings, decision, checks }
|
|
93
94
|
}
|
|
94
95
|
|
|
95
|
-
/**
|
|
96
|
-
* A reviewer may only review the tip it was spawned on.
|
|
97
|
-
* @param {RosterEntry | undefined} caller
|
|
98
|
-
* @param {string} tip
|
|
99
|
-
* @returns {void}
|
|
100
|
-
*/
|
|
101
|
-
export function assertReviewerSpawnTip(caller, tip) {
|
|
102
|
-
if (caller && caller.role === 'reviewer' && !String(caller.spawnTip ?? '').trim()) {
|
|
103
|
-
fail(`reviewer ${caller.name} has no recorded spawn tip; re-spawn the reviewer before reviewing`)
|
|
104
|
-
}
|
|
105
|
-
if (caller && caller.role === 'reviewer' && caller.spawnTip !== tip) {
|
|
106
|
-
fail(
|
|
107
|
-
`reviewer ${caller.name} modified the mission branch after it was spawned (${String(caller.spawnTip).slice(0, 7)} -> ${tip.slice(0, 7)}); reviewers must be read-only — spawn a fresh reviewer`,
|
|
108
|
-
)
|
|
109
|
-
}
|
|
110
|
-
}
|
|
111
|
-
|
|
112
96
|
/**
|
|
113
97
|
* The latest review of one branch. Higher rounds win; routes that share a round
|
|
114
98
|
* are ordered by submission sequence, so "latest" is the most recently recorded
|
package/domain/planning.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// @ts-check
|
|
3
3
|
import { TowerToolError } from './errors.js'
|
|
4
4
|
import { scopesOverlap } from './scope.js'
|
|
5
|
-
import {
|
|
5
|
+
import { shapeFor } from './shapes/index.js'
|
|
6
6
|
|
|
7
7
|
/** @typedef {import('../types/index.js').Mission} Mission */
|
|
8
8
|
|
|
@@ -93,7 +93,7 @@ function assertAcyclic(missions) {
|
|
|
93
93
|
* @returns {void}
|
|
94
94
|
*/
|
|
95
95
|
function addAutoDeps(newMissions, existingMissions, { caseInsensitive = false } = {}) {
|
|
96
|
-
const openExisting = existingMissions.filter((mission) => !isTerminal(mission.status))
|
|
96
|
+
const openExisting = existingMissions.filter((mission) => !shapeFor(mission.kind).isTerminal(mission.status))
|
|
97
97
|
const all = [...existingMissions, ...newMissions]
|
|
98
98
|
/** @type {Map<string, Mission>} */
|
|
99
99
|
const byId = new Map(all.map((mission) => [mission.id, mission]))
|
|
@@ -102,15 +102,15 @@ function addAutoDeps(newMissions, existingMissions, { caseInsensitive = false }
|
|
|
102
102
|
for (let j = i + 1; j < newMissions.length; j += 1) {
|
|
103
103
|
const earlier = newMissions[i]
|
|
104
104
|
const later = newMissions[j]
|
|
105
|
-
if (earlier.kind
|
|
105
|
+
if (!shapeFor(earlier.kind).reach.ownsPaths || !shapeFor(later.kind).reach.ownsPaths) continue
|
|
106
106
|
if (!scopesOverlap(earlier.scope, later.scope, { caseInsensitive })) continue
|
|
107
107
|
if (hasDependencyPath(byId, later.id, earlier.id)) continue
|
|
108
108
|
addDependency(later, earlier.id)
|
|
109
109
|
}
|
|
110
110
|
const mission = newMissions[i]
|
|
111
|
-
if (mission.kind
|
|
111
|
+
if (!shapeFor(mission.kind).reach.ownsPaths) continue
|
|
112
112
|
for (const other of openExisting) {
|
|
113
|
-
if (other.kind
|
|
113
|
+
if (!shapeFor(other.kind).reach.ownsPaths) continue
|
|
114
114
|
if (!scopesOverlap(mission.scope, other.scope, { caseInsensitive })) continue
|
|
115
115
|
if (hasDependencyPath(byId, mission.id, other.id)) continue
|
|
116
116
|
addDependency(mission, other.id)
|
|
@@ -10,10 +10,13 @@ import { fullMission, findingSummary } from '../summary.js'
|
|
|
10
10
|
* @param {object} input
|
|
11
11
|
* @param {TowerState} input.state
|
|
12
12
|
* @param {Mission} input.mission
|
|
13
|
+
* @param {Array<Record<string, any>> | Map<string, Record<string, any>>} [input.archived] archive index entries (or a by-id map) so a dependency that has already been archived keeps its evidence
|
|
13
14
|
* @returns {object}
|
|
14
15
|
*/
|
|
15
|
-
export function missionContextView({ state, mission }) {
|
|
16
|
+
export function missionContextView({ state, mission, archived = [] }) {
|
|
16
17
|
const byId = new Map(state.missions.map((entry) => [entry.id, entry]))
|
|
18
|
+
const archivedById =
|
|
19
|
+
archived instanceof Map ? archived : new Map(archived.map((entry) => [entry.id, entry]))
|
|
17
20
|
const branch = mission.worktree ? mission.worktree.branch : null
|
|
18
21
|
const reviews = branch
|
|
19
22
|
? (state.reviews ?? [])
|
|
@@ -46,8 +49,19 @@ export function missionContextView({ state, mission }) {
|
|
|
46
49
|
})),
|
|
47
50
|
findings: findings.map((finding) => findingSummary(finding)),
|
|
48
51
|
deps: (mission.deps ?? []).map((id) => {
|
|
49
|
-
const dep = byId.get(id)
|
|
50
|
-
|
|
52
|
+
const dep = byId.get(id) ?? archivedById.get(id)
|
|
53
|
+
if (!dep) return { id, title: null, status: 'unknown' }
|
|
54
|
+
return {
|
|
55
|
+
id,
|
|
56
|
+
title: dep.title,
|
|
57
|
+
kind: dep.kind,
|
|
58
|
+
status: dep.status,
|
|
59
|
+
readCommit: dep.readCommit ?? null,
|
|
60
|
+
// #148 D6: a survey's completion evidence travels with the dependency
|
|
61
|
+
// context so a dependent build worker sees what was found, not only that
|
|
62
|
+
// the survey finished. Formatting/clipping is the briefing's job.
|
|
63
|
+
result: dep.result ?? null,
|
|
64
|
+
}
|
|
51
65
|
}),
|
|
52
66
|
}
|
|
53
67
|
}
|
package/domain/read/views.js
CHANGED
|
@@ -125,16 +125,20 @@ export function patchLimit(maxBytes) {
|
|
|
125
125
|
* @param {string[]} input.files
|
|
126
126
|
* @param {string[]} input.commits
|
|
127
127
|
* @param {string} input.fullPatch
|
|
128
|
+
* @param {string|null} [input.reviewedCommit]
|
|
128
129
|
* @param {number} [input.maxBytes]
|
|
129
130
|
* @returns {object}
|
|
130
131
|
*/
|
|
131
|
-
export function diffView({ mission, baseTip, branchTip, mergeBase, files, commits, fullPatch, maxBytes }) {
|
|
132
|
+
export function diffView({ mission, baseTip, branchTip, mergeBase, files, commits, fullPatch, maxBytes, reviewedCommit = null }) {
|
|
132
133
|
const limit = patchLimit(maxBytes)
|
|
133
134
|
return {
|
|
134
135
|
missionId: mission.id,
|
|
135
136
|
branch: mission.worktree ? mission.worktree.branch : null,
|
|
136
137
|
baseTip,
|
|
137
138
|
branchTip,
|
|
139
|
+
// #149 W3: a reviewer sees (and is stamped with) one recorded commit; the
|
|
140
|
+
// field is null for every other caller reading the live branch.
|
|
141
|
+
reviewedCommit,
|
|
138
142
|
mergeBase,
|
|
139
143
|
files,
|
|
140
144
|
commits,
|