dsh-tower 2.5.0 → 2.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (142) hide show
  1. package/CHANGELOG.md +114 -0
  2. package/COMPATIBILITY.md +2 -0
  3. package/CONTRIBUTING.md +1 -1
  4. package/GLOSSARY.md +6 -0
  5. package/PRINCIPLES.md +15 -7
  6. package/README.en.md +18 -16
  7. package/README.md +18 -11
  8. package/adapters/leases.js +20 -6
  9. package/adapters/legacy.js +149 -37
  10. package/adapters/stateFile.js +11 -1
  11. package/app/archive.js +30 -5
  12. package/app/briefing.js +95 -12
  13. package/app/checkCompletion.js +107 -18
  14. package/app/enrichedMissions.js +1 -1
  15. package/app/freshness.js +77 -0
  16. package/app/gate.js +26 -12
  17. package/app/merge.js +7 -4
  18. package/app/mission.js +24 -1
  19. package/app/missionContext.js +2 -1
  20. package/app/plan.js +64 -1
  21. package/app/provision.js +133 -22
  22. package/app/read.js +38 -5
  23. package/app/rebase.js +10 -8
  24. package/app/review.js +104 -61
  25. package/app/reviewCheckout.js +133 -0
  26. package/app/sandbox.js +121 -0
  27. package/app/sharedCheckout.js +90 -0
  28. package/app/spawn-prechecks.js +31 -20
  29. package/app/spawn-support.js +59 -8
  30. package/app/spawn.js +3 -0
  31. package/app/teardown.js +119 -2
  32. package/app/tower.js +34 -6
  33. package/client.js +321 -0
  34. package/cordis.patch.yml +55 -3
  35. package/docs/adr/0005-component-surface.md +58 -13
  36. package/docs/adr/0006-mission-shapes.md +306 -0
  37. package/docs/architecture/overview.md +18 -10
  38. package/docs/examples/first-mission.md +3 -2
  39. package/docs/faq.md +9 -7
  40. package/docs/guide/install-upgrade-migration.md +58 -7
  41. package/docs/guide/quickstart.md +20 -10
  42. package/docs/guide/scenarios-and-philosophy.md +35 -28
  43. package/docs/operations/runbook.md +1 -1
  44. package/docs/operations/security.md +11 -10
  45. package/docs/reference/blockers.md +20 -11
  46. package/docs/reference/component-surface.md +20 -8
  47. package/docs/reference/config.generated.md +8 -5
  48. package/docs/reference/config.md +30 -5
  49. package/docs/reference/doctor.md +7 -7
  50. package/docs/reference/errors.generated.md +1 -0
  51. package/docs/reference/errors.md +2 -1
  52. package/docs/reference/operations.md +1 -1
  53. package/docs/reference/shapes.md +123 -0
  54. package/docs/reference/surface-budget.md +53 -4
  55. package/docs/reference/testing.md +5 -3
  56. package/docs/reference/tools.generated.md +7 -7
  57. package/docs/reference/tools.md +5 -5
  58. package/docs/zh/faq.md +7 -5
  59. package/docs/zh/guide/install-upgrade-migration.md +25 -6
  60. package/docs/zh/guide/quickstart.md +17 -10
  61. package/docs/zh/guide/scenarios-and-philosophy.md +23 -15
  62. package/docs/zh/operations/security.md +9 -8
  63. package/docs/zh/reference/blockers.md +13 -9
  64. package/docs/zh/reference/config.md +22 -5
  65. package/docs/zh/reference/errors.md +2 -1
  66. package/docs/zh/reference/testing.md +5 -4
  67. package/docs/zh/reference/tools.md +3 -3
  68. package/domain/batch.js +75 -105
  69. package/domain/collab/batch.js +30 -10
  70. package/domain/collab/spawn.js +53 -16
  71. package/domain/components.js +12 -4
  72. package/domain/config/schema.js +87 -22
  73. package/domain/contracts/outputs.js +2 -1
  74. package/domain/contracts/tool-inputs/missions.js +10 -2
  75. package/domain/errors.js +6 -0
  76. package/domain/evidence.js +283 -0
  77. package/domain/gate.js +17 -34
  78. package/domain/integration/gate.js +17 -43
  79. package/domain/integration/init.js +3 -2
  80. package/domain/integration/provision.js +51 -0
  81. package/domain/integration/rebase.js +16 -1
  82. package/domain/integration/review.js +203 -9
  83. package/domain/mission.js +16 -33
  84. package/domain/missions/completion.js +46 -8
  85. package/domain/missions/findings.js +2 -1
  86. package/domain/missions/lifecycle.js +131 -35
  87. package/domain/missions/plan.js +30 -6
  88. package/domain/missions/review.js +2 -18
  89. package/domain/planning.js +5 -5
  90. package/domain/read/missionContext.js +17 -3
  91. package/domain/read/views.js +5 -1
  92. package/domain/roles.js +222 -0
  93. package/domain/roster.js +9 -3
  94. package/domain/scope.js +51 -0
  95. package/domain/shapes/build.js +254 -0
  96. package/domain/shapes/index.js +171 -0
  97. package/domain/shapes/survey.js +445 -0
  98. package/domain/state/archive.js +28 -4
  99. package/domain/state/migrations.js +111 -4
  100. package/domain/state/schema.js +69 -7
  101. package/domain/state/semantics.js +93 -13
  102. package/domain/state.js +5 -1
  103. package/domain/summary.js +5 -11
  104. package/domain/vocabulary.js +15 -33
  105. package/host/capability-registry.js +25 -9
  106. package/host/config.js +40 -3
  107. package/host/doctor.js +20 -8
  108. package/host/preset-survey-sandbox.js +19 -0
  109. package/host/preset-survey.js +18 -0
  110. package/host/presets.js +17 -11
  111. package/host/tool-registry.js +23 -13
  112. package/locale/en.json +16 -1
  113. package/locale/preset-survey/en.json +6 -0
  114. package/locale/preset-survey/zh.json +6 -0
  115. package/locale/preset-survey-sandbox/en.json +6 -0
  116. package/locale/preset-survey-sandbox/zh.json +6 -0
  117. package/locale/zh.json +16 -1
  118. package/package.json +19 -2
  119. package/ports/state.js +8 -1
  120. package/scripts/architecture-waivers.json +3 -3
  121. package/scripts/check-components.mjs +120 -6
  122. package/scripts/check-docs.mjs +1 -1
  123. package/scripts/check-pack.mjs +5 -0
  124. package/scripts/component-surface.json +83 -29
  125. package/scripts/generate-capability-locale.mjs +5 -0
  126. package/scripts/generate-shapes-reference.mjs +171 -0
  127. package/scripts/mount-smoke/surface.mjs +3 -0
  128. package/scripts/pack-manifest.txt +22 -0
  129. package/scripts/shape-audit.mjs +361 -0
  130. package/scripts/surface-baseline.json +75 -4
  131. package/scripts/types-coverage.mjs +7 -0
  132. package/survey-sandbox.js +48 -0
  133. package/survey.js +40 -0
  134. package/tools/complete.js +5 -5
  135. package/tools/diff.js +5 -3
  136. package/tools/plan.js +1 -1
  137. package/tools/preflight.js +3 -2
  138. package/tools/review.js +1 -1
  139. package/tools/spawn.js +48 -50
  140. package/tools/status.js +8 -1
  141. package/tools/test.js +2 -1
  142. package/types/index.d.ts +76 -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,7 +1,8 @@
