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,445 @@
1
+ // The survey shape (#148/#149): a read of one commit, not a write of a branch.
2
+ //
3
+ // A survey has no worktree, no branch and no commit of its own. By default it
4
+ // runs in the shared checkout with a read-only preset allow-list. With
5
+ // `survey.sandbox: 'detached'` the read moves into a disposable detached git
6
+ // worktree at the recorded commit (#148 W2): the sandbox preset may write and
7
+ // run commands there, but the sandbox has no branch, never merges, is removed
8
+ // when the mission completes or is abandoned, and must still end at the
9
+ // recorded commit with zero commits. Both placements record the commit read
10
+ // plus the tracked-status fingerprint captured at plan time; only the shared
11
+ // placement enforces the fingerprint as a completion invariant.
12
+ // @ts-check
13
+ import { TowerToolError } from '../errors.js'
14
+ import { hasCheckEvidence } from '../evidence.js'
15
+
16
+ /** @typedef {import('../../types/index.js').Mission} Mission */
17
+ /** @typedef {import('../../types/index.js').Blocker} Blocker */
18
+ /** @typedef {import('../../types/index.js').TowerConfig} TowerConfig */
19
+
20
+ /** @type {readonly string[]} */
21
+ export const SURVEY_STATUSES = Object.freeze(['planned', 'active', 'completed', 'blocked', 'paused', 'abandoned'])
22
+
23
+ /**
24
+ * The evidence this shape accepts (#149 W4). A survey has no branch and no
25
+ * tree tip, so a discovery receipt cannot appertain; its completion checks are
26
+ * still accepted through the same one reader.
27
+ * @type {import('../evidence.js').EvidenceContract}
28
+ */
29
+ export const EVIDENCE = Object.freeze({
30
+ completionChecks: true,
31
+ testReceipts: /** @type {const} */ (false),
32
+ })
33
+
34
+ /**
35
+ * The survey shape's path reach (#149 W4): a survey owns no paths. Its scope is
36
+ * empty by construction and any changed path is a fence violation; the
37
+ * placement (shared read-only checkout or detached disposable sandbox) changes
38
+ * where it runs, not what it may change.
39
+ * @type {{ ownsPaths: boolean, fence: 'scope' | 'read-only' }}
40
+ */
41
+ export const REACH = Object.freeze({
42
+ ownsPaths: false,
43
+ fence: /** @type {const} */ ('read-only'),
44
+ })
45
+
46
+ /**
47
+ * A survey has no `merged` row or column: it is never merged, only completed.
48
+ * @type {Readonly<Record<string, readonly string[]>>}
49
+ */
50
+ export const SURVEY_TRANSITIONS = Object.freeze({
51
+ planned: Object.freeze(['active', 'blocked', 'abandoned']),
52
+ active: Object.freeze(['completed', 'blocked', 'paused', 'abandoned']),
53
+ // W3: `blocked` is derived from open blockers; clearing them is the only way
54
+ // out, and a blocker always wins over a pause (hence paused -> blocked).
55
+ blocked: Object.freeze(['active', 'abandoned']),
56
+ paused: Object.freeze(['active', 'blocked', 'abandoned']),
57
+ completed: Object.freeze([]),
58
+ abandoned: Object.freeze([]),
59
+ })
60
+
61
+ /**
62
+ * @param {string} status
63
+ * @returns {boolean}
64
+ */
65
+ export function isTerminal(status) {
66
+ return status === 'completed' || status === 'abandoned'
67
+ }
68
+
69
+ /**
70
+ * @param {string} from
71
+ * @param {string} to
72
+ * @returns {boolean}
73
+ */
74
+ export function canTransition(from, to) {
75
+ return (SURVEY_TRANSITIONS[from] ?? []).includes(to)
76
+ }
77
+
78
+ /**
79
+ * Survey planning records what is being read, not what will be written: the
80
+ * commit and the tracked-status fingerprint at plan time. Scope is empty.
81
+ * @param {{ readCommit?: unknown, readDirty?: unknown }} context
82
+ * @returns {{ scope: string[], readCommit: string, readDirty: string[] }}
83
+ */
84
+ export function planFields(context) {
85
+ const readCommit = context.readCommit
86
+ if (typeof readCommit !== 'string' || readCommit.length === 0) {
87
+ throw new TowerToolError('invalid-argument', 'a survey needs the commit it reads (state.base did not resolve)', {
88
+ nextAction: 'run tower_init so the base branch resolves to a commit, then plan the survey again',
89
+ })
90
+ }
91
+ const readDirty = Array.isArray(context.readDirty)
92
+ ? context.readDirty.filter((entry) => typeof entry === 'string')
93
+ : []
94
+ return { scope: [], readCommit, readDirty }
95
+ }
96
+
97
+ /**
98
+ * Completion invariants for a survey that reads the shared checkout: zero
99
+ * commits, same HEAD, same tracked files. The app layer supplies the observed
100
+ * primitives.
101
+ * @param {{ mission: Mission, head: string, readCommit: string, trackedChanged: string[] }} context
102
+ * @returns {{ commits: number }}
103
+ */
104
+ export function checkCompletion({ mission, head, readCommit, trackedChanged }) {
105
+ if (head !== readCommit) {
106
+ throw new TowerToolError(
107
+ 'invalid-argument',
108
+ `survey ${mission.id} moved HEAD from ${String(readCommit).slice(0, 12)} to ${String(head).slice(0, 12)}; a survey reads one commit and makes no commits`,
109
+ { nextAction: 'reset the shared checkout to the recorded readCommit; do not commit survey work' },
110
+ )
111
+ }
112
+ if (trackedChanged.length > 0) {
113
+ throw new TowerToolError(
114
+ 'invalid-argument',
115
+ `survey ${mission.id} changed tracked files: ${trackedChanged.join(', ')}; a survey is read-only`,
116
+ { nextAction: 'restore the shared checkout tracked files to the status captured at plan time' },
117
+ )
118
+ }
119
+ return { commits: 0 }
120
+ }
121
+
122
+ /**
123
+ * Completion invariants for a detached survey sandbox: the disposable checkout
124
+ * is allowed to contain write/shell side effects, but it may not commit and it
125
+ * must still be at the recorded commit. The sandbox itself is removed after a
126
+ * successful completion.
127
+ * @param {{ mission: Mission, head: string, readCommit: string, commits: number, sandbox: string }} context
128
+ * @returns {{ commits: number }}
129
+ */
130
+ export function checkSandboxCompletion({ mission, head, readCommit, commits, sandbox }) {
131
+ if (!sandbox || sandbox !== mission.sandbox) {
132
+ throw new TowerToolError(
133
+ 'invalid-argument',
134
+ `detached survey ${mission.id} has no recorded sandbox; re-provision the survey or abandon it`,
135
+ { nextAction: 'run tower_spawn to create the detached sandbox again, then complete' },
136
+ )
137
+ }
138
+ if (commits !== 0) {
139
+ throw new TowerToolError(
140
+ 'invalid-argument',
141
+ `survey ${mission.id} made ${commits} commit(s) in its detached sandbox; a survey reads one commit and records no commit of its own`,
142
+ { nextAction: `reset --hard ${String(readCommit).slice(0, 12)} in ${sandbox}; the sandbox is disposable but its HEAD must stay at the recorded commit` },
143
+ )
144
+ }
145
+ if (head !== readCommit) {
146
+ throw new TowerToolError(
147
+ 'invalid-argument',
148
+ `survey ${mission.id} moved the detached sandbox HEAD from ${String(readCommit).slice(0, 12)} to ${String(head).slice(0, 12)}; a survey reads one commit and makes no commits`,
149
+ { nextAction: `reset --hard ${String(readCommit).slice(0, 12)} in ${sandbox}; the sandbox is removed on completion` },
150
+ )
151
+ }
152
+ return { commits: 0 }
153
+ }
154
+
155
+ /**
156
+ * Resolve the survey placement (#148 W2). The shape owns the decision so
157
+ * generic layers never compare `mission.kind` or read the raw key.
158
+ *
159
+ * A sandbox already recorded on the mission is authoritative: provisioned
160
+ * state must win over current config, otherwise flipping `survey.sandbox`
161
+ * between provision and completion would silently move the mission to the
162
+ * shared checkout (or make the completion invariants disagree with the
163
+ * checkout that actually exists). Config only decides initial provisioning.
164
+ * @param {Mission | null | undefined} [mission]
165
+ * @param {TowerConfig} [config]
166
+ * @returns {'shared' | 'detached'}
167
+ */
168
+ export function resolveProvisioning(mission, config = /** @type {TowerConfig} */ ({})) {
169
+ const recorded = mission && typeof mission.sandbox === 'string' ? mission.sandbox.trim() : ''
170
+ if (recorded.length > 0) return 'detached'
171
+ return config.survey?.sandbox === 'detached' ? 'detached' : 'shared'
172
+ }
173
+
174
+ /**
175
+ * The preset that executes a survey in the resolved placement. Shared surveys
176
+ * stay strictly read-only; a detached sandbox gets a preset that may write and
177
+ * run commands inside the disposable checkout.
178
+ * @param {Mission | null | undefined} [mission]
179
+ * @param {TowerConfig} [config]
180
+ * @returns {'tower-survey' | 'tower-survey-sandbox'}
181
+ */
182
+ export function resolvePreset(mission, config = /** @type {TowerConfig} */ ({})) {
183
+ return resolveProvisioning(mission, config) === 'detached' ? 'tower-survey-sandbox' : 'tower-survey'
184
+ }
185
+
186
+ /** Shared-checkout completion invariants (the default placement). */
187
+ const SHARED_COMPLETION = Object.freeze({
188
+ requiresWorktree: false,
189
+ requiresSandbox: false,
190
+ requiresCommits: false,
191
+ requiresClean: false,
192
+ requiresHeadMatch: true,
193
+ requiresTrackedStatus: true,
194
+ check: /** @type {(ctx: any) => { commits: number }} */ (checkCompletion),
195
+ })
196
+
197
+ /** Detached-sandbox completion invariants. */
198
+ const SANDBOX_COMPLETION = Object.freeze({
199
+ requiresWorktree: false,
200
+ requiresSandbox: true,
201
+ requiresCommits: true,
202
+ requiresClean: false,
203
+ requiresHeadMatch: true,
204
+ requiresTrackedStatus: false,
205
+ check: /** @type {(ctx: any) => { commits: number }} */ (checkSandboxCompletion),
206
+ })
207
+
208
+ /**
209
+ * The completion invariants for the checkout the mission was provisioned in.
210
+ * Placement is read from the recorded `mission.sandbox`, never from current
211
+ * config: a detached sandbox still enforces its HEAD/zero-commit invariants and
212
+ * is removed after a config flip to `none`, and a survey that never got a
213
+ * sandbox keeps the shared invariants even if config now asks for `detached`.
214
+ * The config parameter stays in the shape contract for symmetry with other
215
+ * hooks, but is intentionally not consulted here.
216
+ * @param {Mission} mission
217
+ * @param {TowerConfig} [_config]
218
+ * @returns {typeof SHARED_COMPLETION | typeof SANDBOX_COMPLETION}
219
+ */
220
+ export function resolveCompletion(mission, _config = /** @type {TowerConfig} */ ({})) {
221
+ return resolveProvisioning(mission) === 'detached' ? SANDBOX_COMPLETION : SHARED_COMPLETION
222
+ }
223
+
224
+ /**
225
+ * Completion blockers for a survey. A survey is never merged, so the build-only
226
+ * "closed mission owns a branch" blocker does not exist for it.
227
+ * @param {Mission} mission
228
+ * @param {TowerConfig} [config]
229
+ * @returns {Blocker[]}
230
+ */
231
+ export function completionBlockers(mission, config = /** @type {TowerConfig} */ ({})) {
232
+ /** @type {Blocker[]} */
233
+ const blockers = []
234
+ if (mission.status !== 'completed') {
235
+ blockers.push({
236
+ code: 'mission-not-completed',
237
+ detail:
238
+ mission.status === 'blocked'
239
+ ? `mission ${mission.id} is blocked: ${(mission.blockers ?? []).join('; ')}; clear the blockers to resume it`
240
+ : `mission ${mission.id} is ${mission.status}; completion is required`,
241
+ })
242
+ }
243
+ const open = (mission.tasks ?? []).filter((task) => task.status === 'open')
244
+ if (open.length > 0) {
245
+ blockers.push({ code: 'tasks-open', detail: `${open.length} open task(s): ${open.map((task) => task.text).join('; ')}` })
246
+ }
247
+ if ((mission.blockers ?? []).length > 0) {
248
+ blockers.push({ code: 'blockers-present', detail: (mission.blockers ?? []).join('; ') })
249
+ }
250
+ if (config.gates?.requireChecks && !hasCheckEvidence(EVIDENCE, mission)) {
251
+ blockers.push({
252
+ code: 'no-check-evidence',
253
+ detail: `mission ${mission.id} has no completion checks; gates.requireChecks is enabled`,
254
+ })
255
+ }
256
+ return blockers
257
+ }
258
+
259
+ /**
260
+ * A completed survey satisfies its dependents; its read commit is part of the
261
+ * evidence, but the shape contract only needs the terminal status.
262
+ * @param {Pick<Mission, 'status'>} dep
263
+ * @returns {boolean}
264
+ */
265
+ export function dependencySatisfied(dep) {
266
+ return dep.status === 'completed' || dep.status === 'abandoned'
267
+ }
268
+
269
+ /**
270
+ * Worker spawn constraints: a survey worker runs in the shared checkout (or,
271
+ * with `survey.sandbox: 'detached'`, in a disposable detached worktree) and
272
+ * never provisions a branch. The concrete placement/preset is resolved by
273
+ * `resolveProvisioning`/`resolvePreset`.
274
+ * @type {{ provisioning: 'shared', missionStatuses: readonly string[], worktree: false, defaultPreset: string, countsTowardWorkerBudget: boolean, model: string }}
275
+ */
276
+ export const spawn = Object.freeze({
277
+ provisioning: /** @type {const} */ ('shared'),
278
+ missionStatuses: Object.freeze(['planned', 'active', 'blocked', 'paused']),
279
+ worktree: /** @type {const} */ (false),
280
+ defaultPreset: 'tower-survey',
281
+ countsTowardWorkerBudget: false,
282
+ model: 'survey',
283
+ })
284
+
285
+ /**
286
+ * Mission briefing lines that are specific to the survey shape. A detached
287
+ * sandbox is named explicitly so the worker never edits the main checkout and
288
+ * knows the checkout is disposable but the recorded commit is pinned.
289
+ * @param {Mission} mission
290
+ * @param {{ repoRoot: string, worktree?: unknown }} context
291
+ * @returns {string[]}
292
+ */
293
+ export function briefing(mission, context) {
294
+ const short = String(mission.readCommit ?? '').slice(0, 12)
295
+ const fingerprint = (mission.readDirty ?? []).length
296
+ if (mission.sandbox) {
297
+ return [
298
+ `Read and work in the disposable detached sandbox: ${mission.sandbox} (no branch, no commit of your own; the sandbox is removed when the mission completes)`,
299
+ `The sandbox HEAD is pinned to ${short}. Write and shell side effects stay inside the sandbox; completion requires HEAD to still be ${short} and zero new commits.`,
300
+ `Pass workdir="${mission.sandbox}" on every shell command; never modify the main checkout ${context.repoRoot}.`,
301
+ ]
302
+ }
303
+ return [
304
+ `Read only: commit ${short} in the shared checkout ${context.repoRoot} (no branch, no worktree, no commits)`,
305
+ `Completion invariants: HEAD must still be ${short} and the tracked-status set captured at plan time (${fingerprint} path(s)) must be unchanged. Complete with zero commits.`,
306
+ ]
307
+ }
308
+
309
+ /**
310
+ * Projection of the survey-specific mission fields for summaries/board rows.
311
+ * @param {Mission} mission
312
+ * @returns {Record<string, any>}
313
+ */
314
+ export function summary(mission) {
315
+ return {
316
+ worktree: null,
317
+ sandbox: mission.sandbox ?? null,
318
+ readCommit: mission.readCommit ?? null,
319
+ readDirty: [...(mission.readDirty ?? [])],
320
+ mergedCommit: null,
321
+ mergedAt: null,
322
+ }
323
+ }
324
+
325
+ /**
326
+ * One-line human description of where the mission reads, used by reports.
327
+ * @param {Mission} mission
328
+ * @returns {string | null}
329
+ */
330
+ export function describe(mission) {
331
+ return mission.readCommit ? `read at ${String(mission.readCommit).slice(0, 12)}` : null
332
+ }
333
+
334
+ /**
335
+ * v3 -> v4 migration hook (#148). A legacy survey reads the commit its
336
+ * branch-shaped worktree was based on, keeps its completion report, and loses
337
+ * the worktree/branch/merge projection. A legacy survey that was merged becomes
338
+ * completed: a survey is never merged in v4.
339
+ * @param {Record<string, any>} mission
340
+ * @returns {boolean}
341
+ */
342
+ export function isLegacy(mission) {
343
+ return mission.kind === 'survey'
344
+ }
345
+
346
+ /**
347
+ * The commit a legacy (v3, branch-shaped) survey recorded, in fallback order:
348
+ * the v4 field, the worktree it was based on, then the mission-level base
349
+ * commit. A survey planned and never provisioned (or whose spawn failed before
350
+ * provisioning) has none of the three.
351
+ * @param {Record<string, any>} mission
352
+ * @returns {string | null}
353
+ */
354
+ export function legacyReadCommit(mission) {
355
+ const recorded = mission.readCommit ?? mission.worktree?.baseCommit ?? mission.baseCommit
356
+ return typeof recorded === 'string' && recorded.length > 0 ? recorded : null
357
+ }
358
+
359
+ /**
360
+ * True when a legacy survey has no base of its own: the state loader must
361
+ * resolve one from the repository before migration can anchor the survey.
362
+ * @param {Record<string, any>} mission
363
+ * @returns {boolean}
364
+ */
365
+ export function needsLegacyRead(mission) {
366
+ return legacyReadCommit(mission) === null
367
+ }
368
+
369
+ /**
370
+ * @param {Record<string, any>} mission
371
+ * @param {{ legacySurveyRead?: { readCommit?: unknown, readDirty?: unknown } | null }} [options]
372
+ * @returns {Record<string, any>}
373
+ */
374
+ export function migrateLegacy(mission, options = {}) {
375
+ const recorded = legacyReadCommit(mission)
376
+ // The loader resolves the state base once for the whole document; only a
377
+ // survey that recorded no base of its own may consume that resolution. A
378
+ // recorded commit always wins, so re-running the step is a fixed point.
379
+ const resolved = recorded === null ? options.legacySurveyRead : null
380
+ const readCommit = recorded ?? resolved?.readCommit
381
+ if (typeof readCommit !== 'string' || readCommit.length === 0) {
382
+ throw new Error(
383
+ `survey ${String(mission.id)} has no recorded base commit to read; the state loader must resolve the base ref for a planned-but-never-provisioned survey`,
384
+ )
385
+ }
386
+ const readDirty =
387
+ recorded === null
388
+ ? Array.isArray(resolved?.readDirty)
389
+ ? resolved.readDirty.filter((entry) => typeof entry === 'string')
390
+ : []
391
+ : Array.isArray(mission.readDirty)
392
+ ? [...mission.readDirty]
393
+ : []
394
+ const next = /** @type {Record<string, any>} */ ({
395
+ ...mission,
396
+ kind: 'survey',
397
+ scope: [],
398
+ readCommit,
399
+ readDirty,
400
+ })
401
+ if (next.status === 'merged') next.status = 'completed'
402
+ delete next.worktree
403
+ delete next.worktreeRemovedAt
404
+ delete next.sandbox
405
+ delete next.sandboxCleanupError
406
+ delete next.sandboxRemovedAt
407
+ delete next.branch
408
+ delete next.baseCommit
409
+ delete next.mergedCommit
410
+ delete next.mergedAt
411
+ return next
412
+ }
413
+
414
+ export const surveyShape = Object.freeze({
415
+ kind: /** @type {const} */ ('survey'),
416
+ statuses: SURVEY_STATUSES,
417
+ transitions: SURVEY_TRANSITIONS,
418
+ isTerminal,
419
+ canTransition,
420
+ isLegacy,
421
+ migrateLegacy,
422
+ needsLegacyRead,
423
+ canMerge: false,
424
+ canRebase: false,
425
+ provisioning: /** @type {const} */ ('shared'),
426
+ reach: REACH,
427
+ // Document dimensions this shape owns; the state validator reads these.
428
+ fields: Object.freeze({ worktree: false, readCommit: true, merge: false, sandbox: true }),
429
+ // A survey needs its optional component row; a build does not.
430
+ requiredComponent: 'tower-survey',
431
+ // Only a survey records `result` evidence on completion.
432
+ acceptsResult: true,
433
+ evidence: EVIDENCE,
434
+ planFields,
435
+ resolveProvisioning,
436
+ resolvePreset,
437
+ resolveCompletion,
438
+ completion: SHARED_COMPLETION,
439
+ completionBlockers,
440
+ dependencySatisfied,
441
+ spawn,
442
+ briefing,
443
+ summary,
444
+ describe,
445
+ })
@@ -11,7 +11,8 @@
11
11
  // writing the files, and the lock that makes them atomic with state.json, live
