dsh-tower 2.1.0 → 2.4.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 (203) hide show
  1. package/CHANGELOG.md +137 -0
  2. package/COMPATIBILITY.md +6 -1
  3. package/CONTRIBUTING.md +78 -5
  4. package/PRINCIPLES.md +163 -0
  5. package/README.en.md +35 -19
  6. package/README.md +28 -18
  7. package/SECURITY.md +6 -0
  8. package/{lib → adapters}/archive.js +1 -1
  9. package/{lib → adapters}/caseSensitivity.js +1 -1
  10. package/adapters/default.js +51 -10
  11. package/{lib → adapters}/git.js +1 -1
  12. package/{lib → adapters}/leases.js +1 -1
  13. package/{lib → adapters}/legacy.js +1 -1
  14. package/adapters/persistence.js +543 -0
  15. package/adapters/store-fs.js +28 -1
  16. package/adapters/store.js +204 -0
  17. package/{lib → adapters}/tests.js +9 -5
  18. package/adapters/vcs-git.js +15 -1
  19. package/{lib → adapters}/workspaceLock.js +1 -1
  20. package/{lib → adapters}/worktreeOwnership.js +1 -1
  21. package/app/archive.js +4 -6
  22. package/app/batch.js +9 -10
  23. package/app/board.js +2 -4
  24. package/app/checkCompletion.js +9 -10
  25. package/app/enrichedMissions.js +2 -4
  26. package/app/findings.js +6 -7
  27. package/app/gate.js +1 -2
  28. package/app/init.js +24 -29
  29. package/app/merge.js +58 -51
  30. package/app/messages.js +7 -8
  31. package/app/mission.js +5 -5
  32. package/app/plan.js +8 -5
  33. package/app/provision.js +12 -14
  34. package/app/read.js +6 -3
  35. package/app/rebase.js +14 -16
  36. package/app/recording.js +7 -8
  37. package/app/review.js +15 -8
  38. package/app/roster.js +14 -15
  39. package/{lib → app}/spawn-prechecks.js +4 -3
  40. package/app/spawn.js +12 -13
  41. package/app/teardown.js +8 -9
  42. package/app/tower.js +89 -35
  43. package/cordis.patch.yml +56 -28
  44. package/docs/README.md +7 -2
  45. package/docs/adr/0001-ports-adapters-as-data.md +49 -0
  46. package/docs/adr/0002-single-state-json.md +50 -0
  47. package/docs/adr/0003-allow-agent-control-default-true.md +48 -0
  48. package/docs/adr/0004-surface-budget.md +70 -0
  49. package/docs/adr/0005-component-surface.md +130 -0
  50. package/docs/adr/README.md +52 -0
  51. package/docs/agents/workflow.md +51 -0
  52. package/docs/architecture/overview.md +192 -43
  53. package/docs/guide/hot-reload.md +5 -5
  54. package/docs/guide/install-upgrade-migration.md +13 -0
  55. package/docs/guide/release.md +1 -1
  56. package/docs/operations/incident.md +82 -0
  57. package/docs/operations/runbook.md +12 -3
  58. package/docs/operations/security.md +1 -1
  59. package/docs/reference/blockers.md +2 -2
  60. package/docs/reference/component-surface.md +135 -0
  61. package/docs/reference/config.generated.md +2 -9
  62. package/docs/reference/config.md +11 -9
  63. package/docs/reference/doctor.md +53 -2
  64. package/docs/reference/errors.generated.md +41 -37
  65. package/docs/reference/errors.md +53 -41
  66. package/docs/reference/events.md +38 -6
  67. package/docs/reference/surface-budget.md +157 -0
  68. package/docs/reference/tools.generated.md +8 -5
  69. package/docs/reference/tools.md +10 -7
  70. package/docs/zh/README.md +1 -1
  71. package/docs/zh/guide/hot-reload.md +4 -4
  72. package/docs/zh/guide/install-upgrade-migration.md +10 -0
  73. package/docs/zh/operations/security.md +1 -1
  74. package/docs/zh/reference/blockers.md +2 -2
  75. package/docs/zh/reference/config.md +9 -9
  76. package/docs/zh/reference/doctor.md +29 -1
  77. package/docs/zh/reference/tools.md +10 -8
  78. package/domain/batch.js +2 -2
  79. package/domain/components.js +641 -0
  80. package/domain/config/schema.js +62 -28
  81. package/domain/contracts/outputs.js +16 -2
  82. package/domain/contracts/tool-inputs/ops.js +1 -0
  83. package/domain/errors.js +308 -43
  84. package/domain/events.js +207 -0
  85. package/domain/integration/gate.js +17 -2
  86. package/domain/integration/merge.js +35 -0
  87. package/domain/lockBudget.js +30 -0
  88. package/domain/mission.js +12 -1
  89. package/domain/missions/completion.js +14 -0
  90. package/domain/missions/lifecycle.js +9 -3
  91. package/domain/missions/plan.js +14 -0
  92. package/domain/missions/review.js +90 -7
  93. package/domain/read/views.js +3 -0
  94. package/domain/state/archive.js +1 -1
  95. package/domain/state.js +1 -1
  96. package/domain/surface.js +347 -0
  97. package/host/capability-batch.js +15 -0
  98. package/host/capability-hmr.js +30 -0
  99. package/host/capability-import.js +11 -0
  100. package/host/capability-leases.js +11 -0
  101. package/host/capability-registry.js +149 -0
  102. package/host/capability-tests.js +16 -0
  103. package/{lib → host}/doctor.js +157 -18
  104. package/{lib → host}/hmr.js +2 -4
  105. package/{lib → host}/modeStore.js +1 -1
  106. package/host/presets.js +77 -0
  107. package/{lib → host}/tool-registry.js +235 -44
  108. package/host/tower.js +32 -0
  109. package/index.js +40 -25
  110. package/locale/en.json +31 -11
  111. package/locale/zh.json +30 -10
  112. package/package.json +24 -4
  113. package/ports/archive.js +18 -0
  114. package/ports/caseSensitivity.js +15 -0
  115. package/ports/clock.js +10 -2
  116. package/ports/config.js +19 -0
  117. package/ports/contract.js +38 -0
  118. package/ports/events.js +24 -0
  119. package/ports/ids.js +10 -2
  120. package/ports/index.js +54 -2
  121. package/ports/ownership.js +17 -0
  122. package/ports/state.js +16 -0
  123. package/ports/store.js +52 -3
  124. package/ports/vcs.js +25 -4
  125. package/scripts/architecture-waivers.json +19 -0
  126. package/scripts/architecture.mjs +634 -23
  127. package/scripts/check-components.mjs +235 -0
  128. package/scripts/check-docs.mjs +8 -35
  129. package/scripts/check-pack.mjs +68 -39
  130. package/scripts/check-philosophy.mjs +266 -0
  131. package/scripts/component-surface.json +395 -0
  132. package/scripts/domain-metrics.mjs +13 -4
  133. package/scripts/generate-component-surface.mjs +59 -0
  134. package/scripts/generate-config-reference.mjs +14 -1
  135. package/scripts/generate-error-reference.mjs +68 -14
  136. package/scripts/generate-events-reference.mjs +83 -0
  137. package/scripts/generate-tools-reference.mjs +18 -25
  138. package/scripts/generate-types.mjs +127 -0
  139. package/scripts/issue-audit.mjs +2 -2
  140. package/scripts/lib/surface.mjs +87 -0
  141. package/scripts/lib/tool-surface.mjs +85 -0
  142. package/scripts/mount-smoke/fixtures.mjs +3 -3
  143. package/scripts/mount-smoke/surface.mjs +96 -27
  144. package/scripts/mount-smoke.mjs +3 -1
  145. package/scripts/pack-manifest.txt +75 -22
  146. package/scripts/regression-audit.mjs +164 -0
  147. package/scripts/release-check.mjs +1 -1
  148. package/scripts/surface-baseline.json +52 -0
  149. package/scripts/surface-baseline.mjs +118 -0
  150. package/scripts/types-coverage.mjs +184 -0
  151. package/scripts/types-strict-manifest.txt +10 -0
  152. package/scripts/types-strict.mjs +141 -0
  153. package/skills.js +8 -1
  154. package/tools/adopt.js +2 -3
  155. package/tools/batch.js +2 -3
  156. package/tools/complete.js +2 -3
  157. package/tools/diff.js +3 -3
  158. package/tools/doctor.js +7 -10
  159. package/tools/finding.js +2 -3
  160. package/tools/findings.js +3 -3
  161. package/tools/import-legacy.js +3 -5
  162. package/tools/inbox.js +3 -3
  163. package/tools/index.js +9 -8
  164. package/tools/init.js +3 -6
  165. package/tools/interrupt.js +2 -3
  166. package/tools/legacy-map.js +3 -5
  167. package/tools/merge.js +3 -3
  168. package/tools/mission.js +2 -3
  169. package/tools/plan.js +6 -5
  170. package/tools/preflight.js +2 -3
  171. package/tools/rebase.js +3 -3
  172. package/tools/recover.js +38 -13
  173. package/tools/reload.js +36 -40
  174. package/tools/resource.js +3 -5
  175. package/tools/review.js +3 -4
  176. package/tools/roster.js +2 -3
  177. package/tools/send.js +2 -2
  178. package/tools/spawn.js +8 -5
  179. package/tools/status.js +11 -4
  180. package/tools/teardown.js +2 -3
  181. package/tools/test.js +2 -2
  182. package/types/index.d.ts +29 -13
  183. package/ui-bundle/README.md +37 -3
  184. package/ui-bundle/client.cards.js +356 -0
  185. package/ui-bundle/client.denied.js +142 -0
  186. package/ui-bundle/client.dock.js +333 -0
  187. package/ui-bundle/client.helpers.js +271 -0
  188. package/ui-bundle/client.js +79 -1747
  189. package/ui-bundle/client.locales.js +247 -0
  190. package/ui-bundle/client.mode.js +206 -0
  191. package/ui-bundle/client.overlay.js +64 -0
  192. package/ui-bundle/client.styles.js +151 -0
  193. package/ui-bundle/client.toolview.js +82 -0
  194. package/ui-bundle/package.json +1 -0
  195. package/lib/persistence.js +0 -356
  196. package/lib/presets.js +0 -35
  197. package/lib/store.js +0 -133
  198. /package/{lib → adapters}/stateFile.js +0 -0
  199. /package/{lib → app}/briefing.js +0 -0
  200. /package/{lib → app}/spawn-support.js +0 -0
  201. /package/{lib → host}/compatibility.js +0 -0
  202. /package/{lib → host}/config.js +0 -0
  203. /package/{lib → host}/modeState.js +0 -0