1
1
  // Pure completion gate checks (#49). No repository I/O.
2
2
  // @ts-check
3
- import { isTerminal } from './mission.js'
4
- import { isReviewExempt, reviewExemptionInvalid } from './integration/review.js'
3
+ import { shapeFor } from './shapes/index.js'
4
+ import { passingDiscovery } from './evidence.js'
5
+ import { isReviewExempt, reviewExemptionInvalid, reviewRequired } from './integration/review.js'
5
6
 
6
7
  /** @typedef {import('../types/index.js').Blocker} Blocker */
7
8
  /** @typedef {import('../types/index.js').MergeGateOptions} MergeGateOptions */
@@ -10,39 +11,15 @@ import { isReviewExempt, reviewExemptionInvalid } from './integration/review.js'
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
- /** @type {Blocker[]} */
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
  /**
@@ -74,13 +51,16 @@ export function mergeGateBlockers({
74
51
  blockers.push({ code, detail })
75
52
  }
76
53
  const inactive = new Set(inactiveStates)
77
- const requireReview = config.review?.requireReview !== false
54
+ // #M39: only `review.mode:required` produces review blockers. `optional`
55
+ // records reviews without gating, and `off` disables review entirely (no
56
+ // tower_review, no reviewer spawn), so both skip this whole block.
57
+ const requireReview = reviewRequired(config)
78
58
  const exempt = isReviewExempt(mission, config)
79
59
  if (reviewExemptionInvalid(mission, config)) {
80
60
  push('review-exemption-invalid', `mission ${mission.id} requests no review but gates.allowReviewExemption is disabled`)
81
61
  }
82
62
  if (requireReview && !exempt && !review) {
83
- push('no-review', `branch ${branch ?? '(none)'} has no review; assign a reviewer first (or set review.requireReview:false)`)
63
+ push('no-review', `branch ${branch ?? '(none)'} has no review; assign a reviewer first (or set review.mode:optional)`)
84
64
  } else if (requireReview && !exempt) {
85
65
  const latest = /** @type {Review} */ (review)
