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/plan.js CHANGED
@@ -1,4 +1,5 @@
1
1
  // Mission planning use case.
2
+ import { TowerToolError } from '../domain/errors.js'
2
3
  import { nextMissionNumber, addAutoDeps, assertAcyclic } from '../domain/planning.js'
3
4
  import {
4
5
  normalizePlanMissions,
@@ -7,15 +8,58 @@ import {
7
8
  newMission,
8
9
  applyPlan,
9
10
  } from '../domain/missions/plan.js'
11
+ import { shapeFor } from '../domain/shapes/index.js'
10
12
  import { validateState } from '../domain/state.js'
11
13
  import { fullMission } from '../domain/summary.js'
12
14
  import { sweepArchive } from './archive.js'
15
+ import { surveyStaleWarnings } from './freshness.js'
16
+ import { readFacts } from './sharedCheckout.js'
17
+
18
+ /**
19
+ * Planning a shape whose optional component is off must fail with an actionable
20
+ * message, not silently plan work nothing can execute. Preset rows are only
21
+ * visible through the async probe: the synchronous `enabled` switch cannot see
22
+ * a declarative agent-preset registration.
23
+ * @param {any} tower
24
+ * @param {any} input
25
+ * @returns {Promise<void>}
26
+ */
27
+ async function assertShapeComponents(tower, input) {
28
+ const components = tower.deps?.components
29
+ if (!components) return
30
+ for (const mission of input?.missions ?? []) {
31
+ const required = shapeFor(mission?.kind).requiredComponent
32
+ if (!required) continue
33
+ if (typeof components.available === 'function') {
34
+ // null means "cannot tell" (no agent-presets service): let the plan pass,
35
+ // exactly as a host without the component port did before shapes existed.
36
+ if ((await components.available(required)) !== false) continue
37
+ } else if (components.enabled(required) === true) continue
38
+ throw new TowerToolError(
39
+ 'invalid-argument',
40
+ `the ${required} component is off: surveys cannot be planned; build missions are unaffected`,
41
+ { nextAction: `enable the ${required} component and plan the survey again` },
42
+ )
43
+ }
44
+ }
45
+
46
+ /**
47
+ * @param {any} input
48
+ * @returns {boolean}
49
+ */
50
+ function planNeedsRead(input) {
51
+ return (input?.missions ?? []).some((mission) => shapeFor(mission?.kind).fields.readCommit)
52
+ }
13
53
 
14
54
  export async function plan(tower, input = {}) {
15
- const prepared = normalizePlanMissions(input)
16
55
  return tower.deps.store.withLock(tower.paths, async () => {
17
56
  const state = await tower.load()
18
57
  const now = Date.now()
58
+ // A survey records the commit it reads and the tracked-status fingerprint at
59
+ // plan time; a build plan reads nothing extra.
60
+ await assertShapeComponents(tower, input)
61
+ const read = planNeedsRead(input) ? await readFacts(tower, state.base) : {}
62
+ const prepared = normalizePlanMissions(input, read)
19
63
  // Archived ids stay in the known set: a new mission must not reuse an id
20
64
  // and a merged dependency may live in the archive index (#73).
21
65
  const archive = await tower.archiveIndex()
@@ -29,6 +73,24 @@ export async function plan(tower, input = {}) {
29
73
 
30
74
  const created = assigned.map((mission) => newMission(mission.id, mission, now))
31
75
  const applied = applyPlan(state, created)
76
+ // #148 W2: report survey evidence freshness at plan time. New builds have no
77
+ // provisioned base yet, so the warning compares against the state base; it
78
+ // is advisory here and never blocks the plan. Archived survey dependencies
79
+ // keep their read commit on the archive index, so they are checked too.
80
+ const archivedMissions = new Map(
81
+ (archive.ok ? archive.missions : []).map((entry) => [entry.id, entry]),
82
+ )
83
+ const warnings = []
84
+ for (const mission of created) {
85
+ warnings.push(
86
+ ...(await surveyStaleWarnings(tower, {
87
+ mission,
88
+ state,
89
+ missions: state.missions,
90
+ archived: archivedMissions,
91
+ })),
92
+ )
93
+ }
32
94
  const swept = await sweepArchive(tower, state, { now })
33
95
  state.revision += 1
34
96
  state.updatedAt = now
@@ -46,6 +108,7 @@ export async function plan(tower, input = {}) {
46
108
  return {
47
109
  revision: state.revision,
48
110
  missions: created.map((mission) => fullMission(mission)),
111
+ ...(warnings.length > 0 ? { warnings } : {}),
49
112
  ...(swept.archived.length > 0 ? { archived: swept.archived } : {}),
50
113
  ...(swept.error ? { archiveError: swept.error } : {}),
51
114
  }
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
  )