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
@@ -12,16 +12,48 @@
12
12
  import { fsp } from './store-fs.js'
13
13
  import path from 'node:path'
14
14
  import crypto from 'node:crypto'
15
+ import { isShapeKind } from '../domain/shapes/index.js'
15
16
 
16
17
  const SCHEMA_VERSION = 1
17
18
  const MAX_SNAPSHOT_BYTES = 16 * 1024 * 1024
18
19
  const DSH_STATUSES = ['planned', 'active', 'completed', 'blocked', 'paused', 'merged', 'abandoned']
19
- // Tower Codex also retires missions; retired is read-only history here.
20
+ // Tower Codex also retires missions; `retired` is accepted input vocabulary and
21
+ // is remapped below. It is never a DSH mission status.
20
22
  const CODEX_STATUSES = [...DSH_STATUSES, 'retired']
21
23
  const DSH_ID_RE = /^M\d+$/
22
24
  const CODEX_ID_RE = /^T\d{4,}$/
23
25
  const SHA256_RE = /^[a-f0-9]{64}$/
24
26
 
27
+ // The strict adapter contract (#149 W4). One payload is exactly one legacy
28
+ // format; every status and field alias this module accepts is declared here.
29
+ // Missions are normalised to the shape registry's DSH vocabulary: a status
30
+ // remap is applied and reported in `remapped`, never silently invented, and a
31
+ // value the adapter cannot carry is reported in `unresolved`/`dropped`.
32
+ export const LEGACY_FORMATS = Object.freeze({
33
+ 'tower-codex': Object.freeze({
34
+ idPattern: CODEX_ID_RE,
35
+ idHint: 'T####',
36
+ statuses: CODEX_STATUSES,
37
+ statusRemap: Object.freeze({ retired: 'abandoned' }),
38
+ baseBranchField: 'baseBranch',
39
+ requiresBaseBranch: true,
40
+ }),
41
+ dsh: Object.freeze({
42
+ idPattern: DSH_ID_RE,
43
+ idHint: 'M#',
44
+ statuses: DSH_STATUSES,
45
+ statusRemap: Object.freeze({}),
46
+ baseBranchField: 'base',
47
+ requiresBaseBranch: false,
48
+ }),
49
+ })
50
+
51
+ // Only these fields cross the adapter boundary. Everything else a legacy
52
+ // mission carries is validated for safety, then reported as dropped: the
53
+ // adapter deliberately does not invent DSH semantics for it.
54
+ const CARRIED_MISSION_FIELDS = Object.freeze(['id', 'status', 'kind'])
55
+ const DROPPED_REASON = 'validated for safety but not carried into the summary; the exact payload bytes remain in the stored snapshot'
56
+
25
57
  function fail(code, message) {
26
58
  /** @type {Error & {code?: string}} */
27
59
  const error = new Error(message)
@@ -86,6 +118,9 @@ function invalidResult(error, format = null) {
86
118
  missionIds: [],
87
119
  missionCount: 0,
88
120
  paused: [],
121
+ unresolved: [],
122
+ dropped: [],
123
+ remapped: [],
89
124
  events: null,
90
125
  legacySnapshots: null,
91
126
  repoRoot: null,
@@ -140,31 +175,89 @@ function validateReviews(reviews, id) {
140
175
  return null
141
176
  }
142
177
 
178
+ /**
179
+ * Detect one legacy format per payload. A declared `format` is authoritative;
180
+ * otherwise every mission id must belong to exactly one namespace. Empty
181
+ * mission lists use the top-level shape (a Tower Codex snapshot has a
182
+ * baseBranch; DSH state uses `base`).
183
+ * @param {any} payload
184
+ * @returns {{ format: 'tower-codex'|'dsh'|null, error: string|null }}
185
+ */
186
+ function detectFormat(payload) {
187
+ if (payload.format !== undefined) {
188
+ if (typeof payload.format !== 'string' || !Object.prototype.hasOwnProperty.call(LEGACY_FORMATS, payload.format)) {
189
+ return { format: null, error: `unsupported legacy format ${JSON.stringify(payload.format)} (expected tower-codex or dsh)` }
190
+ }
191
+ return { format: payload.format, error: null }
192
+ }
193
+ if (payload.missions.length === 0) {
194
+ return { format: isNonEmptyString(payload.baseBranch) ? 'tower-codex' : 'dsh', error: null }
195
+ }
196
+ let codex = 0
197
+ let dsh = 0
198
+ for (const mission of payload.missions) {
199
+ const id = mission?.id
200
+ if (typeof id === 'string' && CODEX_ID_RE.test(id)) codex += 1
201
+ else if (typeof id === 'string' && DSH_ID_RE.test(id)) dsh += 1
202
+ }
203
+ if (codex === payload.missions.length) return { format: 'tower-codex', error: null }
204
+ if (dsh === payload.missions.length) return { format: 'dsh', error: null }
205
+ for (const mission of payload.missions) {
206
+ const id = mission?.id
207
+ if (typeof id !== 'string' || (!CODEX_ID_RE.test(id) && !DSH_ID_RE.test(id))) {
208
+ return {
209
+ format: null,
210
+ error: `unsupported legacy mission id ${JSON.stringify(id)} (expected T#### for Tower Codex or M# for DSH)`,
211
+ }
212
+ }
213
+ }
214
+ return { format: null, error: 'legacy snapshot mixes mission id namespaces (T#### and M#); one snapshot is one format' }
215
+ }
216
+
143
217
  /**
144
218
  * @param {any} payload
145
219
  * @param {'tower-codex'|'dsh'} format
146
- * @returns {{error:string|null, missions:Array<{id:string,status:string,kind:string}>, missionIds:string[], paused:string[]}}
220
+ * @param {{ idPattern: RegExp, idHint: string, statuses: string[], statusRemap: Record<string, string>, baseBranchField: string, requiresBaseBranch: boolean }} spec
221
+ * @returns {{error:string|null, missions:Array<{id:string,status:string,kind:string}>, missionIds:string[], paused:string[],
222
+ * unresolved:Array<{missionId:string, field:string, reason:string}>,
223
+ * dropped:Array<{field:string, reason:string}>,
224
+ * remapped:Array<{missionId:string, field:string, from:string, to:string}>}}
147
225
  */
148
- function validateMissions(payload, format) {
149
- const statuses = format === 'tower-codex' ? CODEX_STATUSES : DSH_STATUSES
150
- const idRe = format === 'tower-codex' ? CODEX_ID_RE : DSH_ID_RE
226
+ function validateMissions(payload, format, spec) {
151
227
  const missions = []
152
228
  const missionIds = []
153
229
  const paused = []
230
+ const unresolved = []
231
+ const remapped = []
232
+ const droppedFields = new Set()
154
233
  const seen = new Set()
155
234
  for (let index = 0; index < payload.missions.length; index += 1) {
156
235
  const mission = payload.missions[index]
157
- if (!isPlainObject(mission)) return { error: `missions[${index}] must be an object`, missions: [], missionIds: [], paused: [] }
158
- if (typeof mission.id !== 'string' || !idRe.test(mission.id)) {
159
- return { error: `missions[${index}] has unsupported id ${JSON.stringify(mission.id)} (expected ${format === 'tower-codex' ? 'T####' : 'M#'})`, missions: [], missionIds: [], paused: [] }
236
+ if (!isPlainObject(mission)) return { error: `missions[${index}] must be an object`, missions: [], missionIds: [], paused: [], unresolved: [], dropped: [], remapped: [] }
237
+ if (typeof mission.id !== 'string' || !spec.idPattern.test(mission.id)) {
238
+ return { error: `missions[${index}] has unsupported id ${JSON.stringify(mission.id)} (expected ${spec.idHint})`, missions: [], missionIds: [], paused: [], unresolved: [], dropped: [], remapped: [] }
160
239
  }
161
- if (seen.has(mission.id)) return { error: `duplicate legacy mission id ${mission.id}`, missions: [], missionIds: [], paused: [] }
240
+ if (seen.has(mission.id)) return { error: `duplicate legacy mission id ${mission.id}`, missions: [], missionIds: [], paused: [], unresolved: [], dropped: [], remapped: [] }
162
241
  seen.add(mission.id)
163
- if (typeof mission.status !== 'string' || !statuses.includes(mission.status)) {
164
- return { error: `mission ${mission.id} has unknown status ${JSON.stringify(mission.status)}`, missions: [], missionIds: [], paused: [] }
242
+ if (typeof mission.status !== 'string' || !spec.statuses.includes(mission.status)) {
243
+ return { error: `mission ${mission.id} has unknown status ${JSON.stringify(mission.status)}`, missions: [], missionIds: [], paused: [], unresolved: [], dropped: [], remapped: [] }
244
+ }
245
+ const mappedStatus = spec.statusRemap[mission.status]
246
+ if (mappedStatus !== undefined) {
247
+ remapped.push({ missionId: mission.id, field: 'status', from: mission.status, to: mappedStatus })
248
+ }
249
+ const status = mappedStatus ?? mission.status
250
+ if (!DSH_STATUSES.includes(status)) {
251
+ return { error: `legacy status ${JSON.stringify(mission.status)} has no declared DSH mapping`, missions: [], missionIds: [], paused: [], unresolved: [], dropped: [], remapped: [] }
165
252
  }
166
- if (mission.kind !== undefined && mission.kind !== 'build' && mission.kind !== 'survey') {
167
- return { error: `mission ${mission.id} has unknown kind ${JSON.stringify(mission.kind)}`, missions: [], missionIds: [], paused: [] }
253
+ let kind = 'build'
254
+ if (mission.kind !== undefined) {
255
+ if (!isShapeKind(mission.kind)) {
256
+ return { error: `mission ${mission.id} has unknown kind ${JSON.stringify(mission.kind)}`, missions: [], missionIds: [], paused: [], unresolved: [], dropped: [], remapped: [] }
257
+ }
258
+ kind = mission.kind
259
+ } else {
260
+ unresolved.push({ missionId: mission.id, field: 'kind', reason: 'legacy mission has no kind; assumed build' })
168
261
  }
169
262
  for (const problem of [
170
263
  validateScope(mission.scope, mission.id),
@@ -172,13 +265,24 @@ function validateMissions(payload, format) {
172
265
  validateDeps(mission.deps, mission.id),
173
266
  validateReviews(mission.reviews, mission.id),
174
267
  ]) {
175
- if (problem) return { error: problem, missions: [], missionIds: [], paused: [] }
268
+ if (problem) return { error: problem, missions: [], missionIds: [], paused: [], unresolved: [], dropped: [], remapped: [] }
176
269
  }
177
- missions.push({ id: mission.id, status: mission.status, kind: mission.kind ?? 'build' })
270
+ for (const key of Object.keys(mission)) {
271
+ if (!CARRIED_MISSION_FIELDS.includes(key)) droppedFields.add(key)
272
+ }
273
+ missions.push({ id: mission.id, status, kind })
178
274
  missionIds.push(mission.id)
179
- if (mission.status === 'paused') paused.push(mission.id)
275
+ if (status === 'paused') paused.push(mission.id)
276
+ }
277
+ return {
278
+ missions,
279
+ missionIds,
280
+ paused,
281
+ unresolved,
282
+ dropped: [...droppedFields].sort().map((field) => ({ field, reason: DROPPED_REASON })),
283
+ remapped,
284
+ error: null,
180
285
  }
181
- return { missions, missionIds, paused, error: null }
182
286
  }
183
287
 
184
288
  /**
@@ -189,9 +293,12 @@ function validateMissions(payload, format) {
189
293
  *
190
294
  * @returns {{ok:boolean, format:'tower-codex'|'dsh'|null, schemaVersion:number|null,
191
295
  * missions:Array<{id:string,status:string,kind:string}>, missionIds:string[],
192
- * missionCount:number, paused:string[], events:number|null,
193
- * legacySnapshots:number|null, repoRoot:string|null, baseBranch:string|null,
194
- * error:string|null}}
296
+ * missionCount:number, paused:string[],
297
+ * unresolved:Array<{missionId:string, field:string, reason:string}>,
298
+ * dropped:Array<{field:string, reason:string}>,
299
+ * remapped:Array<{missionId:string, field:string, from:string, to:string}>,
300
+ * events:number|null, legacySnapshots:number|null, repoRoot:string|null,
301
+ * baseBranch:string|null, error:string|null}}
195
302
  */
196
303
  export function validateLegacySnapshot(payload) {
197
304
  if (!isPlainObject(payload)) return invalidResult('legacy snapshot must be a JSON object')
@@ -204,30 +311,26 @@ export function validateLegacySnapshot(payload) {
204
311
  }
205
312
  if (!Array.isArray(payload.missions)) return invalidResult('legacy snapshot has no missions array')
206
313
 
207
- /** @type {'tower-codex'|'dsh'} */
208
- let format = 'dsh'
209
- if (payload.missions.length === 0) {
210
- format = isNonEmptyString(payload.baseBranch) ? 'tower-codex' : 'dsh'
211
- } else {
212
- const firstId = payload.missions[0]?.id
213
- if (typeof firstId === 'string' && CODEX_ID_RE.test(firstId)) format = 'tower-codex'
214
- else if (typeof firstId === 'string' && DSH_ID_RE.test(firstId)) format = 'dsh'
215
- else {
216
- return invalidResult(
217
- `unsupported legacy mission id ${JSON.stringify(firstId)} (expected T#### for Tower Codex or M# for DSH)`,
218
- )
219
- }
314
+ const detected = detectFormat(payload)
315
+ if (detected.error || !detected.format) {
316
+ return invalidResult(detected.error ?? 'legacy snapshot format could not be determined')
220
317
  }
318
+ const format = detected.format
319
+ const spec = LEGACY_FORMATS[format]
221
320
 
222
321
  const rejected = (error) => invalidResult(error, format)
223
- const validated = validateMissions(payload, format)
322
+ const validated = validateMissions(payload, format, spec)
224
323
  if (validated.error) return rejected(validated.error)
225
324
 
226
325
  if (isNonEmptyString(payload.repoRoot) === false) {
227
326
  return rejected(`legacy ${format} snapshot has no repoRoot string`)
228
327
  }
229
- if (format === 'tower-codex' && isNonEmptyString(payload.baseBranch) === false) {
230
- return rejected('legacy tower-codex snapshot has no baseBranch string')
328
+ if (spec.requiresBaseBranch && isNonEmptyString(payload[spec.baseBranchField]) === false) {
329
+ return rejected(`legacy ${format} snapshot has no baseBranch string`)
330
+ }
331
+ const baseBranchValue = payload[spec.baseBranchField]
332
+ if (baseBranchValue !== undefined && baseBranchValue !== null && !isNonEmptyString(baseBranchValue)) {
333
+ return rejected(`legacy ${format} snapshot has no baseBranch string`)
231
334
  }
232
335
  if (payload.events !== undefined && !Array.isArray(payload.events)) {
233
336
  return rejected('legacy snapshot events must be an array')
@@ -247,10 +350,13 @@ export function validateLegacySnapshot(payload) {
247
350
  missionIds: validated.missionIds,
248
351
  missionCount: validated.missionIds.length,
249
352
  paused: validated.paused,
353
+ unresolved: validated.unresolved,
354
+ dropped: validated.dropped,
355
+ remapped: validated.remapped,
250
356
  events: Array.isArray(payload.events) ? payload.events.length : null,
251
357
  legacySnapshots: Array.isArray(payload.legacySnapshots) ? payload.legacySnapshots.length : null,
252
358
  repoRoot: payload.repoRoot.trim(),
253
- baseBranch: format === 'tower-codex' ? payload.baseBranch.trim() : (isNonEmptyString(payload.base) ? payload.base.trim() : null),
359
+ baseBranch: isNonEmptyString(baseBranchValue) ? baseBranchValue.trim() : null,
254
360
  error: null,
255
361
  }
256
362
  }
@@ -342,6 +448,9 @@ export async function importLegacySnapshot(input = {}) {
342
448
  missionIds: validation.missionIds,
343
449
  paused: validation.paused,
344
450
  pausedCount: validation.paused.length,
451
+ unresolved: validation.unresolved,
452
+ dropped: validation.dropped,
453
+ remapped: validation.remapped,
345
454
  events: validation.events,
346
455
  legacySnapshots: validation.legacySnapshots,
347
456
  sourcePath: resolvedSource,
@@ -362,6 +471,9 @@ export async function importLegacySnapshot(input = {}) {
362
471
  missionIds: validation.missionIds,
363
472
  missionCount: validation.missionCount,
364
473
  pausedMissionIds: validation.paused,
474
+ unresolved: validation.unresolved,
475
+ droppedFields: validation.dropped.map((entry) => entry.field),
476
+ remapped: validation.remapped,
365
477
  bytes: buffer.length,
366
478
  importedAt,
367
479
  }
@@ -15,6 +15,7 @@
15
15
  // `state.migrated {from,to}` to the activity log.
16
16
  // A migration that throws therefore leaves state.json byte-identical.
17
17
  import { inspectStateVersion, prepareState } from '../domain/state.js'
18
+ import { legacySurveyNeedsRead } from '../domain/state/migrations.js'
18
19
  import { isStateLockHeld } from './store.js'
19
20
 
20
21
  /**
@@ -37,6 +38,7 @@ import { isStateLockHeld } from './store.js'
37
38
  * now?: number,
38
39
  * persist?: boolean,
39
40
  * migrations?: readonly import('../domain/state/migrations.js').StateMigration[],
41
+ * resolveLegacySurvey?: (rawState: Record<string, any>) => Promise<import('../domain/state/migrations.js').LegacySurveyRead | null>,
40
42
  * }} input
41
43
  * @returns {Promise<StateLoadResult>}
42
44
  */
@@ -54,7 +56,15 @@ export async function loadStateFile(input) {
54
56
  await store.writeJsonAtomic(backupPath, raw)
55
57
  }
56
58
 
57
- const prepared = prepareState(raw, paths.state, { migrations })
59
+ // A legacy survey planned but never provisioned recorded no base at all, so
60
+ // the document alone cannot anchor it. Resolve the repository base exactly
61
+ // once, in the same moment as the migration, and hand it to the pure step.
62
+ const legacySurveyRead =
63
+ version.status === 'migratable' && legacySurveyNeedsRead(raw) && typeof input.resolveLegacySurvey === 'function'
64
+ ? await input.resolveLegacySurvey(raw)
65
+ : null
66
+
67
+ const prepared = prepareState(raw, paths.state, { migrations, legacySurveyRead })
58
68
  if (!prepared.changed) {
59
69
  return { state: prepared.state, version, migration: null }
60
70
  }
package/app/archive.js CHANGED
@@ -144,10 +144,34 @@ export async function sweepArchive(tower, state, options = {}) {
144
144
  force: options.force === true,
145
145
  protectedIds,
146
146
  })
147
- if (selected.length === 0) return { archived: [], written: false, error: null }
147
+ // #148 W2: never archive a mission whose disposable sandbox is still on disk.
148
+ // Archiving would move the only record of the path out of hot state while the
149
+ // checkout leaks; the mission stays hot (protected) until cleanup succeeds on
150
+ // a later mutate or tower_teardown, and this sweep names the caused path.
151
+ const sandboxPending = selected
152
+ .filter((mission) => typeof /** @type {any} */ (mission).sandbox === 'string' && /** @type {any} */ (mission).sandbox.length > 0)
153
+ .map((mission) => ({ missionId: mission.id, sandbox: /** @type {any} */ (mission).sandbox }))
154
+ const pendingIds = new Set(sandboxPending.map((entry) => entry.missionId))
155
+ const archivable = selected.filter((mission) => !pendingIds.has(mission.id))
156
+ for (const entry of sandboxPending) {
157
+ await tower.deps.store.appendJsonl(tower.paths.activity, {
158
+ at: now,
159
+ type: 'archive',
160
+ missionId: entry.missionId,
161
+ text: `deferred: detached sandbox at ${entry.sandbox} is still present; cleanup retries on tower_teardown`,
162
+ }).catch(() => {})
163
+ }
164
+ if (archivable.length === 0) {
165
+ return {
166
+ archived: [],
167
+ written: false,
168
+ error: null,
169
+ ...(sandboxPending.length > 0 ? { sandboxPending } : {}),
170
+ }
171
+ }
148
172
 
149
173
  const archivedYear = archiveYearFor(now)
150
- const entries = selected.map((mission) =>
174
+ const entries = archivable.map((mission) =>
151
175
  archivedEntry(mission, {
152
176
  archivedAt: now,
153
177
  archivedYear,
@@ -165,9 +189,9 @@ export async function sweepArchive(tower, state, options = {}) {
165
189
  if (!written.ok) return { archived: [], written: false, error: written.error ?? 'archive write failed' }
166
190
 
167
191
  // The archive now holds the full record: compact state.json in the same lock.
168
- const ids = new Set(selected.map((mission) => mission.id))
192
+ const ids = new Set(archivable.map((mission) => mission.id))
169
193
  const branches = new Set(
170
- selected
194
+ archivable
171
195
  .map((mission) => mission.worktree?.branch)
172
196
  .filter((branch) => typeof branch === 'string'),
173
197
  )
@@ -184,7 +208,7 @@ export async function sweepArchive(tower, state, options = {}) {
184
208
  state.revision += 1
185
209
  state.updatedAt = now
186
210
 
187
- for (const mission of selected) {
211
+ for (const mission of archivable) {
188
212
  await tower.deps.store.appendJsonl(tower.paths.activity, {
189
213
  at: now,
190
214
  type: 'archive',
@@ -199,5 +223,6 @@ export async function sweepArchive(tower, state, options = {}) {
199
223
  files: written.files,
200
224
  error: null,
201
225
  year: archivedYear,
226
+ ...(sandboxPending.length > 0 ? { sandboxPending } : {}),
202
227
  }
203
228
  }
package/app/briefing.js CHANGED
@@ -1,10 +1,34 @@
1
1
  // Mission briefings: pure text builders for worker/reviewer prompts.
2
+ import { shapeFor } from '../domain/shapes/index.js'
2
3
 
3
4
  function clip(text, max = 2000) {
4
5
  const value = String(text ?? '')
5
6
  return value.length > max ? `${value.slice(0, max)}\n… truncated` : value
6
7
  }
7
8
 
9
+ /**
10
+ * Dependency lines shared by the worker and reviewer briefings. A survey
11
+ * dependency carries its recorded result (#148 D6) alongside its status, with
12
+ * bounded formatting so a large evidence set cannot crowd out the mission.
13
+ * @param {any[]} deps
14
+ * @returns {string[]}
15
+ */
16
+ function dependencyLines(deps) {
17
+ const lines = []
18
+ for (const dep of deps) {
19
+ lines.push(`- ${dep.id} ${dep.title ?? ''} (${dep.status}${dep.kind ? `, ${dep.kind}` : ''})`)
20
+ const result = dep.result
21
+ if (!result || typeof result !== 'object') continue
22
+ const evidence = []
23
+ if (Array.isArray(result.paths) && result.paths.length > 0) evidence.push(`paths: ${result.paths.slice(0, 20).join(', ')}`)
24
+ if (Array.isArray(result.symbols) && result.symbols.length > 0) evidence.push(`symbols: ${result.symbols.slice(0, 20).join(', ')}`)
25
+ if (Array.isArray(result.plan) && result.plan.length > 0) evidence.push(`plan: ${clip(result.plan.slice(0, 20).join('; '), 1200)}`)
26
+ if (typeof result.notes === 'string' && result.notes.trim()) evidence.push(`notes: ${clip(result.notes.trim(), 1200)}`)
27
+ if (evidence.length > 0) lines.push(` evidence: ${evidence.join(' | ')}`)
28
+ }
29
+ return lines
30
+ }
31
+
8
32
  export function workerBriefing({
9
33
  repoRoot,
10
34
  base,
@@ -17,25 +41,44 @@ export function workerBriefing({
17
41
  completionReport = null,
18
42
  }) {
19
43
  const completion = /** @type {import('../types/index.js').CompletionReport | null} */ (completionReport)
44
+ const shape = shapeFor(mission.kind)
45
+ // The checkout the worker actually runs in: a recorded detached sandbox wins
46
+ // over the (absent) worktree, then a build worktree, then the shared main
47
+ // checkout. The spawn cwd uses the same precedence, so the prompt matches the
48
+ // process the agent is started in.
49
+ const cwd = mission.sandbox ?? worktree?.path ?? repoRoot
20
50
  const lines = []
21
51
  lines.push(`# Tower mission ${mission.id} — ${mission.title}`)
22
52
  lines.push('')
23
53
  lines.push(`You are the tower worker for mission ${mission.id} in repository ${repoRoot}.`)
24
54
  lines.push('')
25
- lines.push(`Work ONLY in this git worktree: ${worktree.path}`)
26
- lines.push(`Branch: ${worktree.branch} (based on ${base} @ ${String(worktree.baseCommit).slice(0, 12)})`)
27
- lines.push('')
28
- lines.push(`Kind: ${mission.kind}`)
29
55
  lines.push(
30
- `Scope (only files you may touch): ${mission.scope.length > 0 ? mission.scope.join(', ') : '(none — survey is read-only)'}`,
56
+ ...shape.briefing(mission, {
57
+ repoRoot,
58
+ base,
59
+ worktree,
60
+ readCommit: /** @type {string | undefined} */ (mission.readCommit),
61
+ readDirty: mission.readDirty,
62
+ }),
31
63
  )
64
+ lines.push('')
65
+ lines.push(`Kind: ${mission.kind}`)
66
+ // The empty scope means different things by shape: a build still owns its
67
+ // worktree, a detached survey owns a disposable writable sandbox, and only a
68
+ // shared survey is strictly read-only.
69
+ const emptyScope = shape.fields.worktree
70
+ ? '(none declared — stay inside your worktree)'
71
+ : shape.fields.sandbox && mission.sandbox
72
+ ? '(none declared — the detached survey sandbox is disposable: write and run commands inside it, but never commit or leave it)'
73
+ : '(none — survey is read-only)'
74
+ lines.push(`Scope (only files you may touch): ${mission.scope.length > 0 ? mission.scope.join(', ') : emptyScope}`)
32
75
  if (mission.tasks.length > 0) {
33
76
  lines.push('Tasks:')
34
77
  for (const task of mission.tasks) lines.push(`- [${task.status}] ${task.text}`)
35
78
  }
36
79
  if (deps.length > 0) {
37
80
  lines.push('Dependencies:')
38
- for (const dep of deps) lines.push(`- ${dep.id} ${dep.title ?? ''} (${dep.status})`)
81
+ lines.push(...dependencyLines(deps))
39
82
  }
40
83
  if (mission.context) {
41
84
  lines.push('Context (verbatim from the user):')
@@ -57,6 +100,11 @@ export function workerBriefing({
57
100
  for (const check of completion.checks ?? []) lines.push(`- ${check.name}${check.exitCode !== undefined ? ` (exit ${check.exitCode})` : ''}`)
58
101
  }
59
102
  }
103
+ if (mission.result) {
104
+ lines.push('')
105
+ lines.push('Recorded evidence (from a previous completion):')
106
+ lines.push(`- ${JSON.stringify(mission.result)}`)
107
+ }
60
108
  if (reviews.length > 0) {
61
109
  lines.push('')
62
110
  lines.push('Previous review rounds for this branch (newest first) — address every finding:')
@@ -73,16 +121,34 @@ export function workerBriefing({
73
121
  }
74
122
  lines.push('')
75
123
  lines.push('Rules:')
76
- lines.push(`- Pass workdir="${worktree.path}" on every shell command; never edit the main checkout.`)
77
- lines.push('- Stay inside the declared scope; out-of-scope changes are rejected later.')
78
- lines.push('- Commit your work on the mission branch. Do not merge, push, or spawn other agents.')
124
+ if (shape.fields.worktree) {
125
+ lines.push(`- Pass workdir="${cwd}" on every shell command; never edit the main checkout.`)
126
+ lines.push('- Stay inside the declared scope; out-of-scope changes are rejected later.')
127
+ lines.push('- Commit your work on the mission branch. Do not merge, push, or spawn other agents.')
128
+ } else if (shape.fields.sandbox && mission.sandbox) {
129
+ lines.push(`- Pass workdir="${mission.sandbox}" on every shell command; that checkout is a disposable detached sandbox, not the main checkout.`)
130
+ lines.push('- You may write files and run commands inside the sandbox, but never commit: completion requires the sandbox HEAD to still equal the recorded commit and zero new commits.')
131
+ lines.push(`- Stay inside the sandbox; never edit the main checkout ${repoRoot}. Do not merge, push, or spawn other agents.`)
132
+ } else {
133
+ lines.push(`- Read the shared checkout at ${cwd}; it is the main checkout, not your own worktree.`)
134
+ lines.push(
135
+ '- Make no commits: this mission reads one fixed commit. Do not write, edit, merge, push, rebase, or spawn other agents.',
136
+ )
137
+ }
79
138
  lines.push(
80
139
  `- Update progress: tower_mission({repoRoot:"${repoRoot}",id:"${mission.id}",task_done:"..."}) or blocker:"...".`,
81
140
  )
141
+ const resultHint = shape.acceptsResult
142
+ ? ' Optionally record evidence as result:{paths?:string[],symbols?:string[],plan?:string[],notes?:string}.'
143
+ : ''
144
+ lines.push(
145
+ `- When the work is finished and every task is closed, call tower_complete({repoRoot:"${repoRoot}",missionId:"${mission.id}",report:"<full handoff>",checks:[{name,command,exitCode,evidence}]}).${resultHint} Unread inbox messages block completion, so read them with tower_inbox first.`,
146
+ )
82
147
  lines.push(
83
- `- When the work is finished and every task is closed, call tower_complete({repoRoot:"${repoRoot}",missionId:"${mission.id}",report:"<full handoff>",checks:[{name,command,exitCode,evidence}]}). Unread inbox messages block completion, so read them with tower_inbox first.`,
148
+ shape.fields.worktree
149
+ ? '- Your final message is the full handoff: files changed, how you verified, what remains.'
150
+ : '- Your final message is the full handoff: what you inspected, how you verified it, what remains.',
84
151
  )
85
- lines.push('- Your final message is the full handoff: files changed, how you verified, what remains.')
86
152
  if (instructions) {
87
153
  lines.push('')
88
154
  lines.push('Extra tower instructions:')
@@ -91,12 +157,19 @@ export function workerBriefing({
91
157
  return lines.join('\n')
92
158
  }
93
159
 
160
+ /**
161
+ * @param {{ repoRoot: string, base: string, mission: any, deps: any[],
162
+ * worktree: any, review?: { commit: string, path: string } | null,
163
+ * instructions?: string, reviews?: any[], findings?: any[],
164
+ * completionReport?: any }} input
165
+ */
94
166
  export function reviewerBriefing({
95
167
  repoRoot,
96
168
  base,
97
169
  mission,
98
170
  deps,
99
171
  worktree,
172
+ review = null,
100
173
  instructions,
101
174
  reviews = [],
102
175
  findings = [],
@@ -111,6 +184,12 @@ export function reviewerBriefing({
111
184
  lines.push(`Review branch: ${worktree.branch}`)
112
185
  lines.push(`Worktree: ${worktree.path}`)
113
186
  lines.push(`Reviewed base: ${base} @ ${String(worktree.baseCommit).slice(0, 12)}`)
187
+ if (review) {
188
+ lines.push(`Review commit: ${String(review.commit).slice(0, 12)} (the evidence your verdict is stamped with)`)
189
+ lines.push(`Checkout: ${review.path}`)
190
+ lines.push('')
191
+ lines.push(`Read and inspect the review commit in ${review.path}; that checkout is removed after your verdict.`)
192
+ }
114
193
  lines.push('')
115
194
  lines.push(`Kind: ${mission.kind}`)
116
195
  if (mission.tasks.length > 0) {
@@ -119,7 +198,7 @@ export function reviewerBriefing({
119
198
  }
120
199
  if (deps.length > 0) {
121
200
  lines.push('Dependencies:')
122
- for (const dep of deps) lines.push(`- ${dep.id} ${dep.title ?? ''} (${dep.status})`)
201
+ lines.push(...dependencyLines(deps))
123
202
  }
124
203
  if (mission.context) {
125
204
  lines.push('Context (verbatim from the user):')
@@ -158,6 +237,10 @@ export function reviewerBriefing({
158
237
  lines.push('')
159
238
  lines.push('Rules:')
160
239
  lines.push('- Read-only: do not modify files, commit, or merge.')
240
+ if (review) {
241
+ lines.push(`- Pass workdir="${review.path}" on every command: the checkout is a detached read of commit ${String(review.commit).slice(0, 12)}, not the mutable mission worktree.`)
242
+ lines.push('- tower_diff shows the recorded commit your verdict will stamp; do not review anything newer.')
243
+ }
161
244
  lines.push('- Inspect the diff from the reviewed base to the branch tip and map every task to the diff.')
162
245
  lines.push('- Check scope, correctness, tests, and security. Report severity and concrete evidence.')
163
246
  lines.push(