86
66
  if (latest.missionId !== mission.id) push('review-mission-mismatch', `latest review belongs to mission ${latest.missionId}`)
@@ -109,8 +89,11 @@ export function mergeGateBlockers({
109
89
  push('worker-stale', `stale worker(s) for ${mission.id}: ${staleWorkers.map((row) => row.name).join(', ')}; adopt or interrupt before merge`)
110
90
  }
111
91
  if (config.gates?.requireTests === true) {
112
- const runs = (mission.testRuns ?? []).filter((run) => run.ok === true && run.treeTip && branchTip && run.treeTip === branchTip)
113
- if (runs.length === 0) {
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) {
114
97
  // #140: name the component cause when the row is off. Without this the
115
98
  // blocker reads as a repository mistake ("no passing discovery") when the
116
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 { caseInsensitiveScopeMatch, fileInScope } from '../scope.js'
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
- * @param {object} input
62
- * @param {string} input.missionId
63
- * @param {string[]} input.changedFiles
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
- const outOfScope = changedFiles.filter((file) => !fileInScope(mission.scope, file, { caseInsensitive }))
84
- if (outOfScope.length === 0) return null
85
- const parts = [`changed files outside scope (${mission.scope.join(', ')}): ${outOfScope.slice(0, 8).join(', ')}`]
86
- // On a case-sensitive checkout a case-only mismatch is a human error worth
87
- // naming, but it stays a rejection: folding here would make src/Api and
88
- // src/api the same directory on a filesystem where they are not (#90).
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. A build mission must be
128
- * merged (or abandoned), but a survey is terminal-success at `completed`: it is
129
- * read-only and has no branch to merge, so requiring `merged` would keep the
130
- * dependent mission blocked forever (#107). Archived entries carry `kind` for
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
- if (dep.status === 'merged' || dep.status === 'abandoned') return true
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, TERMINAL_STATUSES } from '../mission.js'
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) => !TERMINAL_STATUSES.has(mission.status))
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 `rebase conflict onto ${base} @ ${baseTip.slice(0, 12)}`
179
+ return `${REBASE_CONFLICT_PREFIX}${base} @ ${baseTip.slice(0, 12)}`
165
180
  }
166
181
 
167
182
  /**