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
@@ -1,11 +1,197 @@
1
- // Pure review-selection and exemption decisions shared by the merge/gate/read
2
- // projections. No repository I/O.
1
+ // Pure review-selection, review-policy resolution and exemption decisions
2
+ // shared by the merge/gate/read projections and the config layer. No repository
3
+ // I/O and no process state.
4
+ //
5
+ // REVIEW POLICY (#M39)
6
+ // --------------------
7
+ // `review.mode` is the single policy with exactly three independent values:
8
+ //
9
+ // off review is intentionally unavailable: recording a review is
10
+ // refused, `tower_spawn` refuses the reviewer role, and the merge
11
+ // gate ignores review blockers. Registration is profile-time: a
12
+ // profile-level off does not mount `tower_review`, while a
13
+ // repository-level off over a profile that mounted it refuses the
14
+ // submission with `review-disabled`;
15
+ // optional review is available but never blocks a merge (the default, and the
16
+ // 2.0.3+ behavior of `review.requireReview:false`);
17
+ // required a clean review bound to the branch tip is a merge blocker (the
18
+ // old `review.requireReview:true` behavior).
19
+ //
20
+ // `review.requireReview` stays accepted for one release as a normalized alias:
21
+ // true -> required, false -> optional. Precedence for one raw config object is
22
+ // deterministic:
23
+ //
24
+ // * only the alias set: the alias decides;
25
+ // * only the mode set: the mode decides;
26
+ // * both set: an alias of `true` next to any mode other than `required` is a
27
+ // contradiction and is rejected naming both keys. An alias of `false` stays
28
+ // compatible with `off` because both mean "not required by merge" and the
29
+ // mode is the more specific policy. That asymmetry is also what keeps the
30
+ // resolver idempotent: it materializes the alias from the mode it resolved,
31
+ // and `required` is the only mode that mirrors back to `true`.
3
32
  // @ts-check
33
+ import { TowerToolError } from '../errors.js'
4
34
 
5
35
  /** @typedef {import('../../types/index.js').Mission} Mission */
6
36
  /** @typedef {import('../../types/index.js').Review} Review */
7
37
  /** @typedef {import('../../types/index.js').TowerConfig} TowerConfig */
8
38
 
