dsh-tower 1.2.0 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/CHANGELOG.md +96 -0
  2. package/CONTRIBUTING.md +36 -0
  3. package/README.en.md +60 -23
  4. package/README.md +54 -21
  5. package/SECURITY.md +20 -0
  6. package/cordis.patch.yml +32 -0
  7. package/docs/README.md +17 -0
  8. package/docs/architecture/overview.md +70 -0
  9. package/docs/examples/first-mission.md +58 -0
  10. package/docs/faq.md +74 -0
  11. package/docs/guide/hot-reload.md +189 -0
  12. package/docs/guide/install-upgrade-migration.md +59 -0
  13. package/docs/guide/quickstart.md +97 -0
  14. package/docs/operations/runbook.md +80 -0
  15. package/docs/operations/security.md +62 -0
  16. package/docs/reference/blockers.md +58 -0
  17. package/docs/reference/config.md +114 -0
  18. package/docs/reference/doctor.md +31 -0
  19. package/docs/reference/errors.md +50 -0
  20. package/docs/reference/events.md +45 -0
  21. package/docs/reference/operations.md +52 -0
  22. package/docs/reference/testing.md +61 -0
  23. package/docs/reference/tools.md +75 -0
  24. package/docs/zh/README.md +58 -0
  25. package/docs/zh/faq.md +72 -0
  26. package/docs/zh/guide/hot-reload.md +178 -0
  27. package/docs/zh/guide/install-upgrade-migration.md +57 -0
  28. package/docs/zh/guide/quickstart.md +96 -0
  29. package/docs/zh/operations/security.md +42 -0
  30. package/docs/zh/reference/blockers.md +62 -0
  31. package/docs/zh/reference/config.md +113 -0
  32. package/docs/zh/reference/doctor.md +30 -0
  33. package/docs/zh/reference/errors.md +52 -0
  34. package/docs/zh/reference/events.md +46 -0
  35. package/docs/zh/reference/operations.md +48 -0
  36. package/docs/zh/reference/testing.md +60 -0
  37. package/docs/zh/reference/tools.md +75 -0
  38. package/index.js +293 -49
  39. package/lib/batch.js +578 -0
  40. package/lib/config.js +381 -0
  41. package/lib/doctor.js +165 -0
  42. package/lib/errors.js +111 -0
  43. package/lib/hmr.js +196 -0
  44. package/lib/leases.js +317 -0
  45. package/lib/legacy.js +413 -0
  46. package/lib/modeStore.js +47 -16
  47. package/lib/store.js +24 -1
  48. package/lib/tests.js +628 -0
  49. package/lib/tools.js +1594 -160
  50. package/lib/tower.js +1025 -101
  51. package/lib/workspaceLock.js +117 -0
  52. package/lib/worktreeOwnership.js +166 -0
  53. package/package.json +21 -4
  54. package/reviewer.js +40 -0
  55. package/scripts/check-docs.mjs +135 -0
  56. package/scripts/enable-dev-hmr.mjs +347 -0
  57. package/skills/tower-recovery.md +40 -0
  58. package/skills/tower-review.md +16 -0
  59. package/skills/tower-worker-protocol.md +15 -0
  60. package/skills.js +63 -0
  61. package/worker.js +7 -4
