dsh-tower 2.6.0 → 2.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (116) hide show
  1. package/CHANGELOG.md +82 -0
  2. package/COMPATIBILITY.md +1 -0
  3. package/CONTRIBUTING.md +1 -1
  4. package/README.en.md +2 -1
  5. package/README.md +6 -1
  6. package/adapters/leases.js +20 -6
  7. package/adapters/legacy.js +149 -37
  8. package/adapters/stateFile.js +11 -1
  9. package/app/archive.js +30 -5
  10. package/app/briefing.js +95 -12
  11. package/app/checkCompletion.js +107 -18
  12. package/app/enrichedMissions.js +1 -1
  13. package/app/freshness.js +77 -0
  14. package/app/gate.js +26 -12
  15. package/app/merge.js +7 -4
  16. package/app/mission.js +24 -1
  17. package/app/missionContext.js +2 -1
  18. package/app/plan.js +64 -1
  19. package/app/provision.js +133 -22
  20. package/app/read.js +38 -5
  21. package/app/rebase.js +10 -8
  22. package/app/review.js +97 -61
  23. package/app/reviewCheckout.js +133 -0
  24. package/app/sandbox.js +121 -0
  25. package/app/sharedCheckout.js +90 -0
  26. package/app/spawn-prechecks.js +25 -24
  27. package/app/spawn-support.js +59 -8
  28. package/app/spawn.js +3 -0
  29. package/app/teardown.js +119 -2
  30. package/app/tower.js +29 -1
  31. package/cordis.patch.yml +50 -0
  32. package/docs/adr/0006-mission-shapes.md +306 -0
  33. package/docs/architecture/overview.md +6 -4
  34. package/docs/guide/install-upgrade-migration.md +26 -0
  35. package/docs/operations/runbook.md +1 -1
  36. package/docs/reference/blockers.md +8 -4
  37. package/docs/reference/component-surface.md +7 -2
  38. package/docs/reference/config.generated.md +6 -4
  39. package/docs/reference/config.md +1 -2
  40. package/docs/reference/doctor.md +7 -7
  41. package/docs/reference/operations.md +1 -1
  42. package/docs/reference/shapes.md +123 -0
  43. package/docs/reference/surface-budget.md +33 -1
  44. package/docs/reference/testing.md +4 -2
  45. package/docs/reference/tools.generated.md +5 -5
  46. package/docs/reference/tools.md +2 -2
  47. package/docs/zh/reference/blockers.md +2 -2
  48. package/docs/zh/reference/config.md +1 -2
  49. package/docs/zh/reference/testing.md +3 -2
  50. package/domain/batch.js +75 -105
  51. package/domain/collab/batch.js +30 -10
  52. package/domain/collab/spawn.js +53 -16
  53. package/domain/config/schema.js +47 -17
  54. package/domain/contracts/outputs.js +0 -1
  55. package/domain/contracts/tool-inputs/missions.js +9 -1
  56. package/domain/evidence.js +283 -0
  57. package/domain/gate.js +11 -31
  58. package/domain/integration/gate.js +17 -43
  59. package/domain/integration/init.js +3 -2
  60. package/domain/integration/provision.js +51 -0
  61. package/domain/integration/rebase.js +16 -1
  62. package/domain/mission.js +16 -33
  63. package/domain/missions/completion.js +46 -8
  64. package/domain/missions/findings.js +2 -1
  65. package/domain/missions/lifecycle.js +131 -35
  66. package/domain/missions/plan.js +30 -6
  67. package/domain/missions/review.js +2 -18
  68. package/domain/planning.js +5 -5
  69. package/domain/read/missionContext.js +17 -3
  70. package/domain/read/views.js +5 -1
  71. package/domain/roles.js +222 -0
  72. package/domain/roster.js +9 -3
  73. package/domain/scope.js +51 -0
  74. package/domain/shapes/build.js +254 -0
  75. package/domain/shapes/index.js +171 -0
  76. package/domain/shapes/survey.js +445 -0
  77. package/domain/state/archive.js +28 -4
  78. package/domain/state/migrations.js +111 -4
  79. package/domain/state/schema.js +69 -7
  80. package/domain/state/semantics.js +93 -13
  81. package/domain/state.js +5 -1
  82. package/domain/summary.js +5 -11
  83. package/domain/vocabulary.js +15 -33
  84. package/host/capability-registry.js +25 -9
  85. package/host/doctor.js +2 -1
  86. package/host/preset-survey-sandbox.js +19 -0
  87. package/host/preset-survey.js +18 -0
  88. package/host/presets.js +8 -3
  89. package/host/tool-registry.js +14 -12
  90. package/locale/en.json +16 -1
  91. package/locale/preset-survey/en.json +6 -0
  92. package/locale/preset-survey/zh.json +6 -0
  93. package/locale/preset-survey-sandbox/en.json +6 -0
  94. package/locale/preset-survey-sandbox/zh.json +6 -0
  95. package/locale/zh.json +16 -1
  96. package/package.json +13 -2
  97. package/ports/state.js +8 -1
  98. package/scripts/architecture-waivers.json +3 -3
  99. package/scripts/check-docs.mjs +1 -1
  100. package/scripts/check-pack.mjs +2 -0
  101. package/scripts/component-surface.json +68 -20
  102. package/scripts/generate-capability-locale.mjs +5 -0
  103. package/scripts/generate-shapes-reference.mjs +171 -0
  104. package/scripts/mount-smoke/surface.mjs +3 -0
  105. package/scripts/pack-manifest.txt +21 -0
  106. package/scripts/shape-audit.mjs +361 -0
  107. package/scripts/surface-baseline.json +68 -4
  108. package/survey-sandbox.js +48 -0
  109. package/survey.js +40 -0
  110. package/tools/complete.js +5 -5
  111. package/tools/diff.js +5 -3
  112. package/tools/plan.js +1 -1
  113. package/tools/preflight.js +3 -2
  114. package/tools/spawn.js +47 -49
  115. package/tools/test.js +2 -1
  116. package/types/index.d.ts +66 -5