39
+ export const REVIEW_MODES = Object.freeze(['off', 'optional', 'required'])
40
+ export const DEFAULT_REVIEW_MODE = 'optional'
41
+
42
+ // Cordis materializes a `.volatile()` schema field as a live reference object
43
+ // (`{ get() }` plus a non-enumerable `Symbol.for('cosmokit.volatile.write')`).
44
+ // Settings forms hand the runtime these references, not plain values, so every
45
+ // consumer below unwraps them before validating or comparing. Keeping the
46
+ // helpers here (instead of a new config module) lets the schema import them
47
+ // without a cycle: review.js has no dependency on schema.js.
48
+ const VOLATILE_WRITE = Symbol.for('cosmokit.volatile.write')
49
+
50
+ /**
51
+ * True for the live reference Cordis/Schemastery creates for a volatile field.
52
+ * @param {unknown} value
53
+ * @returns {value is { get: () => unknown }}
54
+ */
55
+ export function isVolatileRef(value) {
56
+ return (
57
+ value !== null &&
58
+ typeof value === 'object' &&
59
+ typeof /** @type {{ get?: unknown }} */ (value).get === 'function' &&
60
+ Object.getOwnPropertySymbols(value).includes(VOLATILE_WRITE)
61
+ )
62
+ }
63
+
64
+ /**
65
+ * Read a volatile reference once; other values pass through unchanged.
66
+ * @template T
67
+ * @param {T | { get: () => T }} value
68
+ * @returns {T}
69
+ */
70
+ export function unwrapVolatile(value) {
71
+ return /** @type {any} */ (isVolatileRef(value) ? value.get() : value)
72
+ }
73
+
74
+ /**
75
+ * Deep-copy a raw config object, replacing every volatile reference with its
76
+ * current value. Non-plain objects (for example a class instance) are returned
77
+ * as-is; Tower config is JSON-shaped plain data.
78
+ * @param {unknown} value
79
+ * @returns {any}
80
+ */
81
+ export function materializeVolatile(value) {
82
+ const plain = unwrapVolatile(value)
83
+ if (Array.isArray(plain)) return plain.map((entry) => materializeVolatile(entry))
84
+ if (plain === null || typeof plain !== 'object') return plain
85
+ const proto = Object.getPrototypeOf(plain)
86
+ if (proto !== Object.prototype && proto !== null) return plain
87
+ /** @type {Record<string, unknown>} */
88
+ const copy = {}
89
+ for (const [key, entry] of Object.entries(plain)) copy[key] = materializeVolatile(entry)
90
+ return copy
91
+ }
92
+
93
+ /**
94
+ * @param {unknown} value
95
+ * @returns {value is 'off' | 'optional' | 'required'}
96
+ */
97
+ export function isReviewMode(value) {
98
+ return typeof value === 'string' && REVIEW_MODES.includes(value)
99
+ }
100
+
101
+ /**
102
+ * The mode the deprecated boolean alias means.
103
+ * @param {boolean} requireReview
104
+ */
105
+ export function legacyReviewMode(requireReview) {
106
+ return requireReview === true ? 'required' : DEFAULT_REVIEW_MODE
107
+ }
108
+
109
+ /**
110
+ * The resolved mode for any config object the runtime may hold: a resolved
111
+ * config, a raw profile/repo layer, or a hand-built test fixture. An explicit
112
+ * `mode` always wins; otherwise the deprecated alias decides; otherwise the
113
+ * documented default applies.
114
+ * @param {{ review?: any } | null | undefined} [config]
115
+ * @returns {'off' | 'optional' | 'required'}
116
+ */
117
+ export function reviewModeOf(config) {
118
+ const review = config?.review ?? {}
119
+ const mode = unwrapVolatile(review.mode)
120
+ if (isReviewMode(mode)) return mode
121
+ const alias = unwrapVolatile(review.requireReview)
122
+ if (typeof alias === 'boolean') return legacyReviewMode(alias)
123
+ return DEFAULT_REVIEW_MODE
124
+ }
125
+
126
+ /**
127
+ * The review gate is enforced only in `required`. `off` and `optional` both
128
+ * ignore review at the merge gate; they differ in availability, not strictness.
129
+ * @param {{ review?: any } | null | undefined} [config]
130
+ */
131
+ export function reviewRequired(config) {
132
+ return reviewModeOf(config) === 'required'
133
+ }
134
+
135
+ /**
136
+ * True when review is intentionally switched off (`tower_review` absent,
137
+ * reviewer spawn refused, merge gate ignores review).
138
+ * @param {{ review?: any } | null | undefined} [config]
139
+ */
140
+ export function reviewUnavailable(config) {
141
+ return reviewModeOf(config) === 'off'
142
+ }
143
+
144
+ /**
145
+ * Resolve one raw `review` object (a profile, a repository layer, or a single
146
+ * written config) into its mode. Rejects contradictory explicit values and an
147
+ * unknown mode with an actionable `config-invalid` error.
148
+ *
149
+ * @param {any} rawReview
150
+ * @returns {{ mode: 'off' | 'optional' | 'required', explicit: boolean, legacy: boolean }}
151
+ */
152
+ export function resolveReviewPolicy(rawReview) {
153
+ const review = rawReview && typeof rawReview === 'object' && !Array.isArray(rawReview) ? rawReview : {}
154
+ const mode = unwrapVolatile(review.mode)
155
+ const alias = unwrapVolatile(review.requireReview)
156
+ if (mode !== undefined && !isReviewMode(mode)) {
157
+ throw new TowerToolError(
158
+ 'config-invalid',
159
+ `review.mode must be one of ${REVIEW_MODES.join('|')}, got ${JSON.stringify(mode)}`,
160
+ )
161
+ }
162
+ if (alias !== undefined && typeof alias !== 'boolean') {
163
+ throw new TowerToolError(
164
+ 'config-invalid',
165
+ 'review.requireReview must be a boolean (deprecated alias: true -> required, false -> optional)',
166
+ )
167
+ }
168
+ if (mode !== undefined && alias === true && mode !== 'required') {
169
+ throw new TowerToolError(
170
+ 'config-invalid',
171
+ `review.mode is ${JSON.stringify(mode)} but review.requireReview is true; set only review.mode (off|optional|required) — review.requireReview is a deprecated alias (true -> required, false -> optional) and may not contradict it`,
172
+ )
173
+ }
174
+ if (mode !== undefined) return { mode, explicit: true, legacy: alias !== undefined }
175
+ if (alias !== undefined) return { mode: legacyReviewMode(alias), explicit: true, legacy: true }
176
+ return { mode: DEFAULT_REVIEW_MODE, explicit: false, legacy: false }
177
+ }
178
+
179
+ /**
180
+ * The small read-only projection reported by `tower_status` and the doctor's
181
+ * `review` check. `detail` is the one sentence both surfaces share.
182
+ * @param {{ review?: any } | null | undefined} [config]
183
+ */
184
+ export function reviewPolicyView(config) {
185
+ const mode = reviewModeOf(config)
186
+ const detail =
187
+ mode === 'off'
188
+ ? 'review.mode:off — review is intentionally unavailable: recording a review is refused, reviewer spawn is refused, and the merge gate ignores review; a profile-level off does not register tower_review'
189
+ : mode === 'required'
190
+ ? 'review.mode:required — a clean review bound to the branch tip is a merge blocker'
191
+ : 'review.mode:optional — review is available but never blocks a merge'
192
+ return { mode, available: mode !== 'off', required: mode === 'required', detail }
193
+ }
194
+
9
195
  /**
10
196
  * Reviews are branch-scoped; a missing branch has no reviews.
11
197
  * @param {Review[] | null | undefined} reviews
@@ -26,27 +212,35 @@ export function latestReview(reviews) {
26
212
  }
27
213
 
28
214
  /**
29
- * A mission may skip review only with both the exemption gate and a reason.
215
+ * A mission skips the review gate unless `review.mode` is `required`. In
216
+ * `required`, it may still skip with both the exemption gate and a reason.
217
+ * `off` and `optional` both bypass the gate; they differ only in whether the
218
+ * review tools/spawn role exist.
30
219
  * @param {Mission} mission
31
220
  * @param {TowerConfig} [config]
32
221
  * @returns {boolean}
33
222
  */