package/lib/batch.js ADDED
@@ -0,0 +1,578 @@
1
+ // Batch admission packets (2.1): pure create/close/summarize validation.
2
+ //
3
+ // This module performs no filesystem, git, or subprocess I/O. The tower tool
4
+ // layer reads the worktree, receipt files, and test results, then passes what
5
+ // it observed into `validateClose`. Receipt hashes may be supplied by the
6
+ // caller in `input.receipts`/`input.receiptHashes` (already computed) or bound
7
+ // to a passing check as `{ receiptRef, receiptHash }`; both forms are checked
8
+ // against the batch's frozen `receiptPaths`.
9
+ //
10
+ // Expected state/batch shapes (everything else is ignored):
11
+ // state.missions[] -> { id, status, mergedCommit?, completionReport?, testRuns? }
12
+ // state.batches[] -> { id, status: 'open'|'closed'|'abandoned', missionIds,
13
+ // exclusions, requiredChecks, receiptPaths, receiptHashes? }
14
+ // state.legacySnapshots[] -> { hash, missions: [{ id, status }], pausedIds? }
15
+ // A batch record is the normalized validateCreate result plus `id`, `status`,
16
+ // `createdAt`, and the optional `receiptHashes` expected-hash map.
17
+ //
18
+ // 2.1 rule: validateCreate throws `BatchError` (code `batch-invalid`) for
19
+ // invalid input; validateClose returns blockers instead of throwing for
20
+ // verification/lifecycle failures and only throws for structurally malformed
21
+ // arguments. I/O-bound rules from the Codex batch contract (dirty worktree,
22
+ // branch identity, real HEAD comparison) stay in the tool layer; pass
23
+ // `input.treeTip` to bind recorded check `treeCommit` values to the current
24
+ // HEAD.
25
+ import path from 'node:path'
26
+
27
+ const SHA256_PATTERN = /^[a-f0-9]{64}$/i
28
+ const MAX_MISSIONS = 50
29
+ const MAX_CHECKS = 50
30
+ const MAX_RECEIPTS = 200
31
+ const MAX_EXCLUSIONS = 50
32
+ const PASS_STATUSES = new Set(['passed', 'pass', 'ok', 'success', 'succeeded', 'done', 'green'])
33
+ const FAIL_STATUSES = new Set(['failed', 'fail', 'error', 'errored', 'timeout', 'timed-out', 'blocked'])
34
+ const OPEN_BATCH_STATUSES = new Set(['open', 'created', 'active', 'pending'])
35
+ const CLOSED_BATCH_STATUSES = new Set(['closed', 'abandoned'])
36
+
37
+ /** Stable batch error; add `batch-invalid` to ERROR_CODES to map it directly. */
38
+ export class BatchError extends Error {
39
+ constructor(message) {
40
+ super(message)
41
+ this.name = 'BatchError'
42
+ this.code = 'batch-invalid'
43
+ }
44
+ }
45
+
46
+ function fail(message) {
47
+ throw new BatchError(message)
48
+ }
49
+
50
+ function isPlainObject(value) {
51
+ return value !== null && typeof value === 'object' && !Array.isArray(value)
52
+ }
53
+
54
+ function nonEmptyString(value) {
55
+ return typeof value === 'string' && value.trim().length > 0
56
+ }
57
+
58
+ function readInput(input, camel, snake) {
59
+ if (!isPlainObject(input)) return undefined
60
+ if (input[camel] !== undefined) return input[camel]
61
+ return input[snake]
62
+ }
63
+
64
+ function trimmedName(value, where) {
65
+ if (!nonEmptyString(value)) fail(`${where} must be a non-empty string`)
66
+ return value.trim()
67
+ }
68
+
69
+ function normalizeIdList(value, where, { max = MAX_MISSIONS } = {}) {
70
+ if (!Array.isArray(value)) fail(`${where} must be a non-empty array`)
71
+ if (value.length === 0) fail(`${where} must be a non-empty array`)
72
+ if (value.length > max) fail(`${where} must have at most ${max} entries`)
73
+ const ids = []
74
+ for (const [index, raw] of value.entries()) {
75
+ const id = trimmedName(raw, `${where}[${index}]`)
76
+ if (ids.includes(id)) fail(`${where}[${index}] must not duplicate "${id}"`)
77
+ ids.push(id)
78
+ }
79
+ return ids
80
+ }
81
+
82
+ function normalizeNameList(value, where, { max = MAX_CHECKS, allowEmpty = false } = {}) {
83
+ if (!Array.isArray(value)) fail(`${where} must be an array`)
84
+ if (!allowEmpty && value.length === 0) fail(`${where} must be a non-empty array`)
85
+ if (value.length > max) fail(`${where} must have at most ${max} entries`)
86
+ const names = []
87
+ for (const [index, raw] of value.entries()) {
88
+ const name = trimmedName(raw, `${where}[${index}]`)
89
+ if (names.includes(name)) fail(`${where}[${index}] must not duplicate "${name}"`)
90
+ names.push(name)
91
+ }
92
+ return names
93
+ }
94
+
95
+ /** Normalize one receipt path to `a/b/c`; rejects absolute, parent, glob, and directory paths. */
96
+ function normalizeReceiptPath(value, where) {
97
+ if (!nonEmptyString(value)) fail(`${where} must be a non-empty string`)
98
+ const raw = value.trim()
99
+ if (raw.includes('\0')) fail(`${where} must not contain a NUL byte`)
100
+ if (/[*?]/.test(raw) || /[[\]{}]/.test(raw)) fail(`${where} must not contain wildcard characters`)
101
+ if (path.isAbsolute(raw) || /^[A-Za-z]:[\\/]/.test(raw) || /^[\\/]{2}/.test(raw)) {
102
+ fail(`${where} must be a relative path`)
103
+ }
104
+ if (/[\\/]$/.test(raw)) fail(`${where} must name a file, not a directory`)
105
+ const segments = []
106
+ for (const segment of raw.split(/[\\/]+/)) {
107
+ if (segment.length === 0 || segment === '.') continue
108
+ if (segment === '..') fail(`${where} must not contain ".."`)
109
+ segments.push(segment)
110
+ }
111
+ if (segments.length === 0) fail(`${where} must be a relative file path`)
112
+ return segments.join('/')
113
+ }
114
+
115
+ function normalizeReceiptPathOrNull(value, where) {
116
+ try {
117
+ return normalizeReceiptPath(value, where)
118
+ } catch {
119
+ return null
120
+ }
121
+ }
122
+
123
+ /** Legacy snapshots imported by lib/legacy.js; only paused missions may be excluded. */
124
+ function pausedLegacyIds(state) {
125
+ const ids = new Set()
126
+ const collectMission = (mission) => {
127
+ if (isPlainObject(mission) && nonEmptyString(mission.id) && mission.status === 'paused') {
128
+ ids.add(mission.id.trim())
129
+ }
130
+ }
131
+ const collectList = (value) => {
132
+ if (Array.isArray(value)) value.forEach(collectMission)
133
+ else if (isPlainObject(value)) Object.values(value).forEach(collectMission)
134
+ }
135
+ const snapshots = Array.isArray(state.legacySnapshots) ? state.legacySnapshots : []
136
+ for (const snapshot of snapshots) {
137
+ if (!isPlainObject(snapshot)) continue
138
+ collectList(snapshot.missions)
139
+ if (isPlainObject(snapshot.snapshot)) collectList(snapshot.snapshot.missions)
140
+ if (isPlainObject(snapshot.payload)) collectList(snapshot.payload.missions)
141
+ // lib/legacy.js stores imported snapshots with `pausedMissionIds`; older,
142
+ // hand-written fixtures may use `pausedIds`.
143
+ for (const key of ['pausedIds', 'pausedMissionIds']) {
144
+ if (Array.isArray(snapshot[key])) {
145
+ for (const id of snapshot[key]) if (nonEmptyString(id)) ids.add(id.trim())
146
+ }
147
+ }
148
+ }
149
+ return ids
150
+ }
151
+
152
+ function missionInOpenBatch(state, missionId) {
153
+ const batches = Array.isArray(state.batches) ? state.batches : []
154
+ for (const batch of batches) {
155
+ if (!isPlainObject(batch)) continue
156
+ const status = batch.status ?? 'open'
157
+ if (!OPEN_BATCH_STATUSES.has(status)) continue
158
+ if (Array.isArray(batch.missionIds) && batch.missionIds.includes(missionId)) return batch
159
+ }
160
+ return null
161
+ }
162
+
163
+ function checkNames(entry) {
164
+ if (!isPlainObject(entry)) return []
165
+ for (const candidate of [entry.name, entry.id, entry.check]) {
166
+ if (nonEmptyString(candidate)) return [candidate.trim()]
167
+ }
168
+ // A nameless runDiscovery receipt stored in mission.testRuns is the built-in
169
+ // test tool evidence. It answers to `tests` (the tool-layer auto check name)
170
+ // and to `tower_test` (the synthetic name used by earlier integrations).
171
+ if (Array.isArray(entry.commands) && Array.isArray(entry.tests)) return ['tests', 'tower_test']
172
+ return []
173
+ }
174
+
175
+ function checkOutcome(entry) {
176
+ if (!isPlainObject(entry)) return null
177
+ if (typeof entry.status === 'string') {
178
+ const status = entry.status.trim().toLowerCase()
179
+ if (PASS_STATUSES.has(status)) return true
180
+ if (FAIL_STATUSES.has(status)) return false
181
+ }
182
+ if (typeof entry.ok === 'boolean') return entry.ok
183
+ if (typeof entry.pass === 'boolean') return entry.pass
184
+ if (typeof entry.success === 'boolean') return entry.success
185
+ if (entry.exitCode !== undefined && entry.exitCode !== null) {
186
+ const exitCode = typeof entry.exitCode === 'number' ? entry.exitCode : Number(entry.exitCode)
187
+ if (Number.isFinite(exitCode)) return exitCode === 0
188
+ }
189
+ if (entry.error !== undefined && entry.error !== null && entry.error !== '') return false
190
+ return null
191
+ }
192
+
193
+ /**
194
+ * Find the most authoritative recorded outcome for `name` on one mission:
195
+ * completion report first, then testRuns newest-first, then caller-supplied
196
+ * `extraChecks` (optionally scoped by `missionId`).
197
+ */
198
+ function findCheck(mission, name, extraChecks, missionId) {
199
+ const sources = []
200
+ const reportChecks = Array.isArray(mission?.completionReport?.checks) ? mission.completionReport.checks : []
201
+ for (let index = reportChecks.length - 1; index >= 0; index -= 1) {
202
+ sources.push({ source: 'completionReport', entry: reportChecks[index] })
203
+ }
204
+ const testRuns = Array.isArray(mission?.testRuns) ? mission.testRuns : []
205
+ for (let index = testRuns.length - 1; index >= 0; index -= 1) {
206
+ const run = testRuns[index]
207
+ if (!isPlainObject(run)) continue
208
+ if (Array.isArray(run.checks) && run.checks.length > 0) {
209
+ for (let inner = run.checks.length - 1; inner >= 0; inner -= 1) {
210
+ sources.push({ source: 'testRuns', entry: run.checks[inner] })
211
+ }
212
+ } else {
213
+ sources.push({ source: 'testRuns', entry: run })
214
+ }
215
+ }
216
+ for (let index = extraChecks.length - 1; index >= 0; index -= 1) {
217
+ const entry = extraChecks[index]
218
+ if (!isPlainObject(entry)) continue
219
+ const scope = entry.missionId ?? entry.mission_id
220
+ if (scope !== undefined && scope !== null && String(scope) !== missionId) continue
221
+ sources.push({ source: 'input', entry })
222
+ }
223
+ for (const source of sources) {
224
+ if (!checkNames(source.entry).includes(name)) continue
225
+ const outcome = checkOutcome(source.entry)
226
+ if (outcome === null) continue
227
+ return { outcome, source: source.source, entry: source.entry }
228
+ }
229
+ return null
230
+ }
231
+
232
+ function blocker(message, code = 'batch-invalid') {
233
+ return { code, message }
234
+ }
235
+
236
+ /**
237
+ * Validate `tower_batch create` input and return the normalized, frozen batch
238
+ * payload `{ missionIds, exclusions, requiredChecks, receiptPaths }`.
239
+ * Throws `BatchError` (`batch-invalid`) for unknown/duplicate missions,
240
+ * non-paused exclusions, empty requiredChecks, or unsafe receipt paths.
241
+ * Accepts camelCase keys and snake_case aliases (`mission_ids`, ...).
242
+ */
243
+ export function validateCreate(state, input) {
244
+ if (!isPlainObject(state)) fail('state must be an object')
245
+ if (!isPlainObject(input)) fail('input must be an object')
246
+ const missions = Array.isArray(state.missions) ? state.missions : fail('state.missions must be an array')
247
+ const byId = new Map()
248
+ for (const mission of missions) {
249
+ if (isPlainObject(mission) && nonEmptyString(mission.id)) byId.set(mission.id, mission)
250
+ }
251
+
252
+ const missionIds = normalizeIdList(readInput(input, 'missionIds', 'mission_ids'), 'missionIds')
253
+ for (const missionId of missionIds) {
254
+ if (!byId.has(missionId)) fail(`missionIds must reference known missions: "${missionId}" is unknown`)
255
+ const openBatch = missionInOpenBatch(state, missionId)
256
+ if (openBatch) fail(`mission ${missionId} already belongs to open batch ${openBatch.id ?? '(unnamed)'}`)
257
+ }
258
+
259
+ const exclusionsRaw = readInput(input, 'exclusions', 'exclusions') ?? []
260
+ if (!Array.isArray(exclusionsRaw)) fail('exclusions must be an array')
261
+ if (exclusionsRaw.length > MAX_EXCLUSIONS) fail(`exclusions must have at most ${MAX_EXCLUSIONS} entries`)
262
+ const pausedLegacy = pausedLegacyIds(state)
263
+ const exclusions = []
264
+ for (const [index, raw] of exclusionsRaw.entries()) {
265
+ const exclusion = trimmedName(raw, `exclusions[${index}]`)
266
+ if (exclusions.includes(exclusion)) fail(`exclusions[${index}] must not duplicate "${exclusion}"`)
267
+ if (missionIds.includes(exclusion)) fail(`exclusions[${index}] must not overlap missionIds ("${exclusion}")`)
268
+ if (!pausedLegacy.has(exclusion)) {
269
+ fail(`exclusions[${index}] "${exclusion}" is not a paused legacy mission id`)
270
+ }
271
+ exclusions.push(exclusion)
272
+ }
273
+
274
+ const requiredRaw = readInput(input, 'requiredChecks', 'required_checks')
275
+ const requiredChecks = normalizeNameList(requiredRaw === undefined ? ['check'] : requiredRaw, 'requiredChecks')
276
+
277
+ const receiptPathsRaw = readInput(input, 'receiptPaths', 'receipt_paths') ?? []
278
+ if (!Array.isArray(receiptPathsRaw)) fail('receiptPaths must be an array')
279
+ if (receiptPathsRaw.length > MAX_RECEIPTS) fail(`receiptPaths must have at most ${MAX_RECEIPTS} entries`)
280
+ const receiptPaths = []
281
+ for (const [index, raw] of receiptPathsRaw.entries()) {
282
+ const receiptPath = normalizeReceiptPath(raw, `receiptPaths[${index}]`)
283
+ if (receiptPaths.includes(receiptPath)) fail(`receiptPaths[${index}] must not duplicate "${receiptPath}"`)
284
+ receiptPaths.push(receiptPath)
285
+ }
286
+
287
+ return { missionIds, exclusions, requiredChecks, receiptPaths }
288
+ }
289
+
290
+ function collectReceiptHashes(options) {
291
+ const hashes = new Map()
292
+ const problems = []
293
+ const record = (rawPath, rawValue, where) => {
294
+ const path = normalizeReceiptPathOrNull(rawPath, where)
295
+ if (!path) {
296
+ problems.push(blocker(`${where} is not a safe relative receipt path`))
297
+ return
298
+ }
299
+ const value = isPlainObject(rawValue) ? (rawValue.sha256 ?? rawValue.hash ?? rawValue.receiptHash) : rawValue
300
+ if (!nonEmptyString(value) || !SHA256_PATTERN.test(value.trim())) {
301
+ problems.push(blocker(`${where} hash must be a sha256 hex digest`))
302
+ return
303
+ }
304
+ const normalized = value.trim().toLowerCase()
305
+ if (hashes.has(path) && hashes.get(path) !== normalized) {
306
+ problems.push(blocker(`receipt "${path}" was reported with conflicting hashes`))
307
+ return
308
+ }
309
+ hashes.set(path, normalized)
310
+ }
311
+
312
+ const receiptHashes = readInput(options, 'receiptHashes', 'receipt_hashes')
313
+ if (receiptHashes !== undefined) {
314
+ if (!isPlainObject(receiptHashes)) problems.push(blocker('receiptHashes must be an object of path -> sha256'))
315
+ else {
316
+ for (const [rawPath, rawValue] of Object.entries(receiptHashes)) {
317
+ record(rawPath, rawValue, `receiptHashes["${rawPath}"]`)
318
+ }
319
+ }
320
+ }
321
+ const receipts = readInput(options, 'receipts', 'receipts')
322
+ if (receipts !== undefined) {
323
+ if (!Array.isArray(receipts)) problems.push(blocker('receipts must be an array'))
324
+ else {
325
+ for (const [index, entry] of receipts.entries()) {
326
+ if (!isPlainObject(entry)) {
327
+ problems.push(blocker(`receipts[${index}] must be an object`))
328
+ continue
329
+ }
330
+ const rawPath = entry.path ?? entry.receiptPath ?? entry.receipt_path
331
+ record(rawPath, entry, `receipts[${index}]`)
332
+ }
333
+ }
334
+ }
335
+ return { hashes, problems }
336
+ }
337
+
338
+ function expectedReceiptHashes(batch, options) {
339
+ const expected = new Map()
340
+ const add = (value) => {
341
+ if (!isPlainObject(value)) return
342
+ for (const [rawPath, rawHash] of Object.entries(value)) {
343
+ const path = normalizeReceiptPathOrNull(rawPath, `receiptHashes["${rawPath}"]`)
344
+ if (!path || !nonEmptyString(rawHash) || !SHA256_PATTERN.test(rawHash.trim())) continue
345
+ expected.set(path, rawHash.trim().toLowerCase())
346
+ }
347
+ }
348
+ add(batch.receiptHashes)
349
+ add(readInput(options, 'expectedReceiptHashes', 'expected_receipt_hashes'))
350
+ return expected
351
+ }
352
+
353
+ function bindCheckEvidence(entry, missionId, name, receiptPaths, observedHashes) {
354
+ const problems = []
355
+ const record = {}
356
+ const rawRef = entry.receiptRef ?? entry.receipt_ref
357
+ if (rawRef !== undefined && rawRef !== null) {
358
+ const receiptRef = normalizeReceiptPathOrNull(rawRef, `mission ${missionId} check "${name}" receiptRef`)
359
+ if (!receiptRef) {
360
+ problems.push(blocker(`mission ${missionId} check "${name}" has an unsafe receiptRef`))
361
+ } else if (!receiptPaths.includes(receiptRef)) {
362
+ problems.push(blocker(`mission ${missionId} check "${name}" receiptRef "${receiptRef}" is outside the frozen receiptPaths`))
363
+ } else {
364
+ record.receiptRef = receiptRef
365
+ const rawHash = entry.receiptHash ?? entry.receipt_hash
366
+ if (rawHash !== undefined && rawHash !== null) {
367
+ if (!nonEmptyString(rawHash) || !SHA256_PATTERN.test(rawHash.trim())) {
368
+ problems.push(blocker(`mission ${missionId} check "${name}" receiptHash must be a sha256 hex digest`))
369
+ } else {
370
+ const hash = rawHash.trim().toLowerCase()
371
+ record.receiptHash = hash
372
+ if (observedHashes.has(receiptRef) && observedHashes.get(receiptRef) !== hash) {
373
+ problems.push(blocker(`mission ${missionId} check "${name}" receiptHash does not match the reported receipt "${receiptRef}"`))
374
+ } else {
375
+ observedHashes.set(receiptRef, hash)
376
+ }
377
+ }
378
+ }
379
+ }
380
+ }
381
+ return { problems, record }
382
+ }
383
+
384
+ function checkTreeTip(entry, missionId, name, treeTip) {
385
+ if (!treeTip) return []
386
+ const committed = entry.treeCommit ?? entry.tree_commit ?? entry.treeTip ?? entry.tree_tip
387
+ if (!nonEmptyString(committed)) return []
388
+ if (committed.trim() === treeTip) return []
389
+ return [blocker(`mission ${missionId} check "${name}" was recorded at tree ${committed.trim()}, current HEAD is ${treeTip}`)]
390
+ }
391
+
392
+ /**
393
+ * Validate `tower_batch close`: every batch member must be completed or
394
+ * abandoned; every required check must have passed evidence for every
395
+ * non-abandoned member (completion report checks, mission testRuns, or
396
+ * caller-supplied `input.checks`); frozen receipt paths must be reported with
397
+ * a sha256 digest that matches any recorded expected hash. Returns
398
+ * `{ ok, blockers, checks }`; blockers use the 2.1 codes `tests-missing` and
399
+ * `tests-failed` for check shortfalls and `batch-invalid` for structural
400
+ * problems. Caller-supplied `input.treeTip` also binds check `treeCommit`s.
401
+ */
402
+ export function validateClose(state, batch, input = {}) {
403
+ if (!isPlainObject(state)) fail('state must be an object')
404
+ if (!isPlainObject(batch)) fail('batch must be an object')
405
+ const options = input === undefined || input === null ? {} : input
406
+ if (!isPlainObject(options)) fail('input must be an object')
407
+
408
+ const missionIds = normalizeIdList(batch.missionIds, 'batch.missionIds')
409
+ const requiredChecks = normalizeNameList(batch.requiredChecks, 'batch.requiredChecks')
410
+ const exclusions = batch.exclusions ?? []
411
+ if (!Array.isArray(exclusions)) fail('batch.exclusions must be an array')
412
+ const receiptPathsRaw = batch.receiptPaths ?? []
413
+ if (!Array.isArray(receiptPathsRaw)) fail('batch.receiptPaths must be an array')
414
+ const receiptPaths = receiptPathsRaw.map((value, index) => normalizeReceiptPath(value, `batch.receiptPaths[${index}]`))
415
+
416
+ const blockers = []
417
+ const status = batch.status ?? 'open'
418
+ if (status === 'closed') blockers.push(blocker(`batch ${batch.id ?? ''} is already closed`.trim()))
419
+ else if (status === 'abandoned') blockers.push(blocker(`batch ${batch.id ?? ''} is abandoned`.trim()))
420
+
421
+ const missions = Array.isArray(state.missions) ? state.missions : fail('state.missions must be an array')
422
+ const byId = new Map()
423
+ for (const mission of missions) if (isPlainObject(mission) && nonEmptyString(mission.id)) byId.set(mission.id, mission)
424
+
425
+ const extraChecks = Array.isArray(options.checks) ? options.checks : []
426
+ const treeTip = nonEmptyString(options.treeTip)
427
+ ? options.treeTip.trim()
428
+ : nonEmptyString(options.tree_tip)
429
+ ? options.tree_tip.trim()
430
+ : null
431
+ const observed = collectReceiptHashes(options)
432
+ blockers.push(...observed.problems)
433
+
434
+ const results = []
435
+ for (const missionId of missionIds) {
436
+ const mission = byId.get(missionId)
437
+ if (!mission) {
438
+ blockers.push(blocker(`batch mission "${missionId}" does not exist`))
439
+ continue
440
+ }
441
+ if (mission.status !== 'completed' && mission.status !== 'abandoned') {
442
+ blockers.push(blocker(`mission ${missionId} is ${mission.status}, not completed or abandoned`))
443
+ continue
444
+ }
445
+ for (const name of requiredChecks) {
446
+ if (mission.status === 'abandoned') {
447
+ results.push({ missionId, name, ok: true, outcome: 'skipped', source: 'abandoned' })
448
+ continue
449
+ }
450
+ const found = findCheck(mission, name, extraChecks, missionId)
451
+ if (!found) {
452
+ results.push({ missionId, name, ok: false, outcome: 'missing', source: null })
453
+ blockers.push(blocker(`mission ${missionId} has no recorded check "${name}"`, 'tests-missing'))
454
+ continue
455
+ }
456
+ if (found.outcome !== true) {
457
+ results.push({ missionId, name, ok: false, outcome: 'failed', source: found.source })
458
+ blockers.push(blocker(`mission ${missionId} check "${name}" did not pass`, 'tests-failed'))
459
+ continue
460
+ }
461
+ const evidence = bindCheckEvidence(found.entry, missionId, name, receiptPaths, observed.hashes)
462
+ blockers.push(...evidence.problems)
463
+ blockers.push(...checkTreeTip(found.entry, missionId, name, treeTip))
464
+ results.push({
465
+ missionId,
466
+ name,
467
+ ok: evidence.problems.length === 0,
468
+ outcome: 'passed',
469
+ source: found.source,
470
+ ...evidence.record,
471
+ })
472
+ }
473
+ }
474
+
475
+ const expected = expectedReceiptHashes(batch, options)
476
+ for (const receiptPath of receiptPaths) {
477
+ const hash = observed.hashes.get(receiptPath)
478
+ if (!hash) {
479
+ blockers.push(blocker(`receipt "${receiptPath}" was not reported`))
480
+ continue
481
+ }
482
+ const expectedHash = expected.get(receiptPath)
483
+ if (expectedHash && expectedHash !== hash) {
484
+ blockers.push(blocker(`receipt "${receiptPath}" sha256 does not match the recorded expected hash`))
485
+ }
486
+ }
487
+ for (const receiptPath of observed.hashes.keys()) {
488
+ if (!receiptPaths.includes(receiptPath)) {
489
+ blockers.push(blocker(`receipt "${receiptPath}" is outside the frozen receiptPaths`))
490
+ }
491
+ }
492
+
493
+ return { ok: blockers.length === 0, blockers, checks: results }
494
+ }
495
+
496
+ /**
497
+ * Read-only batch status payload (no I/O): member states, per-check evidence
498
+ * across members, and the checks that still block a close. Receipt coverage is
499
+ * intentionally not evaluated here because hashes are only known at close.
500
+ */
501
+ export function summarize(state, batch) {
502
+ if (!isPlainObject(state)) fail('state must be an object')
503
+ if (!isPlainObject(batch)) fail('batch must be an object')
504
+ const missionIds = normalizeIdList(batch.missionIds, 'batch.missionIds')
505
+ const requiredChecks = normalizeNameList(batch.requiredChecks, 'batch.requiredChecks')
506
+ const receiptPathsRaw = batch.receiptPaths ?? []
507
+ if (!Array.isArray(receiptPathsRaw)) fail('batch.receiptPaths must be an array')
508
+ const receiptPaths = receiptPathsRaw.map((value, index) => normalizeReceiptPath(value, `batch.receiptPaths[${index}]`))
509
+ const exclusions = Array.isArray(batch.exclusions) ? batch.exclusions : []
510
+ const status = batch.status ?? 'open'
511
+
512
+ const missions = Array.isArray(state.missions) ? state.missions : fail('state.missions must be an array')
513
+ const byId = new Map()
514
+ for (const mission of missions) if (isPlainObject(mission) && nonEmptyString(mission.id)) byId.set(mission.id, mission)
515
+
516
+ const blockers = []
517
+ if (CLOSED_BATCH_STATUSES.has(status)) blockers.push(blocker(`batch ${batch.id ?? ''} is already ${status}`.trim()))
518
+ const members = []
519
+ const checks = []
520
+ for (const missionId of missionIds) {
521
+ const mission = byId.get(missionId)
522
+ members.push({
523
+ id: missionId,
524
+ found: Boolean(mission),
525
+ status: mission?.status ?? null,
526
+ mergedCommit: mission?.mergedCommit ?? null,
527
+ abandoned: mission?.status === 'abandoned',
528
+ })
529
+ if (!mission) {
530
+ blockers.push(blocker(`batch mission "${missionId}" does not exist`))
531
+ continue
532
+ }
533
+ if (mission.status !== 'completed' && mission.status !== 'abandoned') {
534
+ blockers.push(blocker(`mission ${missionId} is ${mission.status}, not completed or abandoned`))
535
+ continue
536
+ }
537
+ for (const name of requiredChecks) {
538
+ if (mission.status === 'abandoned') {
539
+ checks.push({ missionId, name, ok: true, outcome: 'skipped', source: 'abandoned' })
540
+ continue
541
+ }
542
+ const found = findCheck(mission, name, [], missionId)
543
+ if (!found) {
544
+ checks.push({ missionId, name, ok: false, outcome: 'missing', source: null })
545
+ continue
546
+ }
547
+ checks.push({
548
+ missionId,
549
+ name,
550
+ ok: found.outcome === true,
551
+ outcome: found.outcome === true ? 'passed' : 'failed',
552
+ source: found.source,
553
+ })
554
+ }
555
+ }
556
+
557
+ const pendingChecks = [...new Set(checks.filter((check) => check.outcome === 'missing').map((check) => check.name))]
558
+ const failedChecks = [...new Set(checks.filter((check) => check.outcome === 'failed').map((check) => check.name))]
559
+ return {
560
+ batchId: batch.id ?? null,
561
+ status,
562
+ closed: status === 'closed',
563
+ abandoned: status === 'abandoned',
564
+ createdAt: batch.createdAt ?? null,
565
+ closedAt: batch.closedAt ?? null,
566
+ missionIds,
567
+ exclusions,
568
+ requiredChecks,
569
+ receiptPaths,
570
+ receiptCount: receiptPaths.length,
571
+ members,
572
+ checks,
573
+ pendingChecks,
574
+ failedChecks,
575
+ blockers,
576
+ ok: blockers.length === 0 && pendingChecks.length === 0 && failedChecks.length === 0,
577
+ }
578
+ }