@@ -5,6 +5,8 @@
5
5
  // @ts-check
6
6
  import { TowerToolError } from '../errors.js'
7
7
  import { completedEvent } from '../events.js'
8
+ import { shapeFor } from '../shapes/index.js'
9
+ import { isWorkerRole } from '../vocabulary.js'
8
10
 
9
11
  /** @typedef {import('../../types/index.js').Mission} Mission */
10
12
  /** @typedef {import('../../types/index.js').RosterEntry} RosterEntry */
@@ -113,7 +115,7 @@ export function assertCompletionRequest({ mission, roster, input, maxReportBytes
113
115
  if (!worker || worker.missionId !== mission.id) {
114
116
  fail(`agent ${input.agentId} is not assigned to mission ${mission.id}`)
115
117
  }
116
- if (worker.role !== '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
  }
@@ -6,6 +6,7 @@
6
6
  import { TowerToolError } from '../errors.js'
7
7
  import { plannedEvents } from '../events.js'
8
8
  import { normalizeScope, validateTitle } from '../scope.js'
9
+ import { SHAPE_KINDS, shapeFor } from '../shapes/index.js'
9
10
 
10
11
  /** @typedef {import('../../types/index.js').Mission} Mission */
11
12
  /** @typedef {import('../../types/index.js').TowerState} TowerState */
@@ -28,15 +29,30 @@ function isPlainObject(value) {
28
29
 
29
30
  /**
30
31
  * Validate and normalize one `tower_plan` mission entry.
32
+ *
33
+ * The shape owns the kind-specific plan fields: a build normalizes its scope, a
34
+ * survey records the commit it reads (`read.commit`, resolved by app/plan from
35
+ * `state.base`) plus the tracked-status fingerprint captured at the same moment.
31
36
  * @param {any} input
32
37
  * @param {number} index
38
+ * @param {{ commit?: string, dirty?: string[] }} [read]
33
39
  * @returns {any}
34
40
  */
35
- export function normalizeMissionInput(input, index) {
41
+ export function normalizeMissionInput(input, index, read = {}) {
36
42
  if (!isPlainObject(input)) fail(`missions[${index}] must be an object`)
37
43
  const title = validateTitle(input.title)
38
- const kind = input.kind === 'survey' ? 'survey' : 'build'
39
- const scope = normalizeScope(input.scope ?? [], { allowEmpty: kind === 'survey' })
44
+ const kind = input.kind === undefined ? 'build' : input.kind
45
+ if (typeof kind !== 'string' || !SHAPE_KINDS.includes(kind)) {
46
+ fail(`missions[${index}].kind must be one of ${SHAPE_KINDS.join('|')}`)
47
+ }
48
+ const shape = shapeFor(kind)
49
+ /** @type {string[]} */
50
+ const scopeInput = input.scope ?? []
51
+ if (!Array.isArray(scopeInput)) fail(`missions[${index}].scope must be an array of globs`)
52
+ if (!shape.reach.ownsPaths && scopeInput.length > 0) {
53
+ fail(`missions[${index}] is a ${shape.kind}: it reads a commit and does not own a write scope`)
54
+ }
55
+ const scope = shape.reach.ownsPaths ? normalizeScope(scopeInput) : []
40
56
  /** @type {Array<{ text: string, status: string }>} */
41
57
  const tasks = []
42
58
  if (input.tasks !== undefined) {
@@ -80,19 +96,25 @@ export function normalizeMissionInput(input, index) {
80
96
  if (reviewPolicy === 'none' && reviewExemptionReason.length === 0) {
81
97
  fail(`missions[${index}].reviewPolicy "none" requires a non-empty reviewExemptionReason`)
82
98
  }
83
- return { title, kind, scope, tasks, deps, context, references, reviewPolicy, reviewExemptionReason }
99
+ const fields = /** @type {any} */ (/** @type {any} */ (shape).planFields)({
100
+ scope,
101
+ readCommit: read.commit,
102
+ readDirty: read.dirty,
103
+ })
104
+ return { title, kind, ...fields, tasks, deps, context, references, reviewPolicy, reviewExemptionReason }
84
105
  }
85
106
 
86
107
  /**
87
108
  * Validate the plan envelope and normalize every mission entry.
88
109
  * @param {any} input
110
+ * @param {{ commit?: string, dirty?: string[] }} [read]
89
111
  * @returns {any[]}
90
112
  */
91
- export function normalizePlanMissions(input) {
113
+ export function normalizePlanMissions(input, read = {}) {
92
114
  if (!Array.isArray(input.missions) || input.missions.length === 0) {
93
115
  fail('tower_plan needs at least one mission')
94
116
  }
95
- return input.missions.map((mission, index) => normalizeMissionInput(mission, index))
117
+ return input.missions.map((mission, index) => normalizeMissionInput(mission, index, read))
96
118
  }
97
119
 
98
120
  /**
@@ -147,6 +169,8 @@ export function newMission(id, input, now) {
147
169
  scopeGeneration: 1,
148
170
  createdAt: now,
149
171
  updatedAt: now,
172
+ ...(input.readCommit === undefined ? {} : { readCommit: input.readCommit }),
173
+ ...(input.readDirty === undefined ? {} : { readDirty: input.readDirty }),
150
174
  }))
151
175
  }
152
176
 
@@ -7,6 +7,7 @@
7
7
  import { TowerToolError } from '../errors.js'
8
8
  import { reviewedEvent } from '../events.js'
9
9
  import { MERGE_VERDICTS, REVIEW_STATUS_RE } from '../mission.js'
10
+ import { isReviewerRole } from '../roles.js'
10
11
 
11
12
  /** @typedef {import('../../types/index.js').Mission} Mission */
12
13
  /** @typedef {import('../../types/index.js').RosterEntry} RosterEntry */
@@ -47,7 +48,7 @@ export function resolveReviewActor({ mission, caller, actorRole, agentId, breakG
47
48
  return { reviewerName: 'tower', reviewerRole: 'tower' }
48
49
  }
49
50
  if (caller) {
50
- if (caller.role !== 'reviewer') {
51
+ if (!isReviewerRole(caller.role)) {
51
52
  fail(`agent ${caller.name} is a ${caller.role}; only an assigned reviewer or the tower (break-glass) may review`)
52
53
  }
53
54
  if (caller.missionId !== mission.id) {
@@ -92,23 +93,6 @@ export function normalizeReviewVerdict(input) {
92
93
  return { status: input.status, merge: input.merge, findings, decision, checks }
93
94
  }
94
95
 
95
- /**
96
- * A reviewer may only review the tip it was spawned on.
97
- * @param {RosterEntry | undefined} caller
98
- * @param {string} tip
99
- * @returns {void}
100
- */
101
- export function assertReviewerSpawnTip(caller, tip) {
102
- if (caller && caller.role === 'reviewer' && !String(caller.spawnTip ?? '').trim()) {
103
- fail(`reviewer ${caller.name} has no recorded spawn tip; re-spawn the reviewer before reviewing`)
104
- }
105
- if (caller && caller.role === 'reviewer' && caller.spawnTip !== tip) {
106
- fail(
107
- `reviewer ${caller.name} modified the mission branch after it was spawned (${String(caller.spawnTip).slice(0, 7)} -> ${tip.slice(0, 7)}); reviewers must be read-only — spawn a fresh reviewer`,
108
- )
109
- }
110
- }
111
-
112
96
  /**
113
97
  * The latest review of one branch. Higher rounds win; routes that share a round
114
98
  * are ordered by submission sequence, so "latest" is the most recently recorded
@@ -2,7 +2,7 @@
2
2
  // @ts-check
3
3
  import { TowerToolError } from './errors.js'
4
4
  import { scopesOverlap } from './scope.js'
5
- import { isTerminal } from './mission.js'
5
+ import { shapeFor } from './shapes/index.js'
6
6
 
7
7
  /** @typedef {import('../types/index.js').Mission} Mission */
8
8
 
@@ -93,7 +93,7 @@ function assertAcyclic(missions) {
93
93
  * @returns {void}
94
94
  */
95
95
  function addAutoDeps(newMissions, existingMissions, { caseInsensitive = false } = {}) {
96
- const openExisting = existingMissions.filter((mission) => !isTerminal(mission.status))
96
+ const openExisting = existingMissions.filter((mission) => !shapeFor(mission.kind).isTerminal(mission.status))
97
97
  const all = [...existingMissions, ...newMissions]
98
98
  /** @type {Map<string, Mission>} */
99
99
  const byId = new Map(all.map((mission) => [mission.id, mission]))
@@ -102,15 +102,15 @@ function addAutoDeps(newMissions, existingMissions, { caseInsensitive = false }
102
102
  for (let j = i + 1; j < newMissions.length; j += 1) {
103
103
  const earlier = newMissions[i]
104
104
  const later = newMissions[j]
105
- if (earlier.kind === 'survey' || later.kind === 'survey') continue
105
+ if (!shapeFor(earlier.kind).reach.ownsPaths || !shapeFor(later.kind).reach.ownsPaths) continue
106
106
  if (!scopesOverlap(earlier.scope, later.scope, { caseInsensitive })) continue
107
107
  if (hasDependencyPath(byId, later.id, earlier.id)) continue
108
108
  addDependency(later, earlier.id)
109
109
  }
110
110
  const mission = newMissions[i]
111
- if (mission.kind === 'survey') continue
111
+ if (!shapeFor(mission.kind).reach.ownsPaths) continue
112
112
  for (const other of openExisting) {
113
- if (other.kind === 'survey') continue
113
+ if (!shapeFor(other.kind).reach.ownsPaths) continue
114
114
  if (!scopesOverlap(mission.scope, other.scope, { caseInsensitive })) continue
115
115
  if (hasDependencyPath(byId, mission.id, other.id)) continue
116
116
  addDependency(mission, other.id)
@@ -10,10 +10,13 @@ import { fullMission, findingSummary } from '../summary.js'
10
10
  * @param {object} input
11
11
  * @param {TowerState} input.state
12
12
  * @param {Mission} input.mission
13
+ * @param {Array<Record<string, any>> | Map<string, Record<string, any>>} [input.archived] archive index entries (or a by-id map) so a dependency that has already been archived keeps its evidence
13
14
  * @returns {object}
14
15
  */
15
- export function missionContextView({ state, mission }) {
16
+ export function missionContextView({ state, mission, archived = [] }) {
16
17
  const byId = new Map(state.missions.map((entry) => [entry.id, entry]))
18
+ const archivedById =
19
+ archived instanceof Map ? archived : new Map(archived.map((entry) => [entry.id, entry]))
17
20
  const branch = mission.worktree ? mission.worktree.branch : null
18
21
  const reviews = branch
19
22
  ? (state.reviews ?? [])
@@ -46,8 +49,19 @@ export function missionContextView({ state, mission }) {
46
49
  })),
47
50
  findings: findings.map((finding) => findingSummary(finding)),
48
51
  deps: (mission.deps ?? []).map((id) => {
49
- const dep = byId.get(id)
50
- return dep ? { id, title: dep.title, status: dep.status } : { id, title: null, status: 'unknown' }
52
+ const dep = byId.get(id) ?? archivedById.get(id)
53
+ if (!dep) return { id, title: null, status: 'unknown' }
54
+ return {
55
+ id,
56
+ title: dep.title,
57
+ kind: dep.kind,
58
+ status: dep.status,
59
+ readCommit: dep.readCommit ?? null,
60
+ // #148 D6: a survey's completion evidence travels with the dependency
61
+ // context so a dependent build worker sees what was found, not only that
62
+ // the survey finished. Formatting/clipping is the briefing's job.
63
+ result: dep.result ?? null,
64
+ }
51
65
  }),
52
66
  }
53
67
  }
@@ -125,16 +125,20 @@ export function patchLimit(maxBytes) {
125
125
  * @param {string[]} input.files
126
126
  * @param {string[]} input.commits
127
127
  * @param {string} input.fullPatch
128
+ * @param {string|null} [input.reviewedCommit]
128
129
  * @param {number} [input.maxBytes]
129
130
  * @returns {object}
130
131
  */
131
- export function diffView({ mission, baseTip, branchTip, mergeBase, files, commits, fullPatch, maxBytes }) {
132
+ export function diffView({ mission, baseTip, branchTip, mergeBase, files, commits, fullPatch, maxBytes, reviewedCommit = null }) {
132
133
  const limit = patchLimit(maxBytes)
133
134
  return {
134
135
  missionId: mission.id,
135
136
  branch: mission.worktree ? mission.worktree.branch : null,
136
137
  baseTip,
137
138
  branchTip,
139
+ // #149 W3: a reviewer sees (and is stamped with) one recorded commit; the
140
+ // field is null for every other caller reading the live branch.
141
+ reviewedCommit,
138
142
  mergeBase,
139
143
  files,
140
144
  commits,