34
223
  export function isReviewExempt(mission, config = {}) {
224
+ if (!reviewRequired(config)) return true
35
225
  return (
36
- config.review?.requireReview === false ||
37
- (mission.reviewPolicy === 'none' &&
38
- config.gates?.allowReviewExemption === true &&
39
- String(mission.reviewExemptionReason ?? '').trim().length > 0)
226
+ mission.reviewPolicy === 'none' &&
227
+ config.gates?.allowReviewExemption === true &&
228
+ String(mission.reviewExemptionReason ?? '').trim().length > 0
40
229
  )
41
230
  }
42
231
 
43
232
  /**
44
233
  * `reviewPolicy: none` is invalid when the reviewer gate does not allow it.
234
+ * Only `review.mode:required` can make the request invalid; with review off or
235
+ * optional no review blocker is produced at all.
45
236
  * @param {Mission} mission
46
237
  * @param {TowerConfig} [config]
47
238
  * @returns {boolean}
48
239
  */
49
240
  export function reviewExemptionInvalid(mission, config = {}) {
50
- const requireReview = config.review?.requireReview !== false
51
- return requireReview && mission.reviewPolicy === 'none' && config.gates?.allowReviewExemption !== true
241
+ return (
242
+ reviewRequired(config) &&
243
+ mission.reviewPolicy === 'none' &&
244
+ config.gates?.allowReviewExemption !== true
245
+ )
52
246
  }