12
12
  // in `adapters/archive.js` and `app/archive.js`.
13
13
  // @ts-check
14
- import { isTerminal } from '../mission.js'
14
+ import { normalizeResult } from '../missions/completion.js'
15
+ import { shapeFor } from '../shapes/index.js'
15
16
 
16
17
  /** @typedef {import('../../types/index.js').Finding} Finding */
17
18
  /** @typedef {import('../../types/index.js').Mission} Mission */
@@ -63,7 +64,8 @@ export function terminalTimestamp(mission) {
63
64
  * Select the terminal missions the policy wants moved out of state.json.
64
65
  *
65
66
  * Eligibility:
66
- * - status is `merged` or `abandoned` (terminal);
67
+ * - the mission's shape reports its status as terminal (`merged`/`abandoned`
68
+ * for a build, `completed`/`abandoned` for a survey);
67
69
  * - the worktree is gone (never provisioned, or removed by teardown);
68
70
  * - the caller did not protect it (open batch members, live agents).
69
71
  *
@@ -85,7 +87,7 @@ export function selectForArchive(missions, policy = {}, options = {}) {
85
87
  const afterDays = Number.isFinite(policy?.afterDays) ? /** @type {number} */ (policy.afterDays) : 30
86
88
  const maxMissions = Number.isFinite(policy?.maxMissions) ? /** @type {number} */ (policy.maxMissions) : 100
87
89
  const eligible = missions.filter(
88
- (mission) => isTerminal(mission.status) && !hasLiveWorktree(mission) && !protectedIds.has(mission.id),
90
+ (mission) => shapeFor(mission.kind).isTerminal(mission.status) && !hasLiveWorktree(mission) && !protectedIds.has(mission.id),
89
91
  )
90
92
  if (eligible.length === 0) return []
91
93
  if (options.force === true) return eligible
@@ -140,10 +142,30 @@ export function archiveYearFor(now) {
140
142
  return new Date(now).getUTCFullYear()
141
143
  }
142
144
 
145
+ /**
146
+ * Keep the bounded survey evidence on the index entry. The value was already
147
+ * normalized when the survey completed, but re-normalizing is cheap and makes
148
+ * a hand-edited or legacy document safe to archive.
149
+ * @param {Mission} mission
150
+ * @returns {Record<string, any> | null}
151
+ */
152
+ function boundedResult(mission) {
153
+ const record = /** @type {Record<string, any>} */ (mission)
154
+ if (record.result === undefined || record.result === null) return null
155
+ try {
156
+ return normalizeResult(record.result)
157
+ } catch {
158
+ return null
159
+ }
160
+ }
161
+
143
162
  /**
144
163
  * The compact entry written to `archive/index.json` (and referenced by
145
164
  * `tower_status`). It carries enough fields to keep ordering, dependency
146
- * lookups and mission-id allocation working without loading a year file.
165
+ * lookups, freshness checks and mission-id allocation working without loading a
166
+ * year file: `readCommit` and the bounded survey `result` stay on the index so
167
+ * an archived survey dependency keeps its evidence and cannot fail the
168
+ * `gates.requireFreshSurvey` check open.
147
169
  * @param {Mission} mission
148
170
  * @param {ArchiveMeta} meta
149
171
  * @returns {Record<string, any>}
@@ -158,6 +180,8 @@ export function archiveIndexEntry(mission, meta) {
158
180
  kind: record.kind ?? 'build',
159
181
  scope: Array.isArray(record.scope) ? record.scope : [],
160
182
  deps: Array.isArray(record.deps) ? record.deps : [],
183
+ readCommit: record.readCommit ?? null,
184
+ result: boundedResult(mission),
161
185
  owner: record.owner ?? null,
162
186
  branch,
163
187
  mergedCommit: record.mergedCommit ?? null,
@@ -12,8 +12,12 @@
12
12
  // @ts-check
13
13
  import { TowerToolError } from '../errors.js'
14
14
  import { SCHEMA_VERSION } from '../mission.js'
15
+ import { SHAPE_KINDS, shapeFor } from '../shapes/index.js'
16
+ import { normalizeBatchStatus } from '../batch.js'
15
17
 
16
- /** @typedef {{ from: number, to: number, migrate: (state: Record<string, any>) => Record<string, any> }} StateMigration */
18
+ /** @typedef {{ readCommit: string, readDirty: string[] }} LegacySurveyRead */
19
+ /** @typedef {{ legacySurveyRead?: LegacySurveyRead | null }} MigrationContext */
20
+ /** @typedef {{ from: number, to: number, migrate: (state: Record<string, any>, context?: MigrationContext) => Record<string, any> }} StateMigration */
17
21
 
18
22
  /**
19
23
  * @param {unknown} value
@@ -57,10 +61,109 @@ export function migrateV2ToV3(state) {
57
61
  return { ...state, caseInsensitive: null }
58
62
  }
59
63
 
64
+ /**
65
+ * v3 -> v4. Mission state becomes per-shape (#149). Every mission is stamped with
66
+ * its kind; a legacy branch-shaped survey keeps the commit it read (the
67
+ * worktree's base commit) and drops the worktree/branch/merge fields it no
68
+ * longer owns. Build missions keep their document unchanged.
69
+ *
70
+ * The step is pure and one-way. Re-running it on a v4 document is a fixed point:
71
+ * every mission already has a kind, so the same shape migration rewrites it to
72
+ * the same values.
73
+ *
74
+ * A legacy survey planned but never provisioned records no base commit at all
75
+ * (2.6.0 wrote neither `baseCommit` nor a worktree at plan time). The loader
76
+ * resolves the state base once and passes it as `context.legacySurveyRead`; the
77
+ * survey shape consumes it only when it has no recorded base of its own.
78
+ * @param {Record<string, any>} state
79
+ * @param {MigrationContext} [context]
80
+ * @returns {Record<string, any>}
81
+ */
82
+ export function migrateV3ToV4(state, context = {}) {
83
+ const next = { ...state }
84
+ next.missions = (Array.isArray(next.missions) ? next.missions : []).map((mission) => {
85
+ if (!isPlainObject(mission)) return mission
86
+ const shape = SHAPE_KINDS.map((kind) => shapeFor(kind)).find((candidate) => candidate.isLegacy(mission))
87
+ return shape ? shape.migrateLegacy(mission, context) : mission
88
+ })
89
+ return next
90
+ }
91
+
92
+ /**
93
+ * v4 -> v5. Two contracts that used to overlap are made exact (#149 W3):
94
+ *
95
+ * - `blocked` is derived from open blockers, not an independent stop state. A
96
+ * non-terminal mission that says `blocked` without a blocker gets one explicit
97
+ * legacy blocker (it stays blocked; `clear_blockers` resumes it); a
98
+ * non-terminal mission that carries blockers under any other status becomes
99
+ * `blocked` because a blocker wins over a pause or a fresh status. Terminal
100
+ * missions keep their blockers as history and are never reopened.
101
+ * - A batch's status vocabulary is normalized to `open|closed|abandoned`;
102
+ * pre-v5 documents used `created|active|pending` as synonyms for `open`.
103
+ *
104
+ * The step is pure and one-way. Re-running it on a v5 document is a fixed point.
105
+ * @param {Record<string, any>} state
106
+ * @returns {Record<string, any>}
107
+ */
108
+ export function migrateV4ToV5(state) {
109
+ const next = { ...state }
110
+ next.missions = (Array.isArray(next.missions) ? next.missions : []).map((mission) => {
111
+ if (!isPlainObject(mission)) return mission
112
+ const shape = shapeFor(mission.kind)
113
+ const blockers = Array.isArray(mission.blockers) ? [...mission.blockers] : []
114
+ if (shape.isTerminal(mission.status)) return { ...mission, blockers }
115
+ if (mission.status === 'blocked' && blockers.length === 0) {
116
+ return {
117
+ ...mission,
118
+ blockers: ['(legacy blocked state: no blocker was recorded; clear_blockers resumes the mission)'],
119
+ }
120
+ }
121
+ if (mission.status !== 'blocked' && blockers.length > 0) {
122
+ const migrated = { ...mission, status: 'blocked', blockers }
123
+ // A blocker supersedes a pause: the pause cause no longer describes why
124
+ // the mission is stopped.
125
+ delete migrated.pauseCause
126
+ return migrated
127
+ }
128
+ return mission
129
+ })
130
+ if (Array.isArray(next.batches)) {
131
+ next.batches = next.batches.map((batch) => {
132
+ if (!isPlainObject(batch)) return batch
133
+ const status = normalizeBatchStatus(batch.status)
134
+ return status === batch.status ? batch : { ...batch, status }
135
+ })
136
+ }
137
+ return next
138
+ }
139
+
140
+ /**
141
+ * True when a pre-v4 document contains at least one legacy mission whose shape
142
+ * cannot anchor its read from the document alone (a survey planned but never
143
+ * provisioned). The load path must resolve the repository base before migration
144
+ * runs; a document that recorded its own commit needs no resolver, and a
145
+ * current-version document is never re-migrated.
146
+ * @param {unknown} state
147
+ * @returns {boolean}
148
+ */export function legacySurveyNeedsRead(state) {
149
+ if (!isPlainObject(state)) return false
150
+ const document = /** @type {Record<string, any>} */ (state)
151
+ const version = document.schemaVersion
152
+ if (!Number.isInteger(version) || version < 1 || version >= SCHEMA_VERSION) return false
153
+ const missions = Array.isArray(document.missions) ? document.missions : []
154
+ return missions.some((mission) => {
155
+ if (!isPlainObject(mission)) return false
156
+ const shape = SHAPE_KINDS.map((kind) => shapeFor(kind)).find((candidate) => candidate.isLegacy(mission))
157
+ return typeof shape?.needsLegacyRead === 'function' && shape.needsLegacyRead(mission)
158
+ })
159
+ }
160
+
60
161
  /** Ordered registry: entry 0 moves 1 -> 2, entry 1 moves 2 -> 3, and so on. */
61
162
  export const STATE_MIGRATIONS = Object.freeze([
62
163
  Object.freeze({ from: 1, to: 2, migrate: migrateV1ToV2 }),
63
164
  Object.freeze({ from: 2, to: 3, migrate: migrateV2ToV3 }),
165
+ Object.freeze({ from: 3, to: 4, migrate: migrateV3ToV4 }),
166
+ Object.freeze({ from: 4, to: 5, migrate: migrateV4ToV5 }),
64
167
  ])
65
168
 
66
169
  /**
@@ -91,16 +194,20 @@ export function migrationChain(from, to = SCHEMA_VERSION, migrations = STATE_MIG
91
194
 
92
195
  /**
93
196
  * Apply the registry to a parsed document. Pure: the input object is never
94
- * touched and the returned document has the target `schemaVersion`.
197
+ * touched and the returned document has the target `schemaVersion`. The same
198
+ * `MigrationContext` object reaches every step, so a step may consume a value
199
+ * the loader resolved once (the legacy survey read).
95
200
  *
96
201
  * @param {Record<string, any>} state
97
- * @param {{ statePath?: string, to?: number, migrations?: readonly StateMigration[] }} [options]
202
+ * @param {{ statePath?: string, to?: number, migrations?: readonly StateMigration[] } & MigrationContext} [options]
98
203
  * @returns {{ state: Record<string, any>, from: number, to: number, steps: Array<{ from: number, to: number }> }}
99
204
  */
100
205
  export function migrateState(state, options = {}) {
101
206
  const where = options.statePath ? ` at ${options.statePath}` : ''
102
207
  const to = options.to ?? SCHEMA_VERSION
103
208
  const migrations = options.migrations ?? STATE_MIGRATIONS
209
+ /** @type {MigrationContext} */
210
+ const context = { legacySurveyRead: options.legacySurveyRead }
104
211
  const from = state?.schemaVersion
105
212
  const chain = migrationChain(from, to, migrations)
106
213
  if (!chain || chain.length === 0) {
@@ -120,7 +227,7 @@ export function migrateState(state, options = {}) {
120
227
  /** @type {Record<string, any>} */
121
228
  let next
122
229
  try {
123
- next = step.migrate(current)
230
+ next = step.migrate(current, context)
124
231
  } catch (error) {
125
232
  throw new TowerToolError(
126
233
  'state-migration-failed',