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
package/app/provision.js CHANGED
@@ -1,20 +1,30 @@
1
1
  // `tower_provision` use case, split into three phases (#51):
2
2
  //
3
3
  // prepare — mission/base commit/worktree identity, all lock-free.
4
- // act — revalidate the prepared revision, then `git worktree add` (and
5
- // its supporting exclude/canonical reads) under the git mutex.
4
+ // act — revalidate the prepared revision, then the git mutation (and its
5
+ // supporting exclude/canonical reads) under the git mutex.
6
6
  // commit — one short state lock revalidates the revision and that the
7
7
  // mission still has no worktree, then records it. A rejected
8
- // commit rolls the created worktree/branch back outside the lock.
8
+ // commit rolls the created worktree/branch (or survey sandbox)
9
+ // back outside the lock.
10
+ //
11
+ // #148 W2: a survey resolves its placement through its shape. `shared` keeps the
12
+ // old no-provisioning path; `detached` creates a disposable detached worktree at
13
+ // the recorded readCommit (no branch, no baseCommit, never merged).
9
14
  import path from 'node:path'
10
15
  import { validateState } from '../domain/state.js'
11
16
  import { TowerToolError } from '../domain/errors.js'
17
+ import { provisioningFor } from '../domain/shapes/index.js'
12
18
  import {
13
19
  baseCommitChoice,
14
20
  createdWorktreeResult,
21
+ detachedSandboxResult,
15
22
  existingWorktreeResult,
16
23
  hasMissionId,
17
24
  provisionActivityText,
25
+ sandboxActivityText,
26
+ sandboxPath,
27
+ sharedProvisionResult,
18
28
  worktreeBranch,
19
29
  worktreePath,
20
30
  worktreeRecord,
@@ -25,11 +35,76 @@ function fail(message, code = 'invalid-argument') {
25
35
  throw new TowerToolError(code, message)
26
36
  }
27
37
 
28
- /** Phase 1 of `Tower.provision` (#51). An existing worktree returns here. */
38
+ /**
39
+ * Resolve the parent directory for mission checkouts and refuse an unsafe one.
40
+ * @param {any} tower
41
+ * @param {any} input
42
+ * @returns {string}
43
+ */
44
+ export function resolveCheckoutRoot(tower, input) {
45
+ const root = worktreeRoot({
46
+ worktreeRoot: input.worktreeRoot,
47
+ configuredRoot: tower.config.worktrees.root,
48
+ repoRoot: tower.root,
49
+ commonDirHash: tower.deps.ids.shortHash(tower.commonDir),
50
+ })
51
+ if (!path.isAbsolute(root)) fail('worktreeRoot must be an absolute path')
52
+ tower.assertOutside(root)
53
+ return root
54
+ }
55
+
56
+ /**
57
+ * True when `mission.sandbox` is a worktree registered with git and still at the
58
+ * recorded readCommit. A recorded path that git no longer knows is treated as
59
+ * gone so a failed cleanup does not wedge a re-provision; a registered sandbox
60
+ * whose HEAD moved is a refusal (its contents are the worker's work, not ours
61
+ * to discard silently).
62
+ * @param {any} tower
63
+ * @param {import('../types/index.js').Mission} mission
64
+ * @returns {Promise<boolean>}
65
+ */
66
+ async function sandboxIsCurrent(tower, mission) {
67
+ const sandbox = /** @type {any} */ (mission).sandbox
68
+ if (typeof sandbox !== 'string' || sandbox.length === 0) return false
69
+ const entries = await tower.deps.vcs.listWorktrees(tower.root)
70
+ const registered = entries.some((entry) => path.resolve(entry.path) === path.resolve(sandbox))
71
+ if (!registered) return false
72
+ const head = await tower.revParse(sandbox, 'HEAD')
73
+ if (head !== mission.readCommit) {
74
+ fail(
75
+ `detached sandbox ${sandbox} is at ${String(head).slice(0, 12)}, not the recorded readCommit ${String(mission.readCommit).slice(0, 12)}; inspect it or remove it before provisioning again`,
76
+ )
77
+ }
78
+ return true
79
+ }
80
+
81
+ /** Phase 1 of `Tower.provision` (#51). An existing checkout returns here. */
29
82
  async function prepareProvision(tower, input) {
30
83
  const state = await tower.load()
31
84
  const mission = state.missions.find((entry) => entry.id === input.missionId)
32
85
  if (!mission) fail(`unknown mission "${input.missionId}"`, 'mission-not-found')
86
+ const provisioning = provisioningFor(mission.kind, tower.config, mission)
87
+ // A shared-checkout shape reads one commit in the main checkout: no branch,
88
+ // no directory, no recorded worktree (#148 D1/D2).
89
+ if (provisioning === 'shared') {
90
+ return { early: sharedProvisionResult(mission, tower.root) }
91
+ }
92
+ // #148 W2: a detached survey owns a disposable checkout, not a mission
93
+ // branch. The recorded readCommit is the only base it has.
94
+ if (provisioning === 'detached') {
95
+ const readCommit = mission.readCommit
96
+ if (typeof readCommit !== 'string' || readCommit.length === 0) {
97
+ fail('a detached survey needs the commit it reads (readCommit); re-plan the survey from a resolved base', 'invalid-argument')
98
+ }
99
+ if (await sandboxIsCurrent(tower, mission)) {
100
+ return { early: detachedSandboxResult(mission, /** @type {string} */ (mission.sandbox), false) }
101
+ }
102
+ const root = resolveCheckoutRoot(tower, input)
103
+ await tower.deps.store.ensureDir(root)
104
+ const wtPath = sandboxPath(root, mission.id)
105
+ if (await tower.deps.store.exists(wtPath)) fail(`sandbox path already exists: ${wtPath}`)
106
+ return { stateRevision: state.revision, missionId: mission.id, mode: 'detached', readCommit, wtPath }
107
+ }
33
108
  if (mission.worktree) {
34
109
  await tower.assertWorktree(mission.worktree)
35
110
  return { early: existingWorktreeResult(mission, mission.worktree) }
@@ -47,14 +122,7 @@ async function prepareProvision(tower, input) {
47
122
  })
48
123
  const baseCommit = 'commit' in choice ? choice.commit : await tower.revParse(tower.root, state.base)
49
124
  const branch = worktreeBranch(mission)
50
- const root = worktreeRoot({
51
- worktreeRoot: input.worktreeRoot,
52
- configuredRoot: tower.config.worktrees.root,
53
- repoRoot: tower.root,
54
- commonDirHash: tower.deps.ids.shortHash(tower.commonDir),
55
- })
56
- if (!path.isAbsolute(root)) fail('worktreeRoot must be an absolute path')
57
- tower.assertOutside(root)
125
+ const root = resolveCheckoutRoot(tower, input)
58
126
  await tower.deps.store.ensureDir(root)
59
127
  const wtPath = worktreePath(root, mission.id)
60
128
  if (await tower.deps.store.exists(wtPath)) fail(`worktree path already exists: ${wtPath}`)
@@ -64,7 +132,7 @@ async function prepareProvision(tower, input) {
64
132
  return { stateRevision: state.revision, missionId: mission.id, baseCommit, branch, wtPath }
65
133
  }
66
134
 
67
- /** Phase 2 of `Tower.provision` (#51): no state lock, worktree add under the git mutex. */
135
+ /** Phase 2 of `Tower.provision` (#51): no state lock, git work under the mutex. */
68
136
  async function actProvision(tower, prepared) {
69
137
  return tower.deps.store.withGitLock(tower.paths, async () => {
70
138
  let fresh
@@ -78,6 +146,17 @@ async function actProvision(tower, prepared) {
78
146
  }
79
147
  const mission = fresh.missions.find((entry) => entry.id === prepared.missionId)
80
148
  if (!mission) return { phase: 'missing' }
149
+ if (prepared.mode === 'detached') {
150
+ if (await sandboxIsCurrent(tower, mission)) {
151
+ return { phase: 'existing-sandbox', mission, sandbox: /** @type {any} */ (mission).sandbox }
152
+ }
153
+ if (await tower.deps.store.exists(prepared.wtPath)) fail(`sandbox path already exists: ${prepared.wtPath}`)
154
+ // `--detach` creates no branch: the sandbox is a read-only commit checkout
155
+ // that the agent may dirty, and it never participates in merge/stale-base.
156
+ await tower.git(tower.root, ['worktree', 'add', '--detach', prepared.wtPath, prepared.readCommit])
157
+ await tower.ensureExcluded(prepared.wtPath)
158
+ return { phase: 'created-sandbox', sandbox: prepared.wtPath }
159
+ }
81
160
  if (mission.worktree) {
82
161
  await tower.assertWorktree(mission.worktree)
83
162
  return { phase: 'existing', mission, worktree: mission.worktree }
@@ -99,7 +178,7 @@ async function actProvision(tower, prepared) {
99
178
  }, tower.gitMutexOptions({ phase: 'provision' }))
100
179
  }
101
180
 
102
- /** Phase 3 of `Tower.provision` (#51): record the worktree under a short lock. */
181
+ /** Phase 3 of `Tower.provision` (#51): record the checkout under a short lock. */
103
182
  async function commitProvision(tower, prepared, acted) {
104
183
  return tower.deps.store.withLock(tower.paths, async () => {
105
184
  const state = await tower.load()
@@ -107,9 +186,30 @@ async function commitProvision(tower, prepared, acted) {
107
186
  if (!mission) throw new TowerToolError('mission-not-found', `unknown mission "${prepared.missionId}"`)
108
187
  // Revision revalidation: the plan was built from prepared.stateRevision.
109
188
  if (state.revision !== prepared.stateRevision) return { phase: 'stale', revision: state.revision }
189
+ const now = Date.now()
190
+ if (prepared.mode === 'detached') {
191
+ // Never overwrite a sandbox another writer recorded while git ran.
192
+ if (mission.sandbox) return { phase: 'existing-sandbox', mission, sandbox: mission.sandbox }
193
+ mission.sandbox = acted.sandbox
194
+ mission.updatedAt = now
195
+ state.revision += 1
196
+ state.updatedAt = now
197
+ try {
198
+ validateState(state, tower.paths.state)
199
+ await tower.deps.store.writeJsonAtomic(tower.paths.state, state)
200
+ } catch (error) {
201
+ return { phase: 'persist-error', error }
202
+ }
203
+ await tower.deps.store.appendJsonl(tower.paths.activity, {
204
+ at: now,
205
+ type: 'sandbox',
206
+ missionId: prepared.missionId,
207
+ text: sandboxActivityText(acted.sandbox, prepared.readCommit),
208
+ }).catch(() => {})
209
+ return { phase: 'committed-sandbox', mission, sandbox: acted.sandbox }
210
+ }
110
211
  // Never overwrite a worktree another writer recorded while git ran.
111
212
  if (mission.worktree) return { phase: 'existing', mission, worktree: mission.worktree }
112
- const now = acted.worktree.createdAt
113
213
  mission.worktree = acted.worktree
114
214
  mission.baseCommit = prepared.baseCommit
115
215
  mission.updatedAt = now
@@ -131,11 +231,13 @@ async function commitProvision(tower, prepared, acted) {
131
231
  })
132
232
  }
133
233
 
134
- /** Undo a created worktree/branch outside the state lock (git mutex only). */
234
+ /** Undo a created checkout outside the state lock (git mutex only). */
135
235
  async function rollbackProvision(tower, prepared) {
136
236
  await tower.deps.store.withGitLock(tower.paths, async () => {
137
237
  await tower.git(tower.root, ['worktree', 'remove', '--force', prepared.wtPath]).catch(() => {})
138
- await tower.git(tower.root, ['branch', '-D', prepared.branch]).catch(() => {})
238
+ if (prepared.mode !== 'detached' && prepared.branch) {
239
+ await tower.git(tower.root, ['branch', '-D', prepared.branch]).catch(() => {})
240
+ }
139
241
  }, tower.gitMutexOptions({ phase: 'provision' }))
140
242
  }
141
243
 
@@ -150,6 +252,9 @@ export async function provision(tower, input = {}) {
150
252
 
151
253
  const acted = await actProvision(tower, prepared)
152
254
  if (acted.phase === 'existing') return existingWorktreeResult(acted.mission, acted.worktree)
255
+ if (acted.phase === 'existing-sandbox') {
256
+ return detachedSandboxResult(acted.mission, acted.sandbox, false)
257
+ }
153
258
  if (acted.phase === 'missing') fail(`unknown mission "${prepared.missionId}"`, 'mission-not-found')
154
259
  if (acted.phase === 'stale') {
155
260
  throw new TowerToolError(
@@ -162,8 +267,8 @@ export async function provision(tower, input = {}) {
162
267
  try {
163
268
  committed = await commitProvision(tower, prepared, acted)
164
269
  } catch (error) {
165
- // The worktree already exists. A commit that cannot even take the state
166
- // lock must not leave the mission wedged with an unrecorded worktree.
270
+ // The checkout already exists. A commit that cannot even take the state
271
+ // lock must not leave the mission wedged with an unrecorded checkout.
167
272
  await rollbackProvision(tower, prepared)
168
273
  throw error
169
274
  }
@@ -174,18 +279,24 @@ export async function provision(tower, input = {}) {
174
279
  `tower state changed while the worktree was being created (${prepared.stateRevision} -> ${committed.revision}); the new worktree was rolled back — retry`,
175
280
  )
176
281
  }
177
- if (committed.phase === 'existing') {
178
- // Another writer recorded a worktree for this mission while git ran; the
282
+ if (committed.phase === 'existing' || committed.phase === 'existing-sandbox') {
283
+ // Another writer recorded a checkout for this mission while git ran; the
179
284
  // one created here is orphaned, so remove it and report the recorded one.
180
285
  await rollbackProvision(tower, prepared)
286
+ if (committed.phase === 'existing-sandbox') {
287
+ return detachedSandboxResult(committed.mission, committed.sandbox, false)
288
+ }
181
289
  await tower.assertWorktree(committed.worktree)
182
290
  return existingWorktreeResult(committed.mission, committed.worktree)
183
291
  }
184
292
  if (committed.phase === 'persist-error') {
185
293
  // Preserve the single-phase rollback: a failed state write must not leave
186
- // an untracked worktree/branch behind.
294
+ // an untracked worktree/branch (or survey sandbox) behind.
187
295
  await rollbackProvision(tower, prepared)
188
296
  throw committed.error
189
297
  }
298
+ if (committed.phase === 'committed-sandbox') {
299
+ return detachedSandboxResult(committed.mission, committed.sandbox, true)
300
+ }
190
301
  return createdWorktreeResult(committed.mission, committed.worktree)
191
302
  }
package/app/read.js CHANGED
@@ -1,5 +1,7 @@
1
1
  // Read-only dashboard/detail use cases.
2
2
  import { TowerToolError } from '../domain/errors.js'
3
+ import { isReviewerRole } from '../domain/roles.js'
4
+ import { assertActionSupported } from '../domain/shapes/index.js'
3
5
  import { diffView, missionDetailView, statusView } from '../domain/read/views.js'
4
6
  import { archiveView, archivedSummary } from './archive.js'
5
7
 
@@ -7,6 +9,33 @@ function fail(message, code = 'invalid-argument') {
7
9
  throw new TowerToolError(code, message)
8
10
  }
9
11
 
12
+ /**
13
+ * #149 W3: a reviewer diffs the commit recorded on its roster row — the same
14
+ * commit its verdict is stamped with — never the worker's live worktree HEAD.
15
+ * The checkout is only trusted while its HEAD still equals that commit.
16
+ * @returns {Promise<{ commit: string, cwd: string } | null>}
17
+ */
18
+ async function reviewerBinding(tower, state, mission, actor) {
19
+ if (!actor || !isReviewerRole(actor.role) || !actor.agentId) return null
20
+ const row = (state.roster ?? []).find((entry) => entry.agentId === actor.agentId)
21
+ if (!row || typeof row.reviewCommit !== 'string' || row.reviewCommit.length === 0) {
22
+ fail(
23
+ `reviewer ${row?.name ?? actor.name} has no recorded review commit; spawn reviewers through tower_spawn so a verdict is bound to a commit`,
24
+ )
25
+ }
26
+ if (typeof row.reviewCheckout === 'string' && row.reviewCheckout.length > 0) {
27
+ const head = await tower.revParse(row.reviewCheckout, 'HEAD').catch(() => null)
28
+ if (head !== row.reviewCommit) {
29
+ const where = head ? head.slice(0, 12) : 'a missing checkout'
30
+ fail(
31
+ `reviewer ${row.name}'s checkout is at ${where} instead of the recorded review commit ${row.reviewCommit.slice(0, 12)}; spawn a fresh reviewer`,
32
+ )
33
+ }
34
+ return { commit: row.reviewCommit, cwd: row.reviewCheckout }
35
+ }
36
+ return { commit: row.reviewCommit, cwd: mission.worktree.path }
37
+ }
38
+
10
39
  /**
11
40
  * Full detail for one mission. A mission that was archived is reconstructed
12
41
  * from its year file, including the reviews, findings and activity that left
@@ -91,22 +120,26 @@ export async function diff(tower, input = {}) {
91
120
  const state = await tower.load()
92
121
  const mission = state.missions.find((entry) => entry.id === input.missionId)
93
122
  if (!mission) fail(`unknown mission "${input.missionId}"`, 'mission-not-found')
123
+ assertActionSupported(mission, 'diff')
94
124
  if (!mission.worktree) fail(`mission ${mission.id} has no worktree to diff`)
95
125
  await tower.assertWorktree(mission.worktree)
126
+ const binding = await reviewerBinding(tower, state, mission, input.actor)
127
+ const cwd = binding?.cwd ?? mission.worktree.path
96
128
  const baseTip = await tower.revParse(tower.root, state.base)
97
- const branchTip = await tower.revParse(mission.worktree.path, 'HEAD')
98
- const mergeBase = (await tower.git(mission.worktree.path, ['merge-base', baseTip, branchTip])).trim()
99
- const files = (await tower.git(mission.worktree.path, ['diff', '--name-status', `${mergeBase}..${branchTip}`]))
129
+ const branchTip = binding?.commit ?? (await tower.branchTip(mission))
130
+ const mergeBase = (await tower.git(cwd, ['merge-base', baseTip, branchTip])).trim()
131
+ const files = (await tower.git(cwd, ['diff', '--name-status', `${mergeBase}..${branchTip}`]))
100
132
  .split('\n')
101
133
  .filter(Boolean)
102
- const commits = (await tower.git(mission.worktree.path, ['log', '--oneline', `${mergeBase}..${branchTip}`]))
134
+ const commits = (await tower.git(cwd, ['log', '--oneline', `${mergeBase}..${branchTip}`]))
103
135
  .split('\n')
104
136
  .filter(Boolean)
105
- const fullPatch = await tower.git(mission.worktree.path, ['diff', `${mergeBase}..${branchTip}`])
137
+ const fullPatch = await tower.git(cwd, ['diff', `${mergeBase}..${branchTip}`])
106
138
  return diffView({
107
139
  mission,
108
140
  baseTip,
109
141
  branchTip,
142
+ reviewedCommit: binding?.commit ?? null,
110
143
  mergeBase,
111
144
  files,
112
145
  commits,
package/app/rebase.js CHANGED
@@ -8,6 +8,8 @@
8
8
  // reviewed-tip waiver semantics the merge gate depends on.
9
9
  import { validateState } from '../domain/state.js'
10
10
  import { TowerToolError } from '../domain/errors.js'
11
+ import { assertActionSupported } from '../domain/shapes/index.js'
12
+ import { markBlocked, resolveBlockers } from '../domain/missions/lifecycle.js'
11
13
  import { liveAgentFor } from '../domain/integration/liveness.js'
12
14
  import {
13
15
  alreadyMergedRebaseResult,
@@ -17,6 +19,7 @@ import {
17
19
  classifyRebaseIntent,
18
20
  clearedRebaseIntentActivityText,
19
21
  dirtyWorktreeResult,
22
+ isRebaseConflictBlocker,
20
23
  malformedRebaseIntent,
21
24
  rebaseActivityText,
22
25
  rebaseConflictNote,
@@ -55,6 +58,7 @@ async function prepareRebase(tower, input) {
55
58
  const state = await tower.load()
56
59
  const mission = findMission(state, input.missionId)
57
60
  if (!mission) fail(`unknown mission "${input.missionId}"`)
61
+ assertActionSupported(mission, 'rebase')
58
62
  if (!mission.worktree) fail(`mission ${mission.id} has no worktree to rebase`)
59
63
  const activity = input.activity ?? {}
60
64
  const busy = liveAgentFor(state, mission.id, activity)
@@ -65,7 +69,7 @@ async function prepareRebase(tower, input) {
65
69
  if (!(await tower.isClean(mission.worktree.path))) {
66
70
  return { early: dirtyWorktreeResult(mission.worktree.path) }
67
71
  }
68
- const oldTip = await tower.revParse(mission.worktree.path, 'HEAD')
72
+ const oldTip = await tower.branchTip(mission)
69
73
  const baseTip = await tower.revParse(tower.root, state.base)
70
74
  if (await tower.isAncestor(tower.root, oldTip, baseTip)) {
71
75
  // The branch is already contained in the base (manual merge, crashed
@@ -161,7 +165,7 @@ async function reconcileRebaseIntent(tower) {
161
165
  if (inspection.action === 'record' && mission) {
162
166
  const tip = /** @type {string} */ (inspection.tip)
163
167
  mission.rebase = { at: now, oldTip: intent.oldTip, newTip: tip, baseTip: intent.baseTip }
164
- if (mission.status === 'blocked') mission.status = 'active'
168
+ resolveBlockers(mission, isRebaseConflictBlocker, now)
165
169
  mission.notes.push(rebaseReconcileNote({ oldTip: intent.oldTip, newTip: tip, baseTip: intent.baseTip }))
166
170
  mission.updatedAt = now
167
171
  }
@@ -221,7 +225,7 @@ export async function rebase(tower, input) {
221
225
  // Phase 2 (#51): the long git work runs under the git lock and never under
222
226
  // the state lock. Re-verify the snapshot right before rewriting the branch.
223
227
  const outcome = await tower.deps.store.withGitLock(tower.paths, async () => {
224
- const currentTip = await tower.revParse(worktree.path, 'HEAD')
228
+ const currentTip = await tower.revParse(tower.root, `refs/heads/${worktree.branch}`)
225
229
  const currentBase = await tower.revParse(tower.root, prepared.base)
226
230
  if (rebaseTipsMoved({ oldTip: prepared.oldTip, baseTip: prepared.baseTip, currentTip, currentBase })) {
227
231
  return {
@@ -240,7 +244,7 @@ export async function rebase(tower, input) {
240
244
  await tower.git(worktree.path, ['rebase', '--abort']).catch(() => {})
241
245
  return { phase: 'conflict', detail: String(error?.message ?? error) }
242
246
  }
243
- const newTip = await tower.revParse(worktree.path, 'HEAD')
247
+ const newTip = await tower.revParse(tower.root, `refs/heads/${worktree.branch}`)
244
248
  return { phase: 'done', newTip }
245
249
  }, tower.gitMutexOptions({ phase: 'rebase' }))
246
250
 
@@ -266,9 +270,7 @@ export async function rebase(tower, input) {
266
270
 
267
271
  if (outcome.phase === 'conflict') {
268
272
  delete state.rebaseIntent
269
- mission.status = 'blocked'
270
- mission.blockers.push(rebaseConflictNote(prepared.base, prepared.baseTip))
271
- mission.updatedAt = now
273
+ markBlocked(mission, rebaseConflictNote(prepared.base, prepared.baseTip), now)
272
274
  state.revision += 1
273
275
  state.updatedAt = now
274
276
  validateState(state, tower.paths.state)
@@ -280,7 +282,7 @@ export async function rebase(tower, input) {
280
282
  const { newTip } = outcome
281
283
  delete state.rebaseIntent
282
284
  mission.rebase = { at: now, oldTip: prepared.oldTip, newTip, baseTip: prepared.baseTip }
283
- if (mission.status === 'blocked') mission.status = 'active'
285
+ resolveBlockers(mission, isRebaseConflictBlocker, now)
284
286
  mission.notes.push(
285
287
  rebaseSuccessNote({ oldTip: prepared.oldTip, newTip, baseTip: prepared.baseTip }),
286
288
  )
package/app/review.js CHANGED
@@ -2,11 +2,12 @@
2
2
  import { sweepArchive } from './archive.js'
3
3
  import { validateState } from '../domain/state.js'
4
4
  import { TowerToolError } from '../domain/errors.js'
5
+ import { reviewUnavailable } from '../domain/integration/review.js'
6
+ import { cleanupReviewCheckout } from './reviewCheckout.js'
5
7
  import {
6
8
  assertReviewableMission,
7
9
  resolveReviewActor,
8
10
  normalizeReviewVerdict,
9
- assertReviewerSpawnTip,
10
11
  reviewAdmission,
11
12
  reviewRound,
12
13
  reviewOutcome,
@@ -18,6 +19,37 @@ function fail(message, code = 'invalid-argument') {
18
19
  throw new TowerToolError(code, message)
19
20
  }
20
21
 
22
+ /**
23
+ * The commit a verdict is stamped with (#149 W3).
24
+ *
25
+ * A spawned reviewer is bound to the review commit recorded on its roster row
26
+ * and reads it in a disposable detached checkout; that checkout must still be
27
+ * at the recorded commit, so a verdict can never review a moving target. The
28
+ * tower's break-glass review has no checkout and stamps the current branch tip.
29
+ * @param {any} tower
30
+ * @param {{ mission: any, caller: any, reviewerRole: string }} input
31
+ * @returns {Promise<string>}
32
+ */
33
+ async function reviewCommitFor(tower, { mission, caller, reviewerRole }) {
34
+ if (reviewerRole === 'tower') return tower.branchTip(mission)
35
+ const recorded = caller?.reviewCommit
36
+ if (typeof recorded !== 'string' || recorded.length === 0) {
37
+ fail(
38
+ `reviewer ${caller?.name ?? '?'} has no recorded review commit; spawn reviewers through tower_spawn (a verdict is stamped with a commit, not a live branch tip)`,
39
+ )
40
+ }
41
+ if (typeof caller.reviewCheckout === 'string' && caller.reviewCheckout.length > 0) {
42
+ const head = await tower.revParse(caller.reviewCheckout, 'HEAD').catch(() => null)
43
+ if (head !== recorded) {
44
+ const where = head ? head.slice(0, 12) : 'a missing checkout'
45
+ fail(
46
+ `reviewer ${caller.name}'s checkout is at ${where} instead of the recorded review commit ${recorded.slice(0, 12)}; spawn a fresh reviewer`,
47
+ )
48
+ }
49
+ }
50
+ return recorded
51
+ }
52
+
21
53
  /**
22
54
  * Phase 1 (#51): load state and capture everything the review needs — actor
23
55
  * checks, worktree ownership, branch tip, and round counters — without holding
@@ -44,9 +76,8 @@ async function prepareReview(tower, input) {
44
76
  const verdict = normalizeReviewVerdict(input)
45
77
 
46
78
  await tower.assertWorktree(mission.worktree)
47
- const tip = await tower.revParse(mission.worktree.path, 'HEAD')
79
+ const tip = await reviewCommitFor(tower, { mission, caller, reviewerRole })
48
80
  const branch = mission.worktree.branch
49
- assertReviewerSpawnTip(caller, tip)
50
81
  // #108: a later route may append to the round that returned the mission to
51
82
  // active, but only for the tip that round reviewed.
52
83
  const admission = reviewAdmission({ mission, reviews: state.reviews, branch, tip })
@@ -78,73 +109,85 @@ async function prepareReview(tower, input) {
78
109
  }
79
110
 
80
111
  export async function submitReview(tower, input) {
112
+ // #M39: `review.mode:off` means review is intentionally unavailable. The tool
113
+ // is not registered in that profile, but the repository layer can also turn it
114
+ // off, so the app layer refuses before any state is read.
115
+ if (reviewUnavailable(tower.config)) {
116
+ throw new TowerToolError('review-disabled', 'review.mode is off, so tower_review cannot record a verdict')
117
+ }
81
118
  // Two-phase (#51): the lock is held only for the state reload, revision
82
119
  // revalidation, and write; every git call happens in prepareReview.
83
120
  for (let attempt = 0; attempt < 2; attempt += 1) {
84
121
  const prepared = await prepareReview(tower, input)
85
122
  try {
86
- return await tower.deps.store.withLock(tower.paths, async () => {
87
- const state = await tower.load()
88
- if (state.revision !== prepared.stateRevision) {
89
- throw new TowerToolError('stale-base', 'tower state changed while the review was being prepared; retry')
90
- }
91
- const mission = state.missions.find((entry) => entry.id === input.missionId)
92
- if (!mission) fail(`unknown mission "${input.missionId}"`)
123
+ const result = await tower.deps.store.withLock(tower.paths, async () => {
124
+ const state = await tower.load()
125
+ if (state.revision !== prepared.stateRevision) {
126
+ throw new TowerToolError('stale-base', 'tower state changed while the review was being prepared; retry')
127
+ }
128
+ const mission = state.missions.find((entry) => entry.id === input.missionId)
129
+ if (!mission) fail(`unknown mission "${input.missionId}"`)
93
130
 
94
- const now = Date.now()
95
- const seq = (state.reviewSeq ?? 0) + 1
96
- state.reviewSeq = seq
97
- const review = reviewRecord({
98
- seq,
99
- missionId: mission.id,
100
- branch: prepared.branch,
101
- tip: prepared.tip,
102
- verdict: {
103
- status: prepared.status,
104
- merge: prepared.merge,
105
- findings: prepared.findings,
106
- checks: prepared.checks,
107
- decision: prepared.decision,
108
- },
109
- reviewerName: prepared.reviewerName,
110
- reviewerRole: prepared.reviewerRole,
111
- round: prepared.round,
112
- now,
113
- breakGlassReason: prepared.breakGlassReason,
114
- })
115
- if (!Array.isArray(state.reviews)) state.reviews = []
116
- state.reviews.push(review)
131
+ const now = Date.now()
132
+ const seq = (state.reviewSeq ?? 0) + 1
133
+ state.reviewSeq = seq
134
+ const review = reviewRecord({
135
+ seq,
136
+ missionId: mission.id,
137
+ branch: prepared.branch,
138
+ tip: prepared.tip,
139
+ verdict: {
140
+ status: prepared.status,
141
+ merge: prepared.merge,
142
+ findings: prepared.findings,
143
+ checks: prepared.checks,
144
+ decision: prepared.decision,
145
+ },
146
+ reviewerName: prepared.reviewerName,
147
+ reviewerRole: prepared.reviewerRole,
148
+ round: prepared.round,
149
+ now,
150
+ breakGlassReason: prepared.breakGlassReason,
151
+ })
152
+ if (!Array.isArray(state.reviews)) state.reviews = []
153
+ state.reviews.push(review)
117
154
 
118
- const outcome = reviewOutcome({
119
- status: prepared.status,
120
- merge: prepared.merge,
121
- round: prepared.round,
122
- reviewerName: prepared.reviewerName,
123
- appended: prepared.appended,
124
- })
125
- const applied = applyReviewOutcome({ mission, outcome, now })
126
- await sweepArchive(tower, state, { now })
127
- state.revision += 1
128
- state.updatedAt = now
155
+ const outcome = reviewOutcome({
156
+ status: prepared.status,
157
+ merge: prepared.merge,
158
+ round: prepared.round,
159
+ reviewerName: prepared.reviewerName,
160
+ appended: prepared.appended,
161
+ })
162
+ const applied = applyReviewOutcome({ mission, outcome, now })
163
+ await sweepArchive(tower, state, { now })
164
+ state.revision += 1
165
+ state.updatedAt = now
129
166
 
130
- validateState(state, tower.paths.state)
131
- await tower.deps.store.writeJsonAtomic(tower.paths.state, state)
132
- await tower.deps.store.appendJsonl(tower.paths.activity, {
133
- at: now,
134
- type: 'review',
135
- missionId: mission.id,
136
- text: `round ${prepared.round} ${prepared.reviewerName}: ${prepared.status} / ${prepared.merge} @ ${prepared.tip.slice(0, 12)}`,
137
- })
138
- tower.emitEvents(applied.events, { revision: state.revision, at: now })
139
- return {
140
- review,
141
- branchTip: prepared.tip,
142
- missionStatus: mission.status,
143
- round: prepared.round,
144
- appended: prepared.appended,
145
- reviewerRounds: prepared.reviewerRounds + 1,
146
- }
167
+ validateState(state, tower.paths.state)
168
+ await tower.deps.store.writeJsonAtomic(tower.paths.state, state)
169
+ await tower.deps.store.appendJsonl(tower.paths.activity, {
170
+ at: now,
171
+ type: 'review',
172
+ missionId: mission.id,
173
+ text: `round ${prepared.round} ${prepared.reviewerName}: ${prepared.status} / ${prepared.merge} @ ${prepared.tip.slice(0, 12)}`,
174
+ })
175
+ tower.emitEvents(applied.events, { revision: state.revision, at: now })
176
+ return {
177
+ review,
178
+ branchTip: prepared.tip,
179
+ missionStatus: mission.status,
180
+ round: prepared.round,
181
+ appended: prepared.appended,
182
+ reviewerRounds: prepared.reviewerRounds + 1,
183
+ }
147
184
  })
185
+ // The verdict is recorded, so the reviewer's evidence checkout has served
186
+ // its purpose. Removal is advisory: a failed cleanup is retried by teardown.
187
+ if (prepared.reviewerRole === 'reviewer') {
188
+ await cleanupReviewCheckout(tower, { name: prepared.reviewerName, missionId: prepared.missionId }).catch(() => {})
189
+ }
190
+ return result
148
191
  } catch (error) {
149
192
  if (error.code === 'stale-base' && attempt === 0) continue
150
193
  throw error