package/domain/mission.js CHANGED
@@ -1,4 +1,8 @@
1
- // Pure mission vocabulary and transition rules (#49). No fs, git, or config.
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
- /** @type {readonly MissionStatus[]} */
11
- export const MISSION_STATUSES = ['planned', 'active', 'completed', 'blocked', 'paused', 'merged', 'abandoned']
12
- export const SCHEMA_VERSION = 3
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]
@@ -5,6 +5,8 @@
5
5
  // @ts-check
6
6
  import { TowerToolError } from '../errors.js'
7
7
  import { completedEvent } from '../events.js'
8
+ import { shapeFor } from '../shapes/index.js'
9
+ import { isWorkerRole } from '../vocabulary.js'
8
10
 
9
11
  /** @typedef {import('../../types/index.js').Mission} Mission */
10
12
  /** @typedef {import('../../types/index.js').RosterEntry} RosterEntry */
@@ -113,7 +115,7 @@ export function assertCompletionRequest({ mission, roster, input, maxReportBytes
113
115
  if (!worker || worker.missionId !== mission.id) {
114
116
  fail(`agent ${input.agentId} is not assigned to mission ${mission.id}`)
115
117
  }
116
- if (worker.role !== 'worker') fail(`agent ${worker.name} is a ${worker.role}, not a worker`)
118
+ if (!isWorkerRole(worker.role)) fail(`agent ${worker.name} is a ${worker.role}, not a worker`)
117
119
  if (['retired', 'failed', 'interrupted', 'missing', 'stale'].includes(worker.state)) {
118
120
  fail(`agent ${worker.name} is ${worker.state}; a stopped worker cannot report completion`)
119
121
  }
@@ -121,6 +123,12 @@ export function assertCompletionRequest({ mission, roster, input, maxReportBytes
121
123
  fail(`agent ${worker.name} is not the current owner of mission ${mission.id} (owner: ${mission.owner})`)
122
124
  }
123
125
  if (mission.status === 'completed') fail(`mission ${mission.id} is already completed`)
126
+ // `blocked` is derived from open blockers, so name them instead of a generic
127
+ // status error: clearing them is the one recovery path (#149 W3).
128
+ const blockers = mission.blockers ?? []
129
+ if (blockers.length > 0) {
130
+ fail(`mission ${mission.id} has unresolved blockers: ${blockers.join('; ')}; clear the blockers to resume it`)
131
+ }
124
132
  if (!['active', 'planned'].includes(mission.status)) {
125
133
  fail(`mission ${mission.id} is ${mission.status}; it cannot be completed from that state`)
126
134
  }
@@ -136,11 +144,6 @@ export function assertCompletionRequest({ mission, roster, input, maxReportBytes
136
144
  .join('; ')}`,
137
145
  )
138
146
  }
139
- const blockers = mission.blockers ?? []
140
- if (blockers.length > 0) {
141
- fail(`mission ${mission.id} has unresolved blockers: ${blockers.join('; ')}`)
142
- }
143
- if (!mission.worktree) fail(`mission ${mission.id} has no worktree`)
144
147
  return worker
145
148
  }
146
149
 
@@ -167,13 +170,48 @@ export function completionReport({ report, worker, checks, now, maxReportBytes }
167
170
  return { report: text, reportBytes: assertReportSize(text, maxReportBytes), by: worker.name, at: now, checks }
168
171
  }
169
172
 
173
+ /**
174
+ * Normalize the optional survey result (#148 D6). Only a shape that accepts a
175
+ * result may carry one; the value stays small so the state document does not
176
+ * become an evidence dump.
177
+ * @param {unknown} result
178
+ * @returns {any}
179
+ */
180
+ export function normalizeResult(result) {
181
+ if (result === undefined || result === null) return null
182
+ if (!isPlainObject(result)) fail('result must be an object')
183
+ const value = /** @type {Record<string, any>} */ (result)
184
+ /** @type {Record<string, any>} */
185
+ const normalized = {}
186
+ for (const key of ['paths', 'symbols', 'plan']) {
187
+ if (value[key] === undefined) continue
188
+ if (!Array.isArray(value[key])) fail(`result.${key} must be an array of strings`)
189
+ if (value[key].length > 200) fail(`result.${key} supports at most 200 entries`)
190
+ normalized[key] = value[key].map((entry) => {
191
+ if (typeof entry !== 'string' || entry.trim().length === 0) fail(`result.${key} entries must be non-empty strings`)
192
+ return entry.trim()
193
+ })
194
+ }
195
+ if (value.notes !== undefined) {
196
+ if (typeof value.notes !== 'string') fail('result.notes must be a string')
197
+ normalized.notes = value.notes.trim().slice(0, 4000)
198
+ }
199
+ return normalized
200
+ }
201
+
170
202
  /**
171
203
  * Apply the completion transition to the mission draft and describe the domain
172
204
  * fact. Persistence stays in app/checkCompletion.js.
173
- * @param {{ mission: Mission, report: any, now: number }} params
205
+ * @param {{ mission: Mission, report: any, now: number, result?: any }} params
174
206
  * @returns {{ state: Mission, events: Array<{ type: string, missionId: string }> }}
175
207
  */
176
- export function applyCompletion({ mission, report, now }) {
208
+ export function applyCompletion({ mission, report, now, result }) {
209
+ const shape = shapeFor(mission.kind)
210
+ const normalized = normalizeResult(result)
211
+ if (normalized !== null) {
212
+ if (!shape.acceptsResult) fail(`mission ${mission.id} is a ${shape.kind}; a completion result is survey evidence only`)
213
+ mission.result = normalized
214
+ }
177
215
  mission.status = 'completed'
178
216
  mission.completionReport = report
179
217
  mission.updatedAt = now
@@ -5,6 +5,7 @@
5
5
  // @ts-check
6
6
  import { TowerToolError } from '../errors.js'
7
7
  import { FINDING_SEVERITIES, FINDING_STATUSES, FINDING_TRANSITIONS, FINDING_TYPES } from '../mission.js'
8
+ import { isReviewerRole } from '../roles.js'
8
9
  import { countFindings, findingSummary } from '../summary.js'
9
10
 
10
11
  /** @typedef {import('../../types/index.js').Finding} Finding */
@@ -158,7 +159,7 @@ export function readFindingsView(findings, input) {
158
159
  * @returns {{ noteAdded: boolean }}
159
160
  */
160
161
  export function applyFindingUpdate({ finding, input, actor, now }) {
161
- if (actor.role === 'reviewer') {
162
+ if (isReviewerRole(actor.role)) {
162
163
  fail('reviewers may list and read findings, but only the tower or a worker may change finding status', 'not-authorized')
163
164
  }
164
165
  let noteAdded = false
@@ -6,14 +6,14 @@
6
6
  // @ts-check
7
7
  import { TowerToolError } from '../errors.js'
8
8
  import { abandonedEvent, completedEvent } from '../events.js'
9
- import { MISSION_STATUSES, TERMINAL_STATUSES, canTransition, pauseCauseError } from '../mission.js'
9
+ import { pauseCauseError } from '../mission.js'
10
10
  import { normalizeScope, scopesOverlap } from '../scope.js'
11
+ import { shapeFor } from '../shapes/index.js'
12
+ import { isReviewerRole, isWorkerRole } from '../vocabulary.js'
11
13
 
12
14
  /** @typedef {import('../../types/index.js').Mission} Mission */
13
15
  /** @typedef {import('../../types/index.js').TowerState} TowerState */
14
16
 
15
- const TERMINAL = TERMINAL_STATUSES
16
-
17
17
  /**
18
18
  * @param {string} message
19
19
  * @param {string} [code]
@@ -42,8 +42,8 @@ export function normalizePauseCause(value) {
42
42
  * @returns {void}
43
43
  */
44
44
  export function assertMissionActor(mission, actor, input) {
45
- const workerRequest = actor.role === 'worker'
46
- if (actor.role === 'reviewer') {
45
+ const workerRequest = isWorkerRole(actor.role)
46
+ if (isReviewerRole(actor.role)) {
47
47
  fail('only the tower or a worker may update a mission; reviewers submit tower_review', 'not-authorized')
48
48
  }
49
49
  if (workerRequest && mission.owner && mission.owner !== actor.name) {
@@ -61,6 +61,48 @@ export function assertMissionActor(mission, actor, input) {
61
61
  }
62
62
  }
63
63
 
64
+ /**
65
+ * Mark a mission blocked from a machine-derived failure (a merge or rebase
66
+ * conflict). `blocked` is derived from open blockers, so the blocker is recorded
67
+ * with the status in one write path; no caller assigns the status directly
68
+ * (#149 W3). Repeating the same failure does not duplicate the blocker.
69
+ * @param {Mission} mission
70
+ * @param {string} blocker
71
+ * @param {number} [now]
72
+ * @returns {Mission}
73
+ */
74
+ export function markBlocked(mission, blocker, now = Date.now()) {
75
+ if (typeof blocker !== 'string' || blocker.trim().length === 0) fail('a blocker must be a non-empty string')
76
+ const text = blocker.trim()
77
+ const blockers = /** @type {string[]} */ (mission.blockers)
78
+ if (!blockers.includes(text)) blockers.push(text)
79
+ mission.status = 'blocked'
80
+ // A blocker wins over a pause: the human cause is superseded by the failure.
81
+ delete mission.pauseCause
82
+ mission.updatedAt = now
83
+ return mission
84
+ }
85
+
86
+ /**
87
+ * Resolve blockers that a successful machine operation has cleared (a rebase
88
+ * conflict that the retried rebase resolved). Blockers that do not match stay,
89
+ * and the mission stays blocked while any remain — recovery is always "the
90
+ * recorded blockers are gone", never a status assignment.
91
+ * @param {Mission} mission
92
+ * @param {(blocker: string) => boolean} matches
93
+ * @param {number} [now]
94
+ * @returns {boolean} whether any blocker was removed
95
+ */
96
+ export function resolveBlockers(mission, matches, now = Date.now()) {
97
+ const blockers = /** @type {string[]} */ (mission.blockers ?? [])
98
+ const kept = blockers.filter((blocker) => !matches(blocker))
99
+ if (kept.length === blockers.length) return false
100
+ mission.blockers = kept
101
+ if (kept.length === 0 && mission.status === 'blocked') mission.status = 'active'
102
+ mission.updatedAt = now
103
+ return true
104
+ }
105
+
64
106
  /**
65
107
  * Apply one validated `tower_mission` patch to the mission draft and return
66
108
  * the human-readable change list used by the activity log plus the domain
@@ -82,34 +124,80 @@ export function applyMissionUpdate({ mission, state, input, config }) {
82
124
  /** @type {Array<{ type: string, missionId: string }>} */
83
125
  const events = []
84
126
 
127
+ const shape = shapeFor(mission.kind)
128
+ // W3: `blocked` is derived state, `paused` is a deliberate stop. Clearing the
129
+ // blockers is what resumes a blocked mission, so it is applied before any
130
+ // status patch: `{clear_blockers:true,status:"active"}` and
131
+ // `{clear_blockers:true,status:"completed"}` both describe one coherent step.
132
+ if (input.clear_blockers === true) {
133
+ if ((mission.blockers ?? []).length > 0) {
134
+ mission.blockers = []
135
+ changes.push('blockers cleared')
136
+ }
137
+ if (mission.status === 'blocked') {
138
+ mission.status = 'active'
139
+ changes.push('status=active (blocked until the blockers were cleared)')
140
+ }
141
+ }
85
142
  if (input.status !== undefined) {
86
- if (typeof input.status !== 'string' || !MISSION_STATUSES.includes(input.status)) {
87
- fail(`unknown mission status "${input.status}"`)
143
+ if (typeof input.status !== 'string' || !shape.statuses.includes(input.status)) {
144
+ fail(`unknown mission status "${input.status}" for a ${shape.kind} mission`)
88
145
  }
89
146
  if (input.status === 'merged') {
90
147
  fail('merged is recorded only by tower_merge, not by a generic mission patch', 'invalid-transition')
91
148
  }
92
- if (!canTransition(mission.status, input.status)) {
93
- fail(`mission ${mission.id} cannot move from ${mission.status} to ${input.status}`, 'invalid-transition')
149
+ if (input.status === 'blocked') {
150
+ fail(
151
+ 'blocked is derived from open blockers: record blocker:"..." instead of setting status "blocked"',
152
+ 'invalid-transition',
153
+ )
94
154
  }
95
- if (input.status === 'paused') {
96
- mission.pauseCause = normalizePauseCause(input.pause_cause)
155
+ const openBlockers = mission.blockers ?? []
156
+ if (mission.status === 'blocked' && input.status !== 'abandoned') {
157
+ // A blocked mission is resumed by clearing its blockers, never by a
158
+ // status patch that skips the blocker record (#149 W3).
159
+ fail(
160
+ `mission ${mission.id} is blocked (${openBlockers.join('; ')}); clear_blockers resumes it — it cannot move to ${input.status} directly`,
161
+ 'invalid-transition',
162
+ )
97
163
  }
98
- if (mission.status === 'paused' && input.status === 'active') {
99
- const requireAuthorization = config.mode.requireResumeAuthorization === true
100
- if (requireAuthorization && (mission.pauseCause ?? 'user') === 'user') {
101
- if (typeof input.authorization !== 'string' || input.authorization.trim().length === 0) {
102
- fail('resuming a user-paused mission requires a non-empty authorization reference')
103
- }
164
+ if (openBlockers.length > 0 && ['active', 'completed', 'paused'].includes(input.status)) {
165
+ fail(
166
+ `mission ${mission.id} has open blockers (${openBlockers.join('; ')}); clear_blockers resumes it — do not move it to ${input.status}`,
167
+ 'invalid-transition',
168
+ )
169
+ }
170
+ // A status patch is a real move: the target must differ and be a legal edge.
171
+ // `paused` is re-entered only from a different status (a pause cause is
172
+ // meaningful when the pause begins, not as an in-place metadata edit).
173
+ // `{clear_blockers:true,status:"active"}` is one coherent resume step: the
174
+ // clear already moved the mission, so the redundant patch is not a second
175
+ // transition.
176
+ const redundantResume =
177
+ input.clear_blockers === true && input.status === 'active' && mission.status === 'active'
178
+ if (!redundantResume) {
179
+ if (!shape.canTransition(mission.status, input.status)) {
180
+ fail(`mission ${mission.id} cannot move from ${mission.status} to ${input.status}`, 'invalid-transition')
104
181
  }
105
- if (typeof input.authorization === 'string' && input.authorization.trim()) {
106
- mission.authorizationRef = input.authorization.trim()
182
+ if (input.status === 'paused') {
183
+ mission.pauseCause = normalizePauseCause(input.pause_cause)
107
184
  }
185
+ if (mission.status === 'paused' && input.status === 'active') {
186
+ const requireAuthorization = config.mode.requireResumeAuthorization === true
187
+ if (requireAuthorization && (mission.pauseCause ?? 'user') === 'user') {
188
+ if (typeof input.authorization !== 'string' || input.authorization.trim().length === 0) {
189
+ fail('resuming a user-paused mission requires a non-empty authorization reference')
190
+ }
191
+ }
192
+ if (typeof input.authorization === 'string' && input.authorization.trim()) {
193
+ mission.authorizationRef = input.authorization.trim()
194
+ }
195
+ }
196
+ mission.status = input.status
197
+ changes.push(`status=${input.status}`)
198
+ if (input.status === 'completed') events.push(completedEvent(mission))
199
+ if (input.status === 'abandoned') events.push(abandonedEvent(mission))
108
200
  }
109
- mission.status = input.status
110
- changes.push(`status=${input.status}`)
111
- if (input.status === 'completed') events.push(completedEvent(mission))
112
- if (input.status === 'abandoned') events.push(abandonedEvent(mission))
113
201
  }
114
202
 
115
203
  if (input.task_done !== undefined) {
@@ -144,20 +232,23 @@ export function applyMissionUpdate({ mission, state, input, config }) {
144
232
  if (typeof input.blocker !== 'string' || input.blocker.trim().length === 0) {
145
233
  fail('blocker must be a non-empty string')
146
234
  }
147
- if (TERMINAL.has(mission.status)) {
235
+ if (shape.isTerminal(mission.status)) {
148
236
  fail(`mission ${mission.id} is ${mission.status}; a blocker cannot reopen a terminal mission`)
149
237
  }
150
238
  const blockers = /** @type {string[]} */ (mission.blockers)
151
239
  blockers.push(input.blocker.trim())
152
- mission.status = 'blocked'
240
+ // W3: a blocker always wins over a pause; the paused cause is superseded and
241
+ // the mission is blocked until the blockers are cleared.
242
+ if (mission.status !== 'blocked') {
243
+ if (!shape.canTransition(mission.status, 'blocked')) {
244
+ fail(`mission ${mission.id} cannot move from ${mission.status} to blocked`, 'invalid-transition')
245
+ }
246
+ if (mission.status === 'paused') delete mission.pauseCause
247
+ mission.status = 'blocked'
248
+ }
153
249
  changes.push('blocker added')
154
250
  }
155
251
 
156
- if (input.clear_blockers === true) {
157
- mission.blockers = []
158
- changes.push('blockers cleared')
159
- }
160
-
161
252
  if (input.note !== undefined) {
162
253
  if (typeof input.note !== 'string' || input.note.trim().length === 0) {
163
254
  fail('note must be a non-empty string')
@@ -168,16 +259,21 @@ export function applyMissionUpdate({ mission, state, input, config }) {
168
259
  }
169
260
 
170
261
  if (input.scope !== undefined) {
262
+ if (!shape.reach.ownsPaths) {
263
+ fail(`mission ${mission.id} is a ${shape.kind}: it reads a commit and does not own scope`)
264
+ }
171
265
  if (!['active', 'completed'].includes(mission.status)) {
172
266
  fail(`scope can only be widened for active or completed missions, not ${mission.status}`)
173
267
  }
174
- const scope = normalizeScope(input.scope, { allowEmpty: mission.kind === 'survey' })
175
- const others = state.missions.filter(
176
- (entry) => entry.id !== mission.id && !TERMINAL.has(entry.status) && entry.kind !== 'survey',
177
- )
268
+ const scope = normalizeScope(input.scope)
269
+ const others = state.missions.filter((entry) => {
270
+ if (entry.id === mission.id) return false
271
+ const other = shapeFor(entry.kind)
272
+ return other.reach.ownsPaths && !other.isTerminal(entry.status)
273
+ })
178
274
  const caseInsensitive = state.caseInsensitive === true
179
275
  for (const other of others) {
180
- if (mission.kind !== 'survey' && scopesOverlap(scope, other.scope, { caseInsensitive })) {
276
+ if (scopesOverlap(scope, other.scope, { caseInsensitive })) {
181
277
  fail(`widened scope overlaps mission ${other.id}; retire it or add an explicit ordering`)
182
278
  }
183
279
  }