package/app/read.js CHANGED
@@ -1,7 +1,6 @@
1
1
  // Read-only dashboard/detail use cases.
2
2
  import { TowerToolError } from '../domain/errors.js'
3
3
  import { diffView, missionDetailView, statusView } from '../domain/read/views.js'
4
- import { readActivityTail } from '../lib/store.js'
5
4
  import { archiveView, archivedSummary } from './archive.js'
6
5
 
7
6
  function fail(message, code = 'invalid-argument') {
@@ -17,7 +16,7 @@ function fail(message, code = 'invalid-argument') {
17
16
  export async function missionDetail(tower, missionId) {
18
17
  const state = await tower.load()
19
18
  const mission = state.missions.find((entry) => entry.id === missionId)
20
- const activity = await readActivityTail(tower.paths.activity, 200)
19
+ const activity = await tower.deps.store.readActivityTail(tower.paths.activity, 200)
21
20
  if (mission) return missionDetailView({ state, mission, activity })
22
21
 
23
22
  const archived = await tower.archivedMission(missionId)
@@ -57,7 +56,10 @@ export async function missionDetail(tower, missionId) {
57
56
 
58
57
  export async function status(tower, input = {}) {
59
58
  const state = await tower.load()
60
- const activity = await readActivityTail(tower.paths.activity, 12)
59
+ const activity = await tower.deps.store.readActivityTail(tower.paths.activity, 12)
60
+ // The repo-scoped git mutex can be held for a whole merge; expose the
61
+ // holder so "the tower is stuck" has a visible cause (#93).
62
+ const gitLock = tower.deps.store.gitLockView(await tower.deps.store.readGitLock(tower.paths))
61
63
  const index = await tower.archiveIndex()
62
64
  const activeIds = new Set(state.missions.map((mission) => mission.id))
63
65
  const wanted = Array.isArray(input.missionIds) ? new Set(input.missionIds) : null
@@ -81,6 +83,7 @@ export async function status(tower, input = {}) {
81
83
  missions,
82
84
  archivedMissions: index.ok ? index.missions.length : null,
83
85
  archive: archiveView(index),
86
+ gitLock,
84
87
  })
85
88
  }
86
89
 
package/app/rebase.js CHANGED
@@ -6,8 +6,6 @@
6
6
  // The durable `state.rebaseIntent` lets a crash between act and commit be
7
7
  // reconciled by the next tower_rebase, and `mission.rebase` keeps the
8
8
  // reviewed-tip waiver semantics the merge gate depends on.
9
- import crypto from 'node:crypto'
10
- import { withLock, withGitLock, writeJsonAtomic, appendJsonl } from '../lib/store.js'
11
9
  import { validateState } from '../domain/state.js'
12
10
  import { TowerToolError } from '../domain/errors.js'
13
11
  import { liveAgentFor } from '../domain/integration/liveness.js'
@@ -150,7 +148,7 @@ async function reconcileRebaseIntent(tower) {
150
148
  if (inspection.action === 'ambiguous') {
151
149
  throw stale(ambiguousRebaseIntentMessage(intent, inspection.tip))
152
150
  }
153
- const applied = await withLock(tower.paths, async () => {
151
+ const applied = await tower.deps.store.withLock(tower.paths, async () => {
154
152
  const state = await tower.load()
155
153
  if (state.revision !== snapshot.revision) return false
156
154
  const current = state.rebaseIntent
@@ -168,8 +166,8 @@ async function reconcileRebaseIntent(tower) {
168
166
  mission.updatedAt = now
169
167
  }
170
168
  validateState(state, tower.paths.state)
171
- await writeJsonAtomic(tower.paths.state, state)
172
- await appendJsonl(tower.paths.activity, {
169
+ await tower.deps.store.writeJsonAtomic(tower.paths.state, state)
170
+ await tower.deps.store.appendJsonl(tower.paths.activity, {
173
171
  at: now,
174
172
  type: 'rebase',
175
173
  missionId: intent.missionId,
@@ -197,9 +195,9 @@ export async function rebase(tower, input) {
197
195
 
198
196
  // Record the intent before touching Git: a crash between the rebase and the
199
197
  // commit leaves a durable marker instead of an unexplained moved branch.
200
- const token = crypto.randomUUID()
198
+ const token = tower.deps.ids.uuid()
201
199
  const startedAt = Date.now()
202
- const intentRevision = await withLock(tower.paths, async () => {
200
+ const intentRevision = await tower.deps.store.withLock(tower.paths, async () => {
203
201
  const state = await tower.load()
204
202
  if (state.revision !== prepared.stateRevision) throw stale(STALE_PREPARE)
205
203
  const mission = findMission(state, prepared.missionId)
@@ -216,13 +214,13 @@ export async function rebase(tower, input) {
216
214
  })
217
215
  state.revision += 1
218
216
  state.updatedAt = startedAt
219
- await writeJsonAtomic(tower.paths.state, state)
217
+ await tower.deps.store.writeJsonAtomic(tower.paths.state, state)
220
218
  return state.revision
221
219
  })
222
220
 
223
221
  // Phase 2 (#51): the long git work runs under the git lock and never under
224
222
  // the state lock. Re-verify the snapshot right before rewriting the branch.
225
- const outcome = await withGitLock(tower.paths, async () => {
223
+ const outcome = await tower.deps.store.withGitLock(tower.paths, async () => {
226
224
  const currentTip = await tower.revParse(worktree.path, 'HEAD')
227
225
  const currentBase = await tower.revParse(tower.root, prepared.base)
228
226
  if (rebaseTipsMoved({ oldTip: prepared.oldTip, baseTip: prepared.baseTip, currentTip, currentBase })) {
@@ -244,11 +242,11 @@ export async function rebase(tower, input) {
244
242
  }
245
243
  const newTip = await tower.revParse(worktree.path, 'HEAD')
246
244
  return { phase: 'done', newTip }
247
- })
245
+ }, tower.gitMutexOptions({ phase: 'rebase' }))
248
246
 
249
247
  // Phase 3 (#51): re-read state and revalidate the revision computed by the
250
248
  // intent write; the lock only holds the state mutation, never a subprocess.
251
- return withLock(tower.paths, async () => {
249
+ return tower.deps.store.withLock(tower.paths, async () => {
252
250
  const state = await tower.load()
253
251
  if (state.revision !== intentRevision) throw stale(STALE_ACT)
254
252
  const mission = findMission(state, prepared.missionId)
@@ -262,7 +260,7 @@ export async function rebase(tower, input) {
262
260
  state.revision += 1
263
261
  state.updatedAt = now
264
262
  validateState(state, tower.paths.state)
265
- await writeJsonAtomic(tower.paths.state, state)
263
+ await tower.deps.store.writeJsonAtomic(tower.paths.state, state)
266
264
  throw stale(outcome.detail)
267
265
  }
268
266
 
@@ -274,8 +272,8 @@ export async function rebase(tower, input) {
274
272
  state.revision += 1
275
273
  state.updatedAt = now
276
274
  validateState(state, tower.paths.state)
277
- await writeJsonAtomic(tower.paths.state, state)
278
- await appendJsonl(tower.paths.activity, { at: now, type: 'rebase', missionId: mission.id, text: 'conflict' })
275
+ await tower.deps.store.writeJsonAtomic(tower.paths.state, state)
276
+ await tower.deps.store.appendJsonl(tower.paths.activity, { at: now, type: 'rebase', missionId: mission.id, text: 'conflict' })
279
277
  return rebaseConflictResult(outcome.detail)
280
278
  }
281
279
 
@@ -290,8 +288,8 @@ export async function rebase(tower, input) {
290
288
  state.revision += 1
291
289
  state.updatedAt = now
292
290
  validateState(state, tower.paths.state)
293
- await writeJsonAtomic(tower.paths.state, state)
294
- await appendJsonl(tower.paths.activity, {
291
+ await tower.deps.store.writeJsonAtomic(tower.paths.state, state)
292
+ await tower.deps.store.appendJsonl(tower.paths.activity, {
295
293
  at: now,
296
294
  type: 'rebase',
297
295
  missionId: mission.id,
package/app/recording.js CHANGED
@@ -1,14 +1,13 @@
1
1
  // State recording use cases for test receipts and imported legacy snapshots.
2
2
  import { TowerToolError } from '../domain/errors.js'
3
3
  import { validateState } from '../domain/state.js'
4
- import { withLock, writeJsonAtomic, appendJsonl } from '../lib/store.js'
5
4
 
6
5
  function fail(message, code = 'invalid-argument') {
7
6
  throw new TowerToolError(code, message)
8
7
  }
9
8
 
10
9
  export async function recordTestRun(tower, input = {}) {
11
- return withLock(tower.paths, async () => {
10
+ return tower.deps.store.withLock(tower.paths, async () => {
12
11
  const state = await tower.load()
13
12
  const mission = state.missions.find((entry) => entry.id === input.missionId)
14
13
  if (!mission) fail(`unknown mission "${input.missionId}"`)
@@ -28,8 +27,8 @@ export async function recordTestRun(tower, input = {}) {
28
27
  state.revision += 1
29
28
  state.updatedAt = Date.now()
30
29
  validateState(state, tower.paths.state)
31
- await writeJsonAtomic(tower.paths.state, state)
32
- await appendJsonl(tower.paths.activity, {
30
+ await tower.deps.store.writeJsonAtomic(tower.paths.state, state)
31
+ await tower.deps.store.appendJsonl(tower.paths.activity, {
33
32
  at: Date.now(),
34
33
  type: 'test',
35
34
  missionId: mission.id,
@@ -40,7 +39,7 @@ export async function recordTestRun(tower, input = {}) {
40
39
  }
41
40
 
42
41
  export async function recordLegacySnapshot(tower, snapshot) {
43
- return withLock(tower.paths, async () => {
42
+ return tower.deps.store.withLock(tower.paths, async () => {
44
43
  const state = await tower.load()
45
44
  if (!Array.isArray(state.legacySnapshots)) state.legacySnapshots = []
46
45
  const existing = state.legacySnapshots.find((entry) => entry.sourceHash === snapshot.sourceHash)
@@ -49,13 +48,13 @@ export async function recordLegacySnapshot(tower, snapshot) {
49
48
  state.revision += 1
50
49
  state.updatedAt = Date.now()
51
50
  validateState(state, tower.paths.state)
52
- await writeJsonAtomic(tower.paths.state, state)
51
+ await tower.deps.store.writeJsonAtomic(tower.paths.state, state)
53
52
  return { snapshot, duplicate: false }
54
53
  })
55
54
  }
56
55
 
57
56
  export async function recordLegacyMap(tower, map) {
58
- return withLock(tower.paths, async () => {
57
+ return tower.deps.store.withLock(tower.paths, async () => {
59
58
  const state = await tower.load()
60
59
  if (!Array.isArray(state.legacyMaps)) state.legacyMaps = []
61
60
  const existing = state.legacyMaps.find(
@@ -66,7 +65,7 @@ export async function recordLegacyMap(tower, map) {
66
65
  state.revision += 1
67
66
  state.updatedAt = Date.now()
68
67
  validateState(state, tower.paths.state)
69
- await writeJsonAtomic(tower.paths.state, state)
68
+ await tower.deps.store.writeJsonAtomic(tower.paths.state, state)
70
69
  return { map, duplicate: false }
71
70
  })
72
71
  }
package/app/review.js CHANGED
@@ -1,5 +1,4 @@
1
1
  // Independent review submission use case.
2
- import { withLock, writeJsonAtomic, appendJsonl } from '../lib/store.js'
3
2
  import { sweepArchive } from './archive.js'
4
3
  import { validateState } from '../domain/state.js'
5
4
  import { TowerToolError } from '../domain/errors.js'
@@ -8,9 +7,11 @@ import {
8
7
  resolveReviewActor,
9
8
  normalizeReviewVerdict,
10
9
  assertReviewerSpawnTip,
10
+ reviewAdmission,
11
11
  reviewRound,
12
12
  reviewOutcome,
13
13
  reviewRecord,
14
+ applyReviewOutcome,
14
15
  } from '../domain/missions/review.js'
15
16
 
16
17
  function fail(message, code = 'invalid-argument') {
@@ -37,7 +38,7 @@ async function prepareReview(tower, input) {
37
38
  breakGlassReason: input.breakGlassReason,
38
39
  })
39
40
 
40
- if (mission.status !== 'completed') {
41
+ if (!['completed', 'active'].includes(mission.status)) {
41
42
  fail(`mission ${mission.id} is ${mission.status}; only a completed mission can be reviewed`)
42
43
  }
43
44
  const verdict = normalizeReviewVerdict(input)
@@ -46,11 +47,15 @@ async function prepareReview(tower, input) {
46
47
  const tip = await tower.revParse(mission.worktree.path, 'HEAD')
47
48
  const branch = mission.worktree.branch
48
49
  assertReviewerSpawnTip(caller, tip)
50
+ // #108: a later route may append to the round that returned the mission to
51
+ // active, but only for the tip that round reviewed.
52
+ const admission = reviewAdmission({ mission, reviews: state.reviews, branch, tip })
49
53
  const { reviewerRounds, round } = reviewRound({
50
54
  reviews: state.reviews,
51
55
  branch,
52
56
  reviewerName,
53
57
  maxRoundsPerReviewer: tower.config.review.maxRoundsPerReviewer,
58
+ round: admission.round,
54
59
  })
55
60
 
56
61
  return {
@@ -67,6 +72,7 @@ async function prepareReview(tower, input) {
67
72
  reviewerRole,
68
73
  reviewerRounds,
69
74
  round,
75
+ appended: admission.appended,
70
76
  breakGlassReason: reviewerRole === 'tower' ? input.breakGlassReason.trim() : undefined,
71
77
  }
72
78
  }
@@ -77,7 +83,7 @@ export async function submitReview(tower, input) {
77
83
  for (let attempt = 0; attempt < 2; attempt += 1) {
78
84
  const prepared = await prepareReview(tower, input)
79
85
  try {
80
- return await withLock(tower.paths, async () => {
86
+ return await tower.deps.store.withLock(tower.paths, async () => {
81
87
  const state = await tower.load()
82
88
  if (state.revision !== prepared.stateRevision) {
83
89
  throw new TowerToolError('stale-base', 'tower state changed while the review was being prepared; retry')
@@ -114,27 +120,28 @@ export async function submitReview(tower, input) {
114
120
  merge: prepared.merge,
115
121
  round: prepared.round,
116
122
  reviewerName: prepared.reviewerName,
123
+ appended: prepared.appended,
117
124
  })
118
- if (outcome.returnToActive) mission.status = 'active'
119
- mission.notes.push(outcome.note)
120
- mission.updatedAt = now
125
+ const applied = applyReviewOutcome({ mission, outcome, now })
121
126
  await sweepArchive(tower, state, { now })
122
127
  state.revision += 1
123
128
  state.updatedAt = now
124
129
 
125
130
  validateState(state, tower.paths.state)
126
- await writeJsonAtomic(tower.paths.state, state)
127
- await appendJsonl(tower.paths.activity, {
131
+ await tower.deps.store.writeJsonAtomic(tower.paths.state, state)
132
+ await tower.deps.store.appendJsonl(tower.paths.activity, {
128
133
  at: now,
129
134
  type: 'review',
130
135
  missionId: mission.id,
131
136
  text: `round ${prepared.round} ${prepared.reviewerName}: ${prepared.status} / ${prepared.merge} @ ${prepared.tip.slice(0, 12)}`,
132
137
  })
138
+ tower.emitEvents(applied.events, { revision: state.revision, at: now })
133
139
  return {
134
140
  review,
135
141
  branchTip: prepared.tip,
136
142
  missionStatus: mission.status,
137
143
  round: prepared.round,
144
+ appended: prepared.appended,
138
145
  reviewerRounds: prepared.reviewerRounds + 1,
139
146
  }
140
147
  })
package/app/roster.js CHANGED
@@ -1,5 +1,4 @@
1
1
  // Roster observation and retirement use cases.
2
- import { withLock, writeJsonAtomic, appendJsonl } from '../lib/store.js'
3
2
  import { validateState } from '../domain/state.js'
4
3
  import {
5
4
  recordRosterObservations as recordObservations,
@@ -11,7 +10,7 @@ import {
11
10
  import { unreadMessages } from '../domain/collab/messages.js'
12
11
 
13
12
  export async function recordRosterObservations(tower, { present = [], absent = [], confirmations = 2 }) {
14
- return withLock(tower.paths, async () => {
13
+ return tower.deps.store.withLock(tower.paths, async () => {
15
14
  const state = await tower.load()
16
15
  const now = Date.now()
17
16
  const { candidates, changed } = recordObservations(
@@ -23,7 +22,7 @@ export async function recordRosterObservations(tower, { present = [], absent = [
23
22
  state.revision += 1
24
23
  state.updatedAt = now
25
24
  validateState(state, tower.paths.state)
26
- await writeJsonAtomic(tower.paths.state, state)
25
+ await tower.deps.store.writeJsonAtomic(tower.paths.state, state)
27
26
  }
28
27
  return { candidates }
29
28
  })
@@ -31,7 +30,7 @@ export async function recordRosterObservations(tower, { present = [], absent = [
31
30
 
32
31
  /** Release live roster slots whose catalog activity is explicitly inactive. */
33
32
  export async function reconcileObservedInactive(tower, activity = {}) {
34
- return withLock(tower.paths, async () => {
33
+ return tower.deps.store.withLock(tower.paths, async () => {
35
34
  const state = await tower.load()
36
35
  const now = Date.now()
37
36
  const { released } = reconcileInactive(state.roster ?? [], activity, now)
@@ -39,8 +38,8 @@ export async function recordRosterObservations(tower, { present = [], absent = [
39
38
  state.revision += 1
40
39
  state.updatedAt = now
41
40
  validateState(state, tower.paths.state)
42
- await writeJsonAtomic(tower.paths.state, state)
43
- await appendJsonl(tower.paths.activity, {
41
+ await tower.deps.store.writeJsonAtomic(tower.paths.state, state)
42
+ await tower.deps.store.appendJsonl(tower.paths.activity, {
44
43
  at: now,
45
44
  type: 'roster',
46
45
  text: `released inactive slots: ${released.join(', ')}`,
@@ -52,7 +51,7 @@ export async function recordRosterObservations(tower, { present = [], absent = [
52
51
 
53
52
  export async function markRetired(tower, agentIds) {
54
53
  if (!Array.isArray(agentIds) || agentIds.length === 0) return { retired: [] }
55
- return withLock(tower.paths, async () => {
54
+ return tower.deps.store.withLock(tower.paths, async () => {
56
55
  const state = await tower.load()
57
56
  const now = Date.now()
58
57
  const { retired } = retireEntries(state.roster ?? [], agentIds, now)
@@ -60,8 +59,8 @@ export async function recordRosterObservations(tower, { present = [], absent = [
60
59
  state.revision += 1
61
60
  state.updatedAt = now
62
61
  validateState(state, tower.paths.state)
63
- await writeJsonAtomic(tower.paths.state, state)
64
- await appendJsonl(tower.paths.activity, {
62
+ await tower.deps.store.writeJsonAtomic(tower.paths.state, state)
63
+ await tower.deps.store.appendJsonl(tower.paths.activity, {
65
64
  at: now,
66
65
  type: 'adopt',
67
66
  text: `retired ${retired.join(', ')}`,
@@ -73,7 +72,7 @@ export async function recordRosterObservations(tower, { present = [], absent = [
73
72
 
74
73
  export async function markMissing(tower, agentIds) {
75
74
  if (!Array.isArray(agentIds) || agentIds.length === 0) return { missing: [] }
76
- return withLock(tower.paths, async () => {
75
+ return tower.deps.store.withLock(tower.paths, async () => {
77
76
  const state = await tower.load()
78
77
  const now = Date.now()
79
78
  const { missing } = markMissingEntries(state.roster ?? [], agentIds, now)
@@ -81,8 +80,8 @@ export async function recordRosterObservations(tower, { present = [], absent = [
81
80
  state.revision += 1
82
81
  state.updatedAt = now
83
82
  validateState(state, tower.paths.state)
84
- await writeJsonAtomic(tower.paths.state, state)
85
- await appendJsonl(tower.paths.activity, { at: now, type: 'adopt', text: `missing ${missing.join(', ')}` })
83
+ await tower.deps.store.writeJsonAtomic(tower.paths.state, state)
84
+ await tower.deps.store.appendJsonl(tower.paths.activity, { at: now, type: 'adopt', text: `missing ${missing.join(', ')}` })
86
85
  }
87
86
  return { missing }
88
87
  })
@@ -95,7 +94,7 @@ export async function recordRosterObservations(tower, { present = [], absent = [
95
94
  const staleMs = Number(input.staleMs) || 0
96
95
  if (staleMs <= 0 || input.authoritative !== true) return { marked: [], idled: [] }
97
96
  const activity = input.activity ?? {}
98
- return withLock(tower.paths, async () => {
97
+ return tower.deps.store.withLock(tower.paths, async () => {
99
98
  const state = await tower.load()
100
99
  const now = Date.now()
101
100
  const { marked, idled } = sweepRosterState(
@@ -107,8 +106,8 @@ export async function recordRosterObservations(tower, { present = [], absent = [
107
106
  state.revision += 1
108
107
  state.updatedAt = now
109
108
  validateState(state, tower.paths.state)
110
- await writeJsonAtomic(tower.paths.state, state)
111
- await appendJsonl(tower.paths.activity, {
109
+ await tower.deps.store.writeJsonAtomic(tower.paths.state, state)
110
+ await tower.deps.store.appendJsonl(tower.paths.activity, {
112
111
  at: now,
113
112
  type: 'watchdog',
114
113
  text: `stale ${marked.join(', ') || '-'}; idle ${idled.join(', ') || '-'}`,
@@ -2,7 +2,6 @@
2
2
  // service/provider/lease validation. Split out of tools/spawn.js; no I/O.
3
3
  import { TowerToolError } from '../domain/errors.js'
4
4
  import { ROSTER_INACTIVE_STATES } from '../domain/roster.js'
5
- import { inspectLeases } from './leases.js'
6
5
 
7
6
  /**
8
7
  * Pick a unique roster name before any activation.
@@ -132,9 +131,11 @@ export async function precheckWorkerOwnership({
132
131
 
133
132
  /**
134
133
  * Service/provider/preset/lease checks, run before provisioning.
135
- * @param {{ tower: any, ctx: any, config: any, preset: string, actor: any, exec: any }} input
134
+ * `inspectLeases` is injected by the composition root so the app layer never
135
+ * imports the lease adapter (#69).
136
+ * @param {{ tower: any, ctx: any, config: any, preset: string, actor: any, exec: any, inspectLeases: any }} input
136
137
  */
137
- export async function precheckSpawnServices({ tower, ctx, config, preset, actor, exec }) {
138
+ export async function precheckSpawnServices({ tower, ctx, config, preset, actor, exec, inspectLeases }) {
138
139
  const subagents = ctx.get('subagents')
139
140
  if (!subagents) throw new TowerToolError('spawn-unavailable', 'subagents service is unavailable; cannot spawn')
140
141
  const providers = subagents.list()
package/app/spawn.js CHANGED
@@ -8,7 +8,6 @@ import {
8
8
  rosterProjection,
9
9
  } from '../domain/collab/spawn.js'
10
10
  import { validateState } from '../domain/state.js'
11
- import { withLock, writeJsonAtomic, appendJsonl } from '../lib/store.js'
12
11
 
13
12
  function fail(message, code = 'invalid-argument') {
14
13
  throw new TowerToolError(code, message)
@@ -16,7 +15,7 @@ function fail(message, code = 'invalid-argument') {
16
15
 
17
16
  /** Record that a spawned agent failed or went idle, so roster state is truthful. */
18
17
  export async function recordSpawnOutcome(tower, input) {
19
- return withLock(tower.paths, async () => {
18
+ return tower.deps.store.withLock(tower.paths, async () => {
20
19
  const state = await tower.load()
21
20
  const entry = (state.roster ?? []).find((row) => row.name === input.name && row.missionId === input.missionId)
22
21
  if (!entry) return null
@@ -25,8 +24,8 @@ export async function recordSpawnOutcome(tower, input) {
25
24
  state.revision += 1
26
25
  state.updatedAt = now
27
26
  validateState(state, tower.paths.state)
28
- await writeJsonAtomic(tower.paths.state, state)
29
- await appendJsonl(tower.paths.activity, {
27
+ await tower.deps.store.writeJsonAtomic(tower.paths.state, state)
28
+ await tower.deps.store.appendJsonl(tower.paths.activity, {
30
29
  at: now,
31
30
  type: 'spawn',
32
31
  missionId: input.missionId,
@@ -38,7 +37,7 @@ export async function recordSpawnOutcome(tower, input) {
38
37
 
39
38
  /** Record that a live agent was interrupted by the tower. */
40
39
  export async function recordInterrupt(tower, input) {
41
- return withLock(tower.paths, async () => {
40
+ return tower.deps.store.withLock(tower.paths, async () => {
42
41
  const state = await tower.load()
43
42
  const entry = (state.roster ?? []).find(
44
43
  (row) =>
@@ -51,8 +50,8 @@ export async function recordInterrupt(tower, input) {
51
50
  state.revision += 1
52
51
  state.updatedAt = now
53
52
  validateState(state, tower.paths.state)
54
- await writeJsonAtomic(tower.paths.state, state)
55
- await appendJsonl(tower.paths.activity, {
53
+ await tower.deps.store.writeJsonAtomic(tower.paths.state, state)
54
+ await tower.deps.store.appendJsonl(tower.paths.activity, {
56
55
  at: now,
57
56
  type: 'spawn',
58
57
  missionId: entry.missionId,
@@ -64,7 +63,7 @@ export async function recordInterrupt(tower, input) {
64
63
 
65
64
  /** Reserve a roster name before activation so a failed spawn is recoverable. */
66
65
  export async function reserveRoster(tower, input) {
67
- return withLock(tower.paths, async () => {
66
+ return tower.deps.store.withLock(tower.paths, async () => {
68
67
  const state = await tower.load()
69
68
  const mission = state.missions.find((entry) => entry.id === input.missionId)
70
69
  if (!mission) fail(`unknown mission "${input.missionId}"`)
@@ -75,8 +74,8 @@ export async function reserveRoster(tower, input) {
75
74
  state.revision += 1
76
75
  state.updatedAt = now
77
76
  validateState(state, tower.paths.state)
78
- await writeJsonAtomic(tower.paths.state, state)
79
- await appendJsonl(tower.paths.activity, {
77
+ await tower.deps.store.writeJsonAtomic(tower.paths.state, state)
78
+ await tower.deps.store.appendJsonl(tower.paths.activity, {
80
79
  at: now,
81
80
  type: 'spawn',
82
81
  missionId: input.missionId,
@@ -89,7 +88,7 @@ export async function reserveRoster(tower, input) {
89
88
  export async function recordSpawn(tower, input) {
90
89
  // Capture the spawn tip before taking the state lock (#51).
91
90
  const spawnTip = input.cwd ? ((await tower.tryGit(input.cwd, ['rev-parse', 'HEAD'])) ?? '').trim() || null : null
92
- return withLock(tower.paths, async () => {
91
+ return tower.deps.store.withLock(tower.paths, async () => {
93
92
  const state = await tower.load()
94
93
  const mission = state.missions.find((entry) => entry.id === input.missionId)
95
94
  if (!mission) fail(`unknown mission "${input.missionId}"`)
@@ -108,8 +107,8 @@ export async function recordSpawn(tower, input) {
108
107
  state.updatedAt = now
109
108
 
110
109
  validateState(state, tower.paths.state)
111
- await writeJsonAtomic(tower.paths.state, state)
112
- await appendJsonl(tower.paths.activity, {
110
+ await tower.deps.store.writeJsonAtomic(tower.paths.state, state)
111
+ await tower.deps.store.appendJsonl(tower.paths.activity, {
113
112
  at: now,
114
113
  type: 'spawn',
115
114
  missionId: input.missionId,
package/app/teardown.js CHANGED
@@ -6,7 +6,6 @@
6
6
  // therefore bumps the state revision exactly once, and the revision is
7
7
  // revalidated immediately before the irreversible git mutation so a stale
8
8
  // decision never removes a worktree that changed hands.
9
- import { withLock, withGitLock, writeJsonAtomic, appendJsonl } from '../lib/store.js'
10
9
  import { validateState } from '../domain/state.js'
11
10
  import { TowerToolError } from '../domain/errors.js'
12
11
  import { hasLiveAgent } from '../domain/integration/liveness.js'
@@ -78,7 +77,7 @@ async function prepareTeardown(tower, input) {
78
77
  /** Short state lock: reject a stale decision before any git mutation. */
79
78
  async function claimRemoval(tower, candidate, expectedRevision, input) {
80
79
  const activity = input.activity ?? {}
81
- return withLock(tower.paths, async () => {
80
+ return tower.deps.store.withLock(tower.paths, async () => {
82
81
  const state = await tower.load()
83
82
  if (state.revision !== expectedRevision) {
84
83
  throw new TowerToolError('stale-base', `${STALE}. No worktree was removed.`)
@@ -105,7 +104,7 @@ export async function teardown(tower, input) {
105
104
  // never under the state lock. Re-read state without the lock right before
106
105
  // mutating: the git lock may have waited behind another operation, so a
107
106
  // stale candidate is refused while the worktree still exists.
108
- const outcome = await withGitLock(tower.paths, async () => {
107
+ const outcome = await tower.deps.store.withGitLock(tower.paths, async () => {
109
108
  let fresh
110
109
  try {
111
110
  fresh = await tower.load()
@@ -140,7 +139,7 @@ export async function teardown(tower, input) {
140
139
  branchDeleteError = String(error?.message ?? error).slice(0, 240)
141
140
  }
142
141
  return { phase: 'removed', branch: worktree.branch, branchDeleteError }
143
- })
142
+ }, tower.gitMutexOptions({ phase: 'teardown' }))
144
143
 
145
144
  if (outcome.phase === 'stale') {
146
145
  throw new TowerToolError('stale-base', `${STALE}: ${outcome.detail}. No worktree was removed.`)
@@ -155,7 +154,7 @@ export async function teardown(tower, input) {
155
154
  // the removal on the freshly loaded state instead of rejecting it (which
156
155
  // would leave state claiming a worktree that no longer exists). Only this
157
156
  // mission's fields change, so no concurrent update is lost.
158
- const commit = await withLock(tower.paths, async () => {
157
+ const commit = await tower.deps.store.withLock(tower.paths, async () => {
159
158
  const state = await tower.load()
160
159
  const mission = state.missions.find((entry) => entry.id === candidate.missionId)
161
160
  if (!mission) return { revision: state.revision }
@@ -167,8 +166,8 @@ export async function teardown(tower, input) {
167
166
  state.revision += 1
168
167
  state.updatedAt = now
169
168
  validateState(state, tower.paths.state)
170
- await writeJsonAtomic(tower.paths.state, state)
171
- await appendJsonl(tower.paths.activity, {
169
+ await tower.deps.store.writeJsonAtomic(tower.paths.state, state)
170
+ await tower.deps.store.appendJsonl(tower.paths.activity, {
172
171
  at: now,
173
172
  type: 'teardown',
174
173
  missionId: mission.id,
@@ -184,12 +183,12 @@ export async function teardown(tower, input) {
184
183
  // Archival runs under one final short lock. It is policy-driven (age or the
185
184
  // hot-terminal cap), so a mission torn down a moment ago stays hot until it
186
185
  // is old enough; `archive.afterDays: 0` archives it on this call.
187
- const archive = await withLock(tower.paths, async () => {
186
+ const archive = await tower.deps.store.withLock(tower.paths, async () => {
188
187
  const state = await tower.load()
189
188
  const swept = await sweepArchive(tower, state, { now: Date.now(), activity: input.activity ?? {} })
190
189
  if (swept.written) {
191
190
  validateState(state, tower.paths.state)
192
- await writeJsonAtomic(tower.paths.state, state)
191
+ await tower.deps.store.writeJsonAtomic(tower.paths.state, state)
193
192
  }
194
193
  return swept
195
194
  })