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
@@ -0,0 +1,171 @@
1
+ // The one shape registry (#149). A *shape* is the contract a mission kind
2
+ // carries: its status vocabulary and transitions, what it provisions, how it
3
+ // completes, how dependencies are satisfied, and what its briefing and summary
4
+ // say. Generic layers resolve a shape once through `shapeFor()` and never
5
+ // compare `mission.kind`.
6
+ //
7
+ // `isTerminal(kind, status)` and `canTransition(kind, from, to)` are the only
8
+ // entry points for status policy; the old global `TERMINAL_STATUSES` and
9
+ // `TRANSITIONS` tables are gone.
10
+ // @ts-check
11
+ import { TowerToolError } from '../errors.js'
12
+ import { buildShape } from './build.js'
13
+ import { surveyShape } from './survey.js'
14
+
15
+ /** @typedef {import('../../types/index.js').Mission} Mission */
16
+
17
+ /** @type {Readonly<Record<string, typeof buildShape | typeof surveyShape>>} */
18
+ export const SHAPES = Object.freeze({
19
+ build: buildShape,
20
+ survey: surveyShape,
21
+ })
22
+
23
+ /** Ordered kind vocabulary; the first kind is the default for absent kinds. */
24
+ /** @type {readonly string[]} */
25
+ export const SHAPE_KINDS = Object.freeze(Object.keys(SHAPES))
26
+
27
+ /**
28
+ * Resolve a mission kind to its shape.
29
+ *
30
+ * A document written before schema v4 may omit `kind`; the v3 -> v4 migration
31
+ * stamps `build`, and pure helpers that tolerate partial mission snapshots still
32
+ * resolve an absent kind as the build shape. An unknown non-empty kind is never
33
+ * guessed.
34
+ * @param {string | null | undefined} kind
35
+ * @returns {typeof buildShape | typeof surveyShape}
36
+ */
37
+ export function shapeFor(kind) {
38
+ if (kind === undefined || kind === null || kind === '') return SHAPES.build
39
+ const shape = SHAPES[kind]
40
+ if (!shape) {
41
+ throw new TowerToolError('invalid-argument', `unknown mission kind "${kind}"; expected one of ${SHAPE_KINDS.join('|')}`)
42
+ }
43
+ return shape
44
+ }
45
+
46
+ /**
47
+ * @param {unknown} kind
48
+ * @returns {boolean}
49
+ */
50
+ export function isShapeKind(kind) {
51
+ return typeof kind === 'string' && Object.hasOwn(SHAPES, kind)
52
+ }
53
+
54
+ /**
55
+ * The only terminality entry point.
56
+ * @param {string | null | undefined} kind
57
+ * @param {string} status
58
+ * @returns {boolean}
59
+ */
60
+ export function isTerminal(kind, status) {
61
+ return shapeFor(kind).isTerminal(status)
62
+ }
63
+
64
+ /**
65
+ * The only transition entry point.
66
+ * @param {string | null | undefined} kind
67
+ * @param {string} from
68
+ * @param {string} to
69
+ * @returns {boolean}
70
+ */
71
+ export function canTransition(kind, from, to) {
72
+ return shapeFor(kind).canTransition(from, to)
73
+ }
74
+
75
+ /**
76
+ * @param {string | null | undefined} kind
77
+ * @returns {boolean}
78
+ */
79
+ export function canMerge(kind) {
80
+ return shapeFor(kind).canMerge
81
+ }
82
+
83
+ /**
84
+ * @param {string | null | undefined} kind
85
+ * @returns {boolean}
86
+ */
87
+ export function canRebase(kind) {
88
+ return shapeFor(kind).canRebase
89
+ }
90
+
91
+ /**
92
+ * Resolve how a mission of this kind is provisioned. A shape may resolve the
93
+ * placement from tuning config (#148 W2: a survey is `shared` by default and
94
+ * `detached` with `survey.sandbox: 'detached'`); a shape without a resolver keeps
95
+ * its static placement. When a mission is supplied, a placement already recorded
96
+ * on it (a provisioned detached sandbox) wins over config.
97
+ * @param {string | null | undefined} kind
98
+ * @param {import('../../types/index.js').TowerConfig} [config]
99
+ * @param {import('../../types/index.js').Mission | null | undefined} [mission]
100
+ * @returns {'branch-worktree' | 'shared' | 'detached'}
101
+ */
102
+ export function provisioningFor(kind, config = {}, mission = null) {
103
+ const shape = shapeFor(kind)
104
+ if ('resolveProvisioning' in shape && typeof shape.resolveProvisioning === 'function') {
105
+ return shape.resolveProvisioning(mission, config)
106
+ }
107
+ return shape.provisioning
108
+ }
109
+
110
+ /**
111
+ * Resolve the agent preset that executes this kind in the configured placement.
112
+ * A build keeps its `spawn.defaultPreset`; a survey switches between the
113
+ * read-only preset and the sandbox preset so the allow-list follows the
114
+ * resolved placement rather than a hard-coded kind.
115
+ * @param {string | null | undefined} kind
116
+ * @param {import('../../types/index.js').TowerConfig} [config]
117
+ * @param {import('../../types/index.js').Mission | null | undefined} [mission]
118
+ * @returns {string | null}
119
+ */
120
+ export function defaultPresetFor(kind, config = {}, mission = null) {
121
+ const shape = shapeFor(kind)
122
+ if ('resolvePreset' in shape && typeof shape.resolvePreset === 'function') {
123
+ return shape.resolvePreset(mission, config)
124
+ }
125
+ return 'defaultPreset' in shape.spawn ? shape.spawn.defaultPreset : null
126
+ }
127
+
128
+ /**
129
+ * Refuse branch-shaped actions for a shape that has no branch. The message tells
130
+ * the caller what to do instead, so the refusal is actionable rather than a bare
131
+ * "invalid transition".
132
+ * @param {Mission} mission
133
+ * @param {'merge' | 'rebase' | 'preflight' | 'diff'} action
134
+ * @returns {void}
135
+ */
136
+ export function assertActionSupported(mission, action) {
137
+ const shape = shapeFor(mission.kind)
138
+ // Derived from the shape, never from a hard-coded kind: a new shape that has
139
+ // no branch gets the same refusals without touching this function.
140
+ const evidence = shape.fields.readCommit
141
+ ? `readCommit ${String(mission.readCommit ?? '').slice(0, 12)}`
142
+ : 'recorded evidence'
143
+ if (action === 'merge' && !shape.canMerge) {
144
+ throw new TowerToolError(
145
+ 'invalid-transition',
146
+ `mission ${mission.id} is a ${shape.kind}: it has no branch to merge and is never merged; a completed ${shape.kind} already satisfies its dependents`,
147
+ { nextAction: `use tower_complete to finish the ${shape.kind}; its ${evidence} is the evidence` },
148
+ )
149
+ }
150
+ if (action === 'rebase' && !shape.canRebase) {
151
+ throw new TowerToolError(
152
+ 'invalid-transition',
153
+ `mission ${mission.id} is a ${shape.kind}: it has no branch to rebase; a ${shape.kind} does not move`,
154
+ { nextAction: `re-plan the ${shape.kind} if the base moved; the recorded ${evidence} is immutable` },
155
+ )
156
+ }
157
+ if (action === 'preflight' && !shape.canMerge) {
158
+ throw new TowerToolError(
159
+ 'invalid-transition',
160
+ `mission ${mission.id} is a ${shape.kind}: it has no merge gate; tower_complete checks HEAD, tracked files and zero commits`,
161
+ { nextAction: `use tower_complete to finish the ${shape.kind}; ${shape.kind}s are not preflighted or merged` },
162
+ )
163
+ }
164
+ if (action === 'diff' && !shape.canMerge) {
165
+ throw new TowerToolError(
166
+ 'invalid-transition',
167
+ `mission ${mission.id} is a ${shape.kind}: it has no branch diff; its ${evidence} is the evidence`,
168
+ { nextAction: `inspect the recorded ${evidence} directly, or the ${shape.kind} result recorded at completion` },
169
+ )
170
+ }
171
+ }
@@ -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,