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
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(
@@ -1,4 +1,5 @@
1
1
  // Worker completion preparation and commit use cases.
2
+ import path from 'node:path'
2
3
  import { TowerToolError } from '../domain/errors.js'
3
4
  import {
4
5
  normalizeChecks,
@@ -7,12 +8,79 @@ import {
7
8
  completionReport,
8
9
  applyCompletion,
9
10
  } from '../domain/missions/completion.js'
11
+ import { shapeFor } from '../domain/shapes/index.js'
10
12
  import { fullMission } from '../domain/summary.js'
11
13
  import { validateState } from '../domain/state.js'
12
14
  import { sweepArchive } from './archive.js'
15
+ import { cleanupSandbox } from './sandbox.js'
16
+ import { trackedFingerprint } from './sharedCheckout.js'
13
17
 
14
18
  export { normalizeChecks }
15
19
 
20
+ /**
21
+ * Read the primitives the shape's completion invariants need. Which reads happen
22
+ * is declared by the shape, so this layer never asks "is this a survey?"; a
23
+ * detached sandbox declares `requiresSandbox` and its HEAD/commit count is read
24
+ * from the recorded sandbox path instead of the shared checkout (#148 W2).
25
+ * @param {any} tower
26
+ * @param {import('../types/index.js').Mission} mission
27
+ * @param {any} invariants
28
+ * @returns {Promise<{ commits: number, clean: boolean, dirtyPaths: string[], head: string, trackedChanged: string[], readCommit?: string, sandbox?: string | null }>}
29
+ */
30
+ async function completionFacts(tower, mission, invariants) {
31
+ const worktree = /** @type {any} */ (mission.worktree)
32
+ const sandbox = invariants.requiresSandbox ? String(/** @type {any} */ (mission).sandbox ?? '') : ''
33
+ /** @type {{ commits: number, clean: boolean, dirtyPaths: string[], head: string, trackedChanged: string[], readCommit?: string, sandbox?: string | null }} */
34
+ const facts = { commits: 0, clean: true, dirtyPaths: [], head: '', trackedChanged: [], sandbox: sandbox || null }
35
+ if (invariants.requiresSandbox) {
36
+ if (!sandbox) {
37
+ throw new TowerToolError('invalid-argument', `mission ${mission.id} has no detached sandbox recorded; re-provision it or abandon the survey`)
38
+ }
39
+ const entries = await tower.deps.vcs.listWorktrees(tower.root)
40
+ if (!entries.some((entry) => entry.path && path.resolve(entry.path) === path.resolve(sandbox))) {
41
+ throw new TowerToolError(
42
+ 'invalid-argument',
43
+ `mission ${mission.id} detached sandbox ${sandbox} is not registered with git; the disposable checkout was removed — re-provision the survey`,
44
+ )
45
+ }
46
+ }
47
+ if (invariants.requiresWorktree) await tower.assertWorktree(worktree)
48
+ const cwd = sandbox || worktree?.path
49
+ if (invariants.requiresCommits) {
50
+ const base = invariants.requiresSandbox ? /** @type {string} */ (mission.readCommit) : worktree.baseCommit
51
+ facts.commits = await tower.deps.vcs.countCommits(cwd, base)
52
+ }
53
+ if (invariants.requiresClean) {
54
+ facts.clean = await tower.deps.vcs.isClean(cwd)
55
+ if (!facts.clean) {
56
+ facts.dirtyPaths = (await tower.git(cwd, ['status', '--porcelain']))
57
+ .split('\n')
58
+ .filter(Boolean)
59
+ .map((line) => line.slice(3).trim())
60
+ }
61
+ }
62
+ facts.readCommit = /** @type {string | undefined} */ (mission.readCommit)
63
+ if (invariants.requiresHeadMatch || invariants.requiresTrackedStatus) {
64
+ // A shared survey reads the shared checkout: HEAD and the tracked fingerprint
65
+ // must match the plan-time record exactly (a path that changed either way
66
+ // counts). A detached sandbox reads its own disposable checkout: only HEAD
67
+ // must still equal the recorded commit; writes there are expected.
68
+ facts.head = await tower.revParse(invariants.requiresSandbox ? sandbox : tower.root, 'HEAD')
69
+ if (invariants.requiresTrackedStatus) {
70
+ const current = trackedFingerprint(await tower.git(tower.root, ['status', '--porcelain']))
71
+ const planned = new Set(mission.readDirty ?? [])
72
+ const currentSet = new Set(current)
73
+ facts.trackedChanged = [
74
+ ...new Set([
75
+ ...current.filter((path) => !planned.has(path)),
76
+ ...(mission.readDirty ?? []).filter((path) => !currentSet.has(path)),
77
+ ]),
78
+ ].sort()
79
+ }
80
+ }
81
+ return facts
82
+ }
83
+
16
84
  export async function checkCompletion(tower, input) {
17
85
  const state = await tower.load()
18
86
  const mission = state.missions.find((entry) => entry.id === input.missionId)
@@ -25,24 +93,22 @@ export async function checkCompletion(tower, input) {
25
93
  input,
26
94
  maxReportBytes: tower.config.gates?.maxReportBytes,
27
95
  })
28
- await tower.assertWorktree(mission.worktree)
29
- const commits = await tower.deps.vcs.countCommits(mission.worktree.path, mission.worktree.baseCommit)
30
- if (commits < 1) {
31
- throw new TowerToolError(
32
- 'invalid-argument',
33
- `mission branch has no commits since ${String(mission.worktree.baseCommit).slice(0, 12)}`,
34
- )
96
+ const shape = shapeFor(mission.kind)
97
+ const invariants = /** @type {{
98
+ * requiresWorktree: boolean, requiresSandbox: boolean, requiresCommits: boolean,
99
+ * requiresClean: boolean, requiresHeadMatch: boolean, requiresTrackedStatus: boolean,
100
+ * check: (ctx: any) => { commits: number },
101
+ * }} */ ('resolveCompletion' in shape && typeof shape.resolveCompletion === 'function'
102
+ ? shape.resolveCompletion(mission, tower.config)
103
+ : shape.completion)
104
+ if (invariants.requiresWorktree && !mission.worktree) {
105
+ throw new TowerToolError('invalid-argument', `mission ${mission.id} has no worktree`)
35
106
  }
36
- if (!(await tower.deps.vcs.isClean(mission.worktree.path))) {
37
- const dirty = (await tower.git(mission.worktree.path, ['status', '--porcelain']))
38
- .split('\n')
39
- .filter(Boolean)
40
- .map((line) => line.slice(3).trim())
41
- throw new TowerToolError(
42
- 'worktree-dirty',
43
- `mission ${mission.id} worktree has uncommitted changes (${dirty.slice(0, 8).join(', ')}${dirty.length > 8 ? ', …' : ''}); commit, remove, or record them before tower_complete`,
44
- )
107
+ if (invariants.requiresSandbox && !/** @type {any} */ (mission).sandbox) {
108
+ throw new TowerToolError('invalid-argument', `mission ${mission.id} has no detached sandbox; re-provision it or abandon the survey`)
45
109
  }
110
+ const facts = await completionFacts(tower, mission, invariants)
111
+ const { commits } = invariants.check({ mission, ...facts })
46
112
  const unread = tower.unreadFor(state, worker.name)
47
113
  assertCompletionInbox({ mission, worker, unread })
48
114
  return { state, mission, worker, commits }
@@ -52,7 +118,7 @@ export async function complete(tower, input) {
52
118
  // Two-phase pilot (#51): expensive git/state checks run before the lock;
53
119
  // the lock only revalidates the state revision and commits the transition.
54
120
  const prepared = await tower.checkCompletion(input)
55
- return tower.deps.store.withLock(tower.paths, async () => {
121
+ const result = await tower.deps.store.withLock(tower.paths, async () => {
56
122
  const state = await tower.load()
57
123
  if (state.revision !== prepared.state.revision) {
58
124
  throw new TowerToolError('stale-base', 'tower state changed while completion was being prepared; retry')
@@ -69,7 +135,7 @@ export async function complete(tower, input) {
69
135
  now,
70
136
  maxReportBytes: tower.config.gates?.maxReportBytes,
71
137
  })
72
- const applied = applyCompletion({ mission, report: storedReport, now })
138
+ const applied = applyCompletion({ mission, report: storedReport, now, result: input.result })
73
139
  await sweepArchive(tower, state, { now })
74
140
  state.revision += 1
75
141
  state.updatedAt = now
@@ -85,4 +151,27 @@ export async function complete(tower, input) {
85
151
  tower.emitEvents(applied.events, { revision: state.revision, at: now })
86
152
  return { mission: fullMission(mission), commits, unread: 0 }
87
153
  })
154
+ // The completion transition is durable at this point. Removing the disposable
155
+ // sandbox is advisory: a failed cleanup is reported, never rolled back into
156
+ // the mission state or allowed to fail the completion.
157
+ let sandboxCleanup = null
158
+ if (result.mission.sandbox) {
159
+ const sandbox = result.mission.sandbox
160
+ try {
161
+ sandboxCleanup = await cleanupSandbox(tower, input.missionId)
162
+ } catch (error) {
163
+ sandboxCleanup = {
164
+ ok: false,
165
+ removed: false,
166
+ attempts: 0,
167
+ path: sandbox,
168
+ error: String(error?.message ?? error).slice(0, 400),
169
+ }
170
+ }
171
+ if (sandboxCleanup?.ok) {
172
+ delete result.mission.sandbox
173
+ delete result.mission.sandboxCleanupError
174
+ }
175
+ }
176
+ return { ...result, ...(sandboxCleanup ? { sandboxCleanup } : {}) }
88
177
  }
@@ -12,7 +12,7 @@ export async function enrichedMissions(tower, state, onlyIds) {
12
12
  let tipMoved = false
13
13
  if (latest && mission.worktree && (await tower.deps.store.exists(mission.worktree.path))) {
14
14
  try {
15
- const tip = await tower.deps.vcs.revParse(mission.worktree.path, 'HEAD')
15
+ const tip = await tower.branchTip(mission)
16
16
  tipMoved = tip !== latest.reviewedCommit
17
17
  } catch {
18
18
  tipMoved = false
@@ -0,0 +1,77 @@
1
+ // Dependency freshness (#148 W2 / D5): a build that depends on a survey wants
2
+ // the survey's evidence to describe the code it starts from. A survey records
3
+ // the exact commit it read; when that commit is not an ancestor-or-equal of the
4
+ // build's base, the evidence may be stale. This is advisory by default and a
5
+ // blocker only when `gates.requireFreshSurvey` is true.
6
+ // @ts-check
7
+ import { shapeFor } from '../domain/shapes/index.js'
8
+
9
+ /**
10
+ * @typedef {import('../types/index.js').Mission} Mission
11
+ * @typedef {import('../types/index.js').TowerState} TowerState
12
+ */
13
+
14
+ /**
15
+ * One stable, self-describing freshness warning. The same shape is promoted to a
16
+ * blocker (with `detail` carrying the message) when the gate asks for it.
17
+ * @param {{ missionId: string, dep: { id: string }, readCommit: string, baseCommit: string }} input
18
+ * @returns {{ code: 'survey-stale', missionId: string, depId: string, readCommit: string, baseCommit: string, message: string }}
19
+ */
20
+ export function surveyStaleWarning({ missionId, dep, readCommit, baseCommit }) {
21
+ const read = String(readCommit).slice(0, 12)
22
+ const base = String(baseCommit).slice(0, 12)
23
+ return {
24
+ code: 'survey-stale',
25
+ missionId,
26
+ depId: dep.id,
27
+ readCommit,
28
+ baseCommit,
29
+ message: `survey ${dep.id} read ${read}, which is not an ancestor of ${missionId}'s base ${base}; the survey evidence may not describe the code this mission starts from — re-plan the survey against the current base, or record why the read is still valid`,
30
+ }
31
+ }
32
+
33
+ /**
34
+ * Compute `survey-stale` warnings for one build mission's survey dependencies.
35
+ * A survey dependency is recognised through the shape contract (`fields.readCommit`),
36
+ * not a kind literal. The base is `mission.baseCommit` for a provisioned build,
37
+ * otherwise the current state base (resolved once by the caller or here).
38
+ * @param {any} tower
39
+ * @param {object} input
40
+ * @param {Mission} input.mission
41
+ * @param {TowerState} input.state
42
+ * @param {Mission[]} [input.missions]
43
+ * @param {Map<string, { id?: string, status: string, kind?: string, readCommit?: string }>} [input.archived]
44
+ * @param {string | null} [input.baseCommit] already-resolved build base
45
+ * @returns {Promise<Array<ReturnType<typeof surveyStaleWarning>>>}
46
+ */
47
+ export async function surveyStaleWarnings(tower, { mission, state, missions, archived = new Map(), baseCommit = null }) {
48
+ if (!shapeFor(mission.kind).fields.worktree) return []
49
+ const deps = mission.deps ?? []
50
+ if (deps.length === 0) return []
51
+ let base = baseCommit ?? /** @type {any} */ (mission).baseCommit ?? null
52
+ if (typeof base !== 'string' || base.length === 0) {
53
+ try {
54
+ base = await tower.revParse(tower.root, state.base)
55
+ } catch {
56
+ return []
57
+ }
58
+ }
59
+ const byId = new Map((missions ?? state.missions).map((entry) => [entry.id, entry]))
60
+ /** @type {Array<ReturnType<typeof surveyStaleWarning>>} */
61
+ const warnings = []
62
+ for (const depId of deps) {
63
+ const dep = byId.get(depId) ?? archived.get(depId)
64
+ if (!dep || !shapeFor(dep.kind).fields.readCommit) continue
65
+ const readCommit = typeof /** @type {any} */ (dep).readCommit === 'string' ? /** @type {any} */ (dep).readCommit : null
66
+ if (!readCommit) continue
67
+ let fresh = false
68
+ try {
69
+ fresh = await tower.isAncestor(tower.root, readCommit, base)
70
+ } catch {
71
+ fresh = false
72
+ }
73
+ if (fresh) continue
74
+ warnings.push(surveyStaleWarning({ missionId: mission.id, dep: /** @type {{ id: string }} */ (dep), readCommit, baseCommit: base }))
75
+ }
76
+ return warnings
77
+ }
package/app/gate.js CHANGED
@@ -3,6 +3,7 @@ import { ROSTER_INACTIVE_STATES } from '../domain/roster.js'
3
3
  import { completionBlockers, mergeGateBlockers } from '../domain/gate.js'
4
4
  import { fullMission } from '../domain/summary.js'
5
5
  import { latestReview, reviewsForBranch } from '../domain/integration/review.js'
6
+ import { assertActionSupported } from '../domain/shapes/index.js'
6
7
  import {
7
8
  baseDirtyBlocker,
8
9
  baseWrongBranchBlocker,
@@ -14,8 +15,8 @@ import {
14
15
  outOfScopeBlocker,
15
16
  overwrittenPaths,
16
17
  staleBaseBlocker,
17
- surveyChangedFilesBlocker,
18
18
  } from '../domain/integration/gate.js'
19
+ import { surveyStaleWarnings } from './freshness.js'
19
20
 
20
21
  export async function evaluateGate(tower, input) {
21
22
  const state = await tower.load()
@@ -31,6 +32,9 @@ export async function evaluateGate(tower, input) {
31
32
  }
32
33
  }
33
34
  if (!mission) return missingMissionGateResult(input.missionId)
35
+ // A shape without a branch has no merge gate at all; refuse before doing
36
+ // any git work so the caller gets one actionable code (#148 D4).
37
+ assertActionSupported(mission, input.action === 'merge' ? 'merge' : 'preflight')
34
38
  const archiveIndex = await tower.archiveIndex()
35
39
  const archivedMissions = new Map(
36
40
  (archiveIndex.ok ? archiveIndex.missions : []).map((entry) => [entry.id, entry]),
@@ -56,7 +60,7 @@ export async function evaluateGate(tower, input) {
56
60
  } else {
57
61
  try {
58
62
  await tower.assertWorktree(worktree)
59
- branchTip = await tower.revParse(worktree.path, 'HEAD')
63
+ branchTip = await tower.branchTip(mission)
60
64
  baseTip = await tower.revParse(tower.root, state.base)
61
65
  const mergeBase = (await tower.git(worktree.path, ['merge-base', baseTip, branchTip])).trim()
62
66
  changedFiles = (await tower.git(worktree.path, ['diff', '--name-only', `${mergeBase}..${branchTip}`]))
@@ -66,20 +70,14 @@ export async function evaluateGate(tower, input) {
66
70
  const blocker = staleBaseBlocker({ branch: worktree.branch, base: state.base, baseTip })
67
71
  push(blocker.code, blocker.detail)
68
72
  }
69
- if (mission.kind === 'survey') {
70
- const blocker = surveyChangedFilesBlocker({ missionId: mission.id, changedFiles })
71
- if (blocker) push(blocker.code, blocker.detail)
72
- }
73
- if (mission.kind !== 'survey') {
74
- const blocker = outOfScopeBlocker({ mission, changedFiles, caseInsensitive: state.caseInsensitive === true })
75
- if (blocker) push(blocker.code, blocker.detail)
76
- }
73
+ const scopeBlocker = outOfScopeBlocker({ mission, changedFiles, caseInsensitive: state.caseInsensitive === true })
74
+ if (scopeBlocker) push(scopeBlocker.code, scopeBlocker.detail)
77
75
  const dirty = (await tower.git(tower.root, ['status', '--porcelain']))
78
76
  .split('\n')
79
77
  .filter(Boolean)
80
78
  .map((line) => line.slice(3).trim())
81
- const blocker = baseDirtyBlocker(overwrittenPaths(changedFiles, dirty))
82
- if (blocker) push(blocker.code, blocker.detail)
79
+ const dirtyBlocker = baseDirtyBlocker(overwrittenPaths(changedFiles, dirty))
80
+ if (dirtyBlocker) push(dirtyBlocker.code, dirtyBlocker.detail)
83
81
  } catch (error) {
84
82
  push('branch-missing', String(error?.message ?? error))
85
83
  }
@@ -89,6 +87,21 @@ export async function evaluateGate(tower, input) {
89
87
  push(blocker.code, blocker.detail)
90
88
  }
91
89
 
90
+ // #148 W2: survey evidence freshness is advisory by default. The stable
91
+ // warning shape is what plan/preflight report; `gates.requireFreshSurvey`
92
+ // promotes the same code to a blocker for repositories that need the read
93
+ // to describe the exact base they start from.
94
+ const sandboxWarnings = await surveyStaleWarnings(tower, {
95
+ mission,
96
+ state,
97
+ missions: state.missions,
98
+ archived: archivedMissions,
99
+ baseCommit: mission.baseCommit ?? null,
100
+ })
101
+ if (tower.config.gates?.requireFreshSurvey === true) {
102
+ for (const warning of sandboxWarnings) blockers.push({ ...warning, detail: warning.message })
103
+ }
104
+
92
105
  const branch = worktree ? worktree.branch : null
93
106
  const latest = latestReview(reviewsForBranch(reviews, branch))
94
107
  // #140: `mergeGateBlockers` is pure and may not read the component registry, so
@@ -132,6 +145,7 @@ export async function evaluateGate(tower, input) {
132
145
  review: latest,
133
146
  blockers,
134
147
  }),
148
+ warnings: sandboxWarnings,
135
149
  ...(archivedEntry ? { archived: true } : {}),
136
150
  }
137
151
  }
package/app/merge.js CHANGED
@@ -18,6 +18,8 @@
18
18
  // prepare stale, so the whole prepare is retried, never partially applied.
19
19
  import path from 'node:path'
20
20
  import { validateState } from '../domain/state.js'
21
+ import { assertActionSupported } from '../domain/shapes/index.js'
22
+ import { markBlocked } from '../domain/missions/lifecycle.js'
21
23
  import {
22
24
  alreadyMergedResult,
23
25
  applyLandedMerge,
@@ -134,7 +136,7 @@ async function observeLandedMerge(tower, state, mission) {
134
136
  let branchTip
135
137
  try {
136
138
  baseTip = await tower.revParse(tower.root, state.base)
137
- branchTip = await tower.revParse(mission.worktree.path, 'HEAD')
139
+ branchTip = await tower.branchTip(mission)
138
140
  } catch {
139
141
  return null
140
142
  }
@@ -238,6 +240,8 @@ async function prepareFromState(tower, input, deps) {
238
240
  if (archived.ok && archived.entry) mission = archived.entry.mission
239
241
  }
240
242
  if (!mission) return { result: missionMissingResult(input.missionId) }
243
+ // A branchless shape has no merge to prepare; refuse before any git work.
244
+ assertActionSupported(mission, 'merge')
241
245
 
242
246
  // Reconcile a stale merge intent: Git runs here, outside every state lock.
243
247
  if (state.mergeIntent && state.mergeIntent.missionId === mission.id) {
@@ -272,7 +276,7 @@ async function prepareFromState(tower, input, deps) {
272
276
  // Capture the exact commits this merge is allowed to integrate. The merge
273
277
  // command below uses the captured branch SHA, never the mutable branch name.
274
278
  const baseTip = await tower.revParse(tower.root, 'HEAD')
275
- const branchTip = await tower.revParse(worktree.path, 'HEAD')
279
+ const branchTip = await tower.branchTip(mission)
276
280
  const drift = gateTipDrift({
277
281
  missionId: mission.id,
278
282
  gateBaseTip: gate.baseTip,
@@ -522,8 +526,7 @@ async function commitMerge(tower, plan, recorded, outcome) {
522
526
  const now = Date.now()
523
527
  if (intentActive) delete state.mergeIntent
524
528
  if (conflict) {
525
- mission.status = 'blocked'
526
- mission.blockers.push(mergeConflictNote(outcome.detail))
529
+ markBlocked(mission, mergeConflictNote(outcome.detail), now)
527
530
  } else {
528
531
  mission.notes.push(mergeHookFailureNote(outcome.detail, mission.status))
529
532
  }
package/app/mission.js CHANGED
@@ -4,6 +4,7 @@ import { applyMissionUpdate } from '../domain/missions/lifecycle.js'
4
4
  import { validateState } from '../domain/state.js'
5
5
  import { fullMission } from '../domain/summary.js'
6
6
  import { sweepArchive } from './archive.js'
7
+ import { cleanupSandbox } from './sandbox.js'
7
8
 
8
9
  function fail(message, code = 'invalid-argument') {
9
10
  throw new TowerToolError(code, message)
@@ -12,7 +13,7 @@ function fail(message, code = 'invalid-argument') {
12
13
  export async function updateMission(tower, input = {}) {
13
14
  const missionId = typeof input.id === 'string' ? input.id.trim() : input.id
14
15
  if (typeof missionId !== 'string' || missionId.length === 0) fail('tower_mission requires id')
15
- return tower.deps.store.withLock(tower.paths, async () => {
16
+ const result = await tower.deps.store.withLock(tower.paths, async () => {
16
17
  const state = await tower.load()
17
18
  const mission = state.missions.find((entry) => entry.id === missionId)
18
19
  if (!mission) fail(`unknown mission "${missionId}"`, 'mission-not-found')
@@ -34,4 +35,26 @@ export async function updateMission(tower, input = {}) {
34
35
  tower.emitEvents(events, { revision: state.revision, at: now })
35
36
  return { revision: state.revision, mission: fullMission(mission) }
36
37
  })
38
+ // Abandoning a detached survey disposes its sandbox. The state transition is
39
+ // already durable; cleanup is advisory and reports rather than throws.
40
+ let sandboxCleanup = null
41
+ if (result.mission.status === 'abandoned' && result.mission.sandbox) {
42
+ const sandbox = result.mission.sandbox
43
+ try {
44
+ sandboxCleanup = await cleanupSandbox(tower, missionId)
45
+ } catch (error) {
46
+ sandboxCleanup = {
47
+ ok: false,
48
+ removed: false,
49
+ attempts: 0,
50
+ path: sandbox,
51
+ error: String(error?.message ?? error).slice(0, 400),
52
+ }
53
+ }
54
+ if (sandboxCleanup?.ok) {
55
+ delete result.mission.sandbox
56
+ delete result.mission.sandboxCleanupError
57
+ }
58
+ }
59
+ return { ...result, ...(sandboxCleanup ? { sandboxCleanup } : {}) }
37
60
  }
@@ -10,5 +10,6 @@ export async function missionContext(tower, missionId) {
10
10
  const state = await tower.load()
11
11
  const mission = state.missions.find((entry) => entry.id === missionId)
12
12
  if (!mission) fail(`unknown mission "${missionId}"`, 'mission-not-found')
13
- return missionContextView({ state, mission })
13
+ const archived = await tower.archiveIndex()
14
+ return missionContextView({ state, mission, archived: archived.ok ? archived.missions : [] })
14
15
  }