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
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
// One evidence contract (#149 W4). "Did the work pass checks?" has exactly one
|
|
2
|
+
// answer shape and one reader:
|
|
3
|
+
//
|
|
4
|
+
// * a completion report's `checks` (an assertion the caller recorded), and
|
|
5
|
+
// * a test-discovery receipt in `mission.testRuns` (machine evidence bound to
|
|
6
|
+
// the tree tip the discovery ran on).
|
|
7
|
+
//
|
|
8
|
+
// Every consumer — the merge gate's test blocker, batch close/status, the
|
|
9
|
+
// shape completion invariants and the tools that record evidence — calls this
|
|
10
|
+
// module. The shape declares which sources appertain (`shape.evidence`); the
|
|
11
|
+
// generic layers never read `completionReport.checks` or `mission.testRuns`
|
|
12
|
+
// themselves, and no layer copies a receipt into a completion report.
|
|
13
|
+
//
|
|
14
|
+
// A normalized record is the unit both kinds of evidence share:
|
|
15
|
+
// { names, name, source, ok, command, exitCode, evidence, treeTip, entry }
|
|
16
|
+
//
|
|
17
|
+
// @ts-check
|
|
18
|
+
|
|
19
|
+
/** @typedef {'completionReport' | 'testRuns' | 'input'} EvidenceSource */
|
|
20
|
+
/**
|
|
21
|
+
* @typedef {{
|
|
22
|
+
* completionChecks: boolean,
|
|
23
|
+
* testReceipts: false | 'tip-bound',
|
|
24
|
+
* }} EvidenceContract
|
|
25
|
+
*/
|
|
26
|
+
/**
|
|
27
|
+
* @typedef {{
|
|
28
|
+
* names: string[],
|
|
29
|
+
* name: string,
|
|
30
|
+
* source: EvidenceSource,
|
|
31
|
+
* ok: boolean | null,
|
|
32
|
+
* command: string | null,
|
|
33
|
+
* exitCode: number | null,
|
|
34
|
+
* evidence: string | null,
|
|
35
|
+
* treeTip: string | null,
|
|
36
|
+
* entry: Record<string, any>,
|
|
37
|
+
* }} EvidenceEntry
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
/** Provenance labels reported by every reader. */
|
|
41
|
+
export const EVIDENCE_SOURCE = Object.freeze({
|
|
42
|
+
COMPLETION: /** @type {const} */ ('completionReport'),
|
|
43
|
+
DISCOVERY: /** @type {const} */ ('testRuns'),
|
|
44
|
+
INPUT: /** @type {const} */ ('input'),
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* A nameless discovery receipt answers to both names: `tests` is the name the
|
|
49
|
+
* built-in test tool has always used, `tower_test` the synthetic name older
|
|
50
|
+
* integrations attached to the same receipt.
|
|
51
|
+
*/
|
|
52
|
+
export const TEST_EVIDENCE_NAMES = Object.freeze(['tests', 'tower_test'])
|
|
53
|
+
|
|
54
|
+
/** @param {unknown} value */
|
|
55
|
+
function nonEmptyString(value) {
|
|
56
|
+
return typeof value === 'string' && value.trim().length > 0
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** @param {unknown} value */
|
|
60
|
+
function isPlainObject(value) {
|
|
61
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* @param {Record<string, any>} entry
|
|
66
|
+
* @returns {boolean}
|
|
67
|
+
*/
|
|
68
|
+
function isDiscoveryReceipt(entry) {
|
|
69
|
+
return Array.isArray(entry.commands) && Array.isArray(entry.tests)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* @param {Record<string, any>} entry
|
|
74
|
+
* @returns {string[]}
|
|
75
|
+
*/
|
|
76
|
+
function namesOf(entry) {
|
|
77
|
+
if (isDiscoveryReceipt(entry)) return [...TEST_EVIDENCE_NAMES]
|
|
78
|
+
for (const candidate of [entry.name, entry.id, entry.check]) {
|
|
79
|
+
if (nonEmptyString(candidate)) return [String(candidate).trim()]
|
|
80
|
+
}
|
|
81
|
+
return []
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* @param {Record<string, any>} entry
|
|
86
|
+
* @returns {boolean | null}
|
|
87
|
+
*/
|
|
88
|
+
function outcomeOf(entry) {
|
|
89
|
+
if (typeof entry.status === 'string') {
|
|
90
|
+
const status = entry.status.trim().toLowerCase()
|
|
91
|
+
if (['pass', 'passed', 'ok', 'success', 'succeeded', 'green', 'clean'].includes(status)) return true
|
|
92
|
+
if (['fail', 'failed', 'error', 'failure', 'red', 'not-clean'].includes(status)) return false
|
|
93
|
+
}
|
|
94
|
+
if (typeof entry.ok === 'boolean') return entry.ok
|
|
95
|
+
if (typeof entry.pass === 'boolean') return entry.pass
|
|
96
|
+
if (typeof entry.success === 'boolean') return entry.success
|
|
97
|
+
if (entry.exitCode !== undefined && entry.exitCode !== null) {
|
|
98
|
+
const exitCode = typeof entry.exitCode === 'number' ? entry.exitCode : Number(entry.exitCode)
|
|
99
|
+
if (Number.isFinite(exitCode)) return exitCode === 0
|
|
100
|
+
}
|
|
101
|
+
if (entry.error !== undefined && entry.error !== null && entry.error !== '') return false
|
|
102
|
+
return null
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* @param {Record<string, any>} entry
|
|
107
|
+
* @returns {string | null}
|
|
108
|
+
*/
|
|
109
|
+
function receiptCommand(entry) {
|
|
110
|
+
const commands = Array.isArray(entry.commands) ? entry.commands : []
|
|
111
|
+
const rendered = commands
|
|
112
|
+
.filter((command) => isPlainObject(command))
|
|
113
|
+
.map((command) => `${command.id ?? '?'}: ${command.command ?? '?'}`)
|
|
114
|
+
.join('; ')
|
|
115
|
+
return rendered || null
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* @param {Record<string, any>} entry
|
|
120
|
+
* @returns {number | null}
|
|
121
|
+
*/
|
|
122
|
+
function receiptExitCode(entry) {
|
|
123
|
+
const commands = Array.isArray(entry.commands) ? entry.commands : []
|
|
124
|
+
if (commands.length === 0) return null
|
|
125
|
+
const failed = commands.find((command) => isPlainObject(command) && command.exitCode !== 0)
|
|
126
|
+
if (!failed) return 0
|
|
127
|
+
return typeof failed.exitCode === 'number' ? failed.exitCode : 1
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* @param {Record<string, any>} entry
|
|
132
|
+
* @returns {string | null}
|
|
133
|
+
*/
|
|
134
|
+
function receiptEvidence(entry) {
|
|
135
|
+
if (entry.ok === true) {
|
|
136
|
+
const count = Array.isArray(entry.tests) ? entry.tests.length : 0
|
|
137
|
+
const tip = String(entry.treeTip ?? '').slice(0, 12)
|
|
138
|
+
return `${count} test(s) passed${tip ? ` at ${tip}` : ''}; receipt ${entry.receiptPath ?? entry.diagnosticsPath ?? '(none)'}`
|
|
139
|
+
}
|
|
140
|
+
return `test discovery failed: ${String(entry.error ?? 'unknown').slice(0, 300)}`
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Normalize one evidence source entry. Returns null for an entry that names no
|
|
145
|
+
* check and for a non-object, so a reader never silently invents evidence.
|
|
146
|
+
* @param {unknown} raw
|
|
147
|
+
* @param {EvidenceSource} source
|
|
148
|
+
* @returns {EvidenceEntry | null}
|
|
149
|
+
*/
|
|
150
|
+
export function normalizeEvidence(raw, source) {
|
|
151
|
+
if (!isPlainObject(raw)) return null
|
|
152
|
+
const entry = /** @type {Record<string, any>} */ (raw)
|
|
153
|
+
const names = namesOf(entry)
|
|
154
|
+
if (names.length === 0) return null
|
|
155
|
+
const receipt = isDiscoveryReceipt(entry)
|
|
156
|
+
return {
|
|
157
|
+
names,
|
|
158
|
+
name: names[0],
|
|
159
|
+
source,
|
|
160
|
+
ok: outcomeOf(entry),
|
|
161
|
+
command: receipt ? receiptCommand(entry) : nonEmptyString(entry.command) ? String(entry.command) : null,
|
|
162
|
+
exitCode: receipt
|
|
163
|
+
? receiptExitCode(entry)
|
|
164
|
+
: typeof entry.exitCode === 'number'
|
|
165
|
+
? entry.exitCode
|
|
166
|
+
: null,
|
|
167
|
+
evidence: receipt
|
|
168
|
+
? receiptEvidence(entry)
|
|
169
|
+
: nonEmptyString(entry.evidence)
|
|
170
|
+
? String(entry.evidence)
|
|
171
|
+
: null,
|
|
172
|
+
treeTip: nonEmptyString(entry.treeTip) ? String(entry.treeTip) : null,
|
|
173
|
+
entry,
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Every evidence record for one mission, newest first within a source:
|
|
179
|
+
* completion checks, then discovery receipts (when the shape accepts them),
|
|
180
|
+
* then caller-supplied close-time checks.
|
|
181
|
+
* @param {EvidenceContract} contract
|
|
182
|
+
* @param {Record<string, any> | null | undefined} mission
|
|
183
|
+
* @param {{ extraChecks?: any[], missionId?: string }} [options]
|
|
184
|
+
* @returns {EvidenceEntry[]}
|
|
185
|
+
*/
|
|
186
|
+
export function evidenceEntries(contract, mission, { extraChecks = [], missionId = mission?.id } = {}) {
|
|
187
|
+
/** @type {EvidenceEntry[]} */
|
|
188
|
+
const records = []
|
|
189
|
+
if (contract?.completionChecks !== false) {
|
|
190
|
+
const checks = Array.isArray(mission?.completionReport?.checks) ? mission.completionReport.checks : []
|
|
191
|
+
for (let index = checks.length - 1; index >= 0; index -= 1) {
|
|
192
|
+
const record = normalizeEvidence(checks[index], EVIDENCE_SOURCE.COMPLETION)
|
|
193
|
+
if (record) records.push(record)
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
if (contract?.testReceipts) {
|
|
197
|
+
const runs = Array.isArray(mission?.testRuns) ? mission.testRuns : []
|
|
198
|
+
for (let index = runs.length - 1; index >= 0; index -= 1) {
|
|
199
|
+
const run = runs[index]
|
|
200
|
+
if (!isPlainObject(run)) continue
|
|
201
|
+
const nested = Array.isArray(run.checks) && run.checks.length > 0
|
|
202
|
+
if (nested) {
|
|
203
|
+
for (let inner = run.checks.length - 1; inner >= 0; inner -= 1) {
|
|
204
|
+
const record = normalizeEvidence(run.checks[inner], EVIDENCE_SOURCE.DISCOVERY)
|
|
205
|
+
if (record) records.push(record)
|
|
206
|
+
}
|
|
207
|
+
} else {
|
|
208
|
+
const record = normalizeEvidence(run, EVIDENCE_SOURCE.DISCOVERY)
|
|
209
|
+
if (record) records.push(record)
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
for (let index = extraChecks.length - 1; index >= 0; index -= 1) {
|
|
214
|
+
const entry = extraChecks[index]
|
|
215
|
+
if (!isPlainObject(entry)) continue
|
|
216
|
+
const scope = entry.missionId ?? entry.mission_id
|
|
217
|
+
if (scope !== undefined && scope !== null && String(scope) !== missionId) continue
|
|
218
|
+
const record = normalizeEvidence(entry, EVIDENCE_SOURCE.INPUT)
|
|
219
|
+
if (record) records.push(record)
|
|
220
|
+
}
|
|
221
|
+
return records
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* The most authoritative recorded outcome for one check name. Completion
|
|
226
|
+
* checks win over discovery receipts, which win over caller-supplied checks;
|
|
227
|
+
* within a source the newest entry wins.
|
|
228
|
+
* @param {EvidenceContract} contract
|
|
229
|
+
* @param {Record<string, any> | null | undefined} mission
|
|
230
|
+
* @param {string} name
|
|
231
|
+
* @param {{ extraChecks?: any[], missionId?: string }} [options]
|
|
232
|
+
* @returns {EvidenceEntry | null}
|
|
233
|
+
*/
|
|
234
|
+
export function findEvidence(contract, mission, name, options = {}) {
|
|
235
|
+
for (const record of evidenceEntries(contract, mission, options)) {
|
|
236
|
+
if (!record.names.includes(name)) continue
|
|
237
|
+
if (record.ok === null) continue
|
|
238
|
+
return record
|
|
239
|
+
}
|
|
240
|
+
return null
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* The passing discovery receipt bound to `branchTip`, or null. A receipt is
|
|
245
|
+
* evidence only for the tree it ran on, so a receipt recorded at another tip
|
|
246
|
+
* (or no tip) never satisfies the merge gate.
|
|
247
|
+
* @param {EvidenceContract} contract
|
|
248
|
+
* @param {Record<string, any> | null | undefined} mission
|
|
249
|
+
* @param {string | null | undefined} branchTip
|
|
250
|
+
* @returns {EvidenceEntry | null}
|
|
251
|
+
*/
|
|
252
|
+
export function passingDiscovery(contract, mission, branchTip) {
|
|
253
|
+
if (contract?.testReceipts !== 'tip-bound') return null
|
|
254
|
+
if (!nonEmptyString(branchTip)) return null
|
|
255
|
+
const tip = String(branchTip)
|
|
256
|
+
return (
|
|
257
|
+
evidenceEntries(contract, mission).find(
|
|
258
|
+
(record) => record.source === EVIDENCE_SOURCE.DISCOVERY && record.ok === true && record.treeTip === tip,
|
|
259
|
+
) ?? null
|
|
260
|
+
)
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Whether a mission has any recorded check evidence. `gates.requireChecks`
|
|
265
|
+
* asks this question; a discovery receipt answers it because a receipt is
|
|
266
|
+
* stronger evidence than a hand-written check line.
|
|
267
|
+
* @param {EvidenceContract} contract
|
|
268
|
+
* @param {Record<string, any> | null | undefined} mission
|
|
269
|
+
* @returns {boolean}
|
|
270
|
+
*/
|
|
271
|
+
export function hasCheckEvidence(contract, mission) {
|
|
272
|
+
return evidenceEntries(contract, mission).length > 0
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Whether the mission has recorded test-discovery receipts. Diagnostics use
|
|
277
|
+
* this instead of reading `mission.testRuns` themselves.
|
|
278
|
+
* @param {Record<string, any> | null | undefined} mission
|
|
279
|
+
* @returns {boolean}
|
|
280
|
+
*/
|
|
281
|
+
export function hasTestReceipts(mission) {
|
|
282
|
+
return Array.isArray(mission?.testRuns) && mission.testRuns.length > 0
|
|
283
|
+
}
|
package/domain/gate.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// Pure completion gate checks (#49). No repository I/O.
|
|
2
2
|
// @ts-check
|
|
3
|
-
import {
|
|
3
|
+
import { shapeFor } from './shapes/index.js'
|
|
4
|
+
import { passingDiscovery } from './evidence.js'
|
|
4
5
|
import { isReviewExempt, reviewExemptionInvalid, reviewRequired } from './integration/review.js'
|
|
5
6
|
|
|
6
7
|
/** @typedef {import('../types/index.js').Blocker} Blocker */
|
|
@@ -10,39 +11,15 @@ import { isReviewExempt, reviewExemptionInvalid, reviewRequired } from './integr
|
|
|
10
11
|
/** @typedef {import('../types/index.js').TowerConfig} TowerConfig */
|
|
11
12
|
|
|
12
13
|
/**
|
|
14
|
+
* Pre-merge completion blockers. The shape owns the vocabulary: a survey is
|
|
15
|
+
* terminal when completed and is never merged, so it has no
|
|
16
|
+
* "closed mission owns a branch" blocker.
|
|
13
17
|
* @param {Mission} mission
|
|
14
18
|
* @param {TowerConfig} [config]
|
|
15
19
|
* @returns {Blocker[]}
|
|
16
20
|
*/
|
|
17
21
|
export function completionBlockers(mission, config = {}) {
|
|
18
|
-
|
|
19
|
-
const blockers = []
|
|
20
|
-
/** @type {(code: string, detail: string) => void} */
|
|
21
|
-
const push = (code, detail) => {
|
|
22
|
-
blockers.push({ code, detail })
|
|
23
|
-
}
|
|
24
|
-
if (isTerminal(mission.status)) {
|
|
25
|
-
push(
|
|
26
|
-
'branch-owned-by-closed-missions',
|
|
27
|
-
`mission ${mission.id} is ${mission.status}; a merge never flips a closed mission — re-plan the work under a new title`,
|
|
28
|
-
)
|
|
29
|
-
}
|
|
30
|
-
if (mission.status !== 'completed') {
|
|
31
|
-
push('mission-not-completed', `mission ${mission.id} is ${mission.status}; completion is required`)
|
|
32
|
-
}
|
|
33
|
-
const openTasks = (mission.tasks ?? []).filter((task) => task.status === 'open')
|
|
34
|
-
if (openTasks.length > 0) {
|
|
35
|
-
push('tasks-open', `${openTasks.length} open task(s): ${openTasks.map((task) => task.text).join('; ')}`)
|
|
36
|
-
}
|
|
37
|
-
const presentBlockers = mission.blockers ?? []
|
|
38
|
-
if (presentBlockers.length > 0) push('blockers-present', presentBlockers.join('; '))
|
|
39
|
-
if (config.gates?.requireChecks === true) {
|
|
40
|
-
const checks = mission.completionReport?.checks ?? []
|
|
41
|
-
if (checks.length === 0) {
|
|
42
|
-
push('no-check-evidence', `mission ${mission.id} has no completion checks; gates.requireChecks is enabled`)
|
|
43
|
-
}
|
|
44
|
-
}
|
|
45
|
-
return blockers
|
|
22
|
+
return shapeFor(mission.kind).completionBlockers(mission, config)
|
|
46
23
|
}
|
|
47
24
|
|
|
48
25
|
/**
|
|
@@ -112,8 +89,11 @@ export function mergeGateBlockers({
|
|
|
112
89
|
push('worker-stale', `stale worker(s) for ${mission.id}: ${staleWorkers.map((row) => row.name).join(', ')}; adopt or interrupt before merge`)
|
|
113
90
|
}
|
|
114
91
|
if (config.gates?.requireTests === true) {
|
|
115
|
-
|
|
116
|
-
|
|
92
|
+
// #149 W4: the receipt question is answered by the one evidence reader, not
|
|
93
|
+
// by a second filter here. The shape decides whether a receipt appertains;
|
|
94
|
+
// a receipt is evidence only for the tip it ran on.
|
|
95
|
+
const receipt = passingDiscovery(shapeFor(mission.kind).evidence, mission, branchTip)
|
|
96
|
+
if (!receipt) {
|
|
117
97
|
// #140: name the component cause when the row is off. Without this the
|
|
118
98
|
// blocker reads as a repository mistake ("no passing discovery") when the
|
|
119
99
|
// real cause is a disabled row and no tower_test tool exists to produce a
|
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
// facts the app already read (tips, changed files, checkout branch). The app
|
|
3
3
|
// performs every read and owns the result assembly.
|
|
4
4
|
// @ts-check
|
|
5
|
-
import {
|
|
5
|
+
import { scopeFenceBlocker } from '../scope.js'
|
|
6
|
+
import { shapeFor } from '../shapes/index.js'
|
|
6
7
|
|
|
7
8
|
/** @typedef {import('../../types/index.js').Blocker} Blocker */
|
|
8
9
|
/** @typedef {import('../../types/index.js').Mission} Mission */
|
|
@@ -58,21 +59,9 @@ export function staleBaseBlocker({ branch, base, baseTip }) {
|
|
|
58
59
|
}
|
|
59
60
|
|
|
60
61
|
/**
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
*
|
|
64
|
-
* @returns {Blocker | null}
|
|
65
|
-
*/
|
|
66
|
-
export function surveyChangedFilesBlocker({ missionId, changedFiles }) {
|
|
67
|
-
return changedFiles.length > 0
|
|
68
|
-
? {
|
|
69
|
-
code: 'read-only-survey',
|
|
70
|
-
detail: `survey ${missionId} is read-only but ${changedFiles.length} file(s) changed: ${changedFiles.slice(0, 5).join(', ')}`,
|
|
71
|
-
}
|
|
72
|
-
: null
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
/**
|
|
62
|
+
* The merge gate's scope fence. The shape (via `reach`) owns the permission
|
|
63
|
+
* question; this enforcer only supplies the changed files and the checkout's
|
|
64
|
+
* comparison mode.
|
|
76
65
|
* @param {object} input
|
|
77
66
|
* @param {Mission} input.mission
|
|
78
67
|
* @param {string[]} input.changedFiles
|
|
@@ -80,25 +69,12 @@ export function surveyChangedFilesBlocker({ missionId, changedFiles }) {
|
|
|
80
69
|
* @returns {Blocker | null}
|
|
81
70
|
*/
|
|
82
71
|
export function outOfScopeBlocker({ mission, changedFiles, caseInsensitive = false }) {
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
if (!caseInsensitive) {
|
|
90
|
-
const hints = outOfScope
|
|
91
|
-
.map((file) => {
|
|
92
|
-
const pattern = caseInsensitiveScopeMatch(mission.scope, file)
|
|
93
|
-
return pattern
|
|
94
|
-
? `${file} matches "${pattern}" only when case is folded; this checkout is case-sensitive`
|
|
95
|
-
: null
|
|
96
|
-
})
|
|
97
|
-
.filter(Boolean)
|
|
98
|
-
.slice(0, 3)
|
|
99
|
-
if (hints.length > 0) parts.push(`case mismatch: ${hints.join('; ')}`)
|
|
100
|
-
}
|
|
101
|
-
return { code: 'out-of-scope', detail: parts.join('. ') }
|
|
72
|
+
return scopeFenceBlocker({
|
|
73
|
+
mission,
|
|
74
|
+
reach: shapeFor(mission.kind).reach,
|
|
75
|
+
changedFiles,
|
|
76
|
+
caseInsensitive,
|
|
77
|
+
})
|
|
102
78
|
}
|
|
103
79
|
|
|
104
80
|
/**
|
|
@@ -124,18 +100,16 @@ export function baseDirtyBlocker(wouldOverwrite) {
|
|
|
124
100
|
}
|
|
125
101
|
|
|
126
102
|
/**
|
|
127
|
-
* Whether a dependency already satisfies the gate.
|
|
128
|
-
* merged (or abandoned),
|
|
129
|
-
*
|
|
130
|
-
*
|
|
131
|
-
* the same decision.
|
|
103
|
+
* Whether a dependency already satisfies the gate. Each shape owns the answer:
|
|
104
|
+
* a build dependency must be merged (or abandoned), a survey dependency is
|
|
105
|
+
* terminal-success at `completed` (it has no branch to merge). Archived entries
|
|
106
|
+
* carry `kind` for the same decision.
|
|
132
107
|
* @param {{ kind?: string, status?: string } | null | undefined} dep
|
|
133
108
|
* @returns {boolean}
|
|
134
109
|
*/
|
|
135
110
|
export function dependencySatisfied(dep) {
|
|
136
|
-
if (!dep) return false
|
|
137
|
-
|
|
138
|
-
return dep.kind === 'survey' && dep.status === 'completed'
|
|
111
|
+
if (!dep || typeof dep.status !== 'string') return false
|
|
112
|
+
return shapeFor(dep.kind).dependencySatisfied(/** @type {any} */ (dep))
|
|
139
113
|
}
|
|
140
114
|
|
|
141
115
|
/**
|
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
// policy, snapshot decisions, and the initial/existing result projections. All
|
|
3
3
|
// fs/git work and state persistence stay in app/init.js.
|
|
4
4
|
// @ts-check
|
|
5
|
-
import { SCHEMA_VERSION
|
|
5
|
+
import { SCHEMA_VERSION } from '../mission.js'
|
|
6
|
+
import { shapeFor } from '../shapes/index.js'
|
|
6
7
|
|
|
7
8
|
/** @typedef {import('../../types/index.js').Mission} Mission */
|
|
8
9
|
/** @typedef {import('../../types/index.js').TowerState} TowerState */
|
|
@@ -72,7 +73,7 @@ export function claimCaller(owner, caller) {
|
|
|
72
73
|
* @returns {string | null} refusal message, or null when reanchor may proceed
|
|
73
74
|
*/
|
|
74
75
|
export function reanchorBlockedMessage(missions) {
|
|
75
|
-
const active = missions.filter((mission) => !
|
|
76
|
+
const active = missions.filter((mission) => !shapeFor(mission.kind).isTerminal(mission.status))
|
|
76
77
|
return active.length > 0 ? `cannot reanchor base while ${active.length} mission(s) are not merged or abandoned` : null
|
|
77
78
|
}
|
|
78
79
|
|
|
@@ -69,6 +69,18 @@ export function worktreePath(root, missionId) {
|
|
|
69
69
|
return path.join(root, missionId)
|
|
70
70
|
}
|
|
71
71
|
|
|
72
|
+
/**
|
|
73
|
+
* The disposable detached checkout a `survey.sandbox: 'detached'` survey reads
|
|
74
|
+
* in (#148 W2). The `-sandbox` suffix keeps it distinct from a build worktree
|
|
75
|
+
* with the same mission id and from the mission branch naming scheme.
|
|
76
|
+
* @param {string} root
|
|
77
|
+
* @param {string} missionId
|
|
78
|
+
* @returns {string}
|
|
79
|
+
*/
|
|
80
|
+
export function sandboxPath(root, missionId) {
|
|
81
|
+
return path.join(root, `${missionId}-sandbox`)
|
|
82
|
+
}
|
|
83
|
+
|
|
72
84
|
/**
|
|
73
85
|
* @param {unknown} missionId
|
|
74
86
|
* @returns {boolean}
|
|
@@ -114,6 +126,45 @@ export function createdWorktreeResult(mission, worktree) {
|
|
|
114
126
|
return { mission, worktree, created: true }
|
|
115
127
|
}
|
|
116
128
|
|
|
129
|
+
/**
|
|
130
|
+
* A shared-checkout shape (survey) provisions no branch and no worktree: the
|
|
131
|
+
* agent reads the recorded commit in the main checkout.
|
|
132
|
+
* @param {Mission} mission
|
|
133
|
+
* @param {string} cwd
|
|
134
|
+
* @returns {{ mission: Mission, worktree: null, cwd: string, created: false, provisioning: 'shared' }}
|
|
135
|
+
*/
|
|
136
|
+
export function sharedProvisionResult(mission, cwd) {
|
|
137
|
+
return { mission, worktree: null, cwd, created: false, provisioning: /** @type {const} */ ('shared') }
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* A detached survey result (#148 W2). The mission has no worktree projection:
|
|
142
|
+
* the sandbox path is recorded as `mission.sandbox` and is the agent's cwd.
|
|
143
|
+
* @param {Mission} mission
|
|
144
|
+
* @param {string} sandbox
|
|
145
|
+
* @param {boolean} created
|
|
146
|
+
* @returns {{ mission: Mission, worktree: null, sandbox: string, cwd: string, created: boolean, provisioning: 'detached' }}
|
|
147
|
+
*/
|
|
148
|
+
export function detachedSandboxResult(mission, sandbox, created) {
|
|
149
|
+
return {
|
|
150
|
+
mission,
|
|
151
|
+
worktree: null,
|
|
152
|
+
sandbox,
|
|
153
|
+
cwd: sandbox,
|
|
154
|
+
created,
|
|
155
|
+
provisioning: /** @type {const} */ ('detached'),
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* @param {string} sandbox
|
|
161
|
+
* @param {string} readCommit
|
|
162
|
+
* @returns {string}
|
|
163
|
+
*/
|
|
164
|
+
export function sandboxActivityText(sandbox, readCommit) {
|
|
165
|
+
return `detached sandbox at ${sandbox} (read ${String(readCommit).slice(0, 12)})`
|
|
166
|
+
}
|
|
167
|
+
|
|
117
168
|
/**
|
|
118
169
|
* @param {string} branch
|
|
119
170
|
* @param {string} wtPath
|
|
@@ -155,13 +155,28 @@ export function buildRebaseIntent({ token, missionId, base, oldTip, baseTip, com
|
|
|
155
155
|
return { token, missionId, base, oldTip, baseTip, commits, startedAt }
|
|
156
156
|
}
|
|
157
157
|
|
|
158
|
+
/**
|
|
159
|
+
* Prefix that identifies the blocker a rebase conflict records. A successful
|
|
160
|
+
* retry resolves exactly that blocker; unrelated blockers keep the mission
|
|
161
|
+
* blocked.
|
|
162
|
+
*/
|
|
163
|
+
export const REBASE_CONFLICT_PREFIX = 'rebase conflict onto '
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* @param {string} blocker
|
|
167
|
+
* @returns {boolean}
|
|
168
|
+
*/
|
|
169
|
+
export function isRebaseConflictBlocker(blocker) {
|
|
170
|
+
return typeof blocker === 'string' && blocker.startsWith(REBASE_CONFLICT_PREFIX)
|
|
171
|
+
}
|
|
172
|
+
|
|
158
173
|
/**
|
|
159
174
|
* @param {string} base
|
|
160
175
|
* @param {string} baseTip
|
|
161
176
|
* @returns {string}
|
|
162
177
|
*/
|
|
163
178
|
export function rebaseConflictNote(base, baseTip) {
|
|
164
|
-
return
|
|
179
|
+
return `${REBASE_CONFLICT_PREFIX}${base} @ ${baseTip.slice(0, 12)}`
|
|
165
180
|
}
|
|
166
181
|
|
|
167
182
|
/**
|
package/domain/mission.js
CHANGED
|
@@ -1,4 +1,8 @@
|
|
|
1
|
-
// Pure mission vocabulary
|
|
1
|
+
// Pure mission vocabulary (#49). No fs, git, or config.
|
|
2
|
+
//
|
|
3
|
+
// Per-kind status policy lives in `./shapes/` (#149): this module keeps only the
|
|
4
|
+
// vocabulary every kind shares. `isTerminal(kind, status)` and
|
|
5
|
+
// `canTransition(kind, from, to)` are exported from `./shapes/index.js`.
|
|
2
6
|
// @ts-check
|
|
3
7
|
|
|
4
8
|
/** @typedef {import('../types/index.js').FindingSeverity} FindingSeverity */
|
|
@@ -7,9 +11,17 @@
|
|
|
7
11
|
/** @typedef {import('../types/index.js').MergeVerdict} MergeVerdict */
|
|
8
12
|
/** @typedef {import('../types/index.js').MissionStatus} MissionStatus */
|
|
9
13
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
14
|
+
import { SHAPE_KINDS, shapeFor } from './shapes/index.js'
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The union of every shape's status vocabulary, in registry order. A build
|
|
18
|
+
* mission can be `merged`; a survey cannot, but the union names the word.
|
|
19
|
+
* @type {readonly MissionStatus[]}
|
|
20
|
+
*/
|
|
21
|
+
export const MISSION_STATUSES = /** @type {import('../types/index.js').MissionStatus[]} */ (
|
|
22
|
+
[...new Set(SHAPE_KINDS.flatMap((kind) => shapeFor(kind).statuses))]
|
|
23
|
+
)
|
|
24
|
+
export const SCHEMA_VERSION = 5
|
|
13
25
|
/** @type {readonly MergeVerdict[]} */
|
|
14
26
|
export const MERGE_VERDICTS = ['merge', 'fix-then-merge', 'hold']
|
|
15
27
|
// #115: the review status vocabulary is one runtime source. The validator
|
|
@@ -24,8 +36,6 @@ export const REVIEW_STATUSES = [
|
|
|
24
36
|
...REVIEW_STATUS_PRIORITIES.flatMap((priority) => REVIEW_STATUS_ITEMS.map((count) => `${priority}-${count}items`)),
|
|
25
37
|
]
|
|
26
38
|
export const REVIEW_STATUS_RE = new RegExp(`^(?:clean|(?:${REVIEW_STATUS_PRIORITIES.join('|')})-\\d+items)$`)
|
|
27
|
-
/** @type {ReadonlySet<string>} */
|
|
28
|
-
export const TERMINAL_STATUSES = new Set(['merged', 'abandoned'])
|
|
29
39
|
/** @type {readonly FindingType[]} */
|
|
30
40
|
export const FINDING_TYPES = ['bug', 'improve', 'vuln', 'idea']
|
|
31
41
|
/** @type {readonly FindingSeverity[]} */
|
|
@@ -41,33 +51,6 @@ export const FINDING_TRANSITIONS = {
|
|
|
41
51
|
}
|
|
42
52
|
/** @type {readonly string[]} */
|
|
43
53
|
export const PAUSE_CAUSES = ['user', 'failure', 'resource', 'dependency']
|
|
44
|
-
/** @type {Record<string, readonly string[] | undefined>} */
|
|
45
|
-
export const TRANSITIONS = {
|
|
46
|
-
planned: ['active', 'abandoned'],
|
|
47
|
-
active: ['completed', 'blocked', 'paused', 'abandoned'],
|
|
48
|
-
blocked: ['active', 'paused', 'completed', 'abandoned'],
|
|
49
|
-
paused: ['active', 'abandoned'],
|
|
50
|
-
completed: ['active', 'abandoned'],
|
|
51
|
-
merged: [],
|
|
52
|
-
abandoned: [],
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
/**
|
|
56
|
-
* @param {string} status
|
|
57
|
-
* @returns {boolean}
|
|
58
|
-
*/
|
|
59
|
-
export function isTerminal(status) {
|
|
60
|
-
return TERMINAL_STATUSES.has(status)
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
/**
|
|
64
|
-
* @param {string} from
|
|
65
|
-
* @param {string} to
|
|
66
|
-
* @returns {boolean}
|
|
67
|
-
*/
|
|
68
|
-
export function canTransition(from, to) {
|
|
69
|
-
return (TRANSITIONS[from] ?? []).includes(to)
|
|
70
|
-
}
|
|
71
54
|
|
|
72
55
|
/**
|
|
73
56
|
* @param {string | null | undefined} [value]
|