dsh-tower 2.1.0 → 2.3.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 (170) hide show
  1. package/CHANGELOG.md +103 -0
  2. package/COMPATIBILITY.md +5 -1
  3. package/CONTRIBUTING.md +64 -5
  4. package/PRINCIPLES.md +145 -0
  5. package/README.en.md +7 -4
  6. package/README.md +7 -4
  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/docs/README.md +6 -2
  44. package/docs/adr/0001-ports-adapters-as-data.md +49 -0
  45. package/docs/adr/0002-single-state-json.md +50 -0
  46. package/docs/adr/0003-allow-agent-control-default-true.md +48 -0
  47. package/docs/adr/0004-surface-budget.md +50 -0
  48. package/docs/adr/README.md +51 -0
  49. package/docs/agents/workflow.md +51 -0
  50. package/docs/architecture/overview.md +167 -36
  51. package/docs/guide/hot-reload.md +1 -1
  52. package/docs/guide/release.md +1 -1
  53. package/docs/operations/incident.md +82 -0
  54. package/docs/operations/runbook.md +12 -3
  55. package/docs/reference/blockers.md +2 -2
  56. package/docs/reference/config.generated.md +1 -0
  57. package/docs/reference/doctor.md +48 -2
  58. package/docs/reference/errors.generated.md +41 -37
  59. package/docs/reference/errors.md +53 -41
  60. package/docs/reference/events.md +38 -6
  61. package/docs/reference/surface-budget.md +128 -0
  62. package/docs/reference/tools.generated.md +5 -4
  63. package/docs/reference/tools.md +4 -4
  64. package/docs/zh/guide/hot-reload.md +1 -1
  65. package/docs/zh/reference/blockers.md +2 -2
  66. package/docs/zh/reference/doctor.md +29 -1
  67. package/docs/zh/reference/tools.md +4 -4
  68. package/domain/batch.js +2 -2
  69. package/domain/config/schema.js +25 -1
  70. package/domain/contracts/outputs.js +6 -2
  71. package/domain/contracts/tool-inputs/ops.js +1 -0
  72. package/domain/errors.js +297 -42
  73. package/domain/events.js +207 -0
  74. package/domain/integration/gate.js +17 -2
  75. package/domain/integration/merge.js +35 -0
  76. package/domain/lockBudget.js +30 -0
  77. package/domain/missions/completion.js +14 -0
  78. package/domain/missions/lifecycle.js +9 -3
  79. package/domain/missions/plan.js +14 -0
  80. package/domain/missions/review.js +90 -7
  81. package/domain/read/views.js +3 -0
  82. package/domain/state/archive.js +1 -1
  83. package/domain/state.js +1 -1
  84. package/domain/surface.js +248 -0
  85. package/{lib → host}/doctor.js +116 -9
  86. package/{lib → host}/modeStore.js +1 -1
  87. package/{lib → host}/tool-registry.js +106 -31
  88. package/host/tower.js +32 -0
  89. package/index.js +12 -9
  90. package/package.json +10 -3
  91. package/ports/archive.js +18 -0
  92. package/ports/caseSensitivity.js +15 -0
  93. package/ports/clock.js +10 -2
  94. package/ports/config.js +19 -0
  95. package/ports/contract.js +38 -0
  96. package/ports/events.js +24 -0
  97. package/ports/ids.js +10 -2
  98. package/ports/index.js +54 -2
  99. package/ports/ownership.js +17 -0
  100. package/ports/state.js +16 -0
  101. package/ports/store.js +52 -3
  102. package/ports/vcs.js +25 -4
  103. package/scripts/architecture-waivers.json +19 -0
  104. package/scripts/architecture.mjs +634 -23
  105. package/scripts/check-docs.mjs +8 -35
  106. package/scripts/check-pack.mjs +68 -39
  107. package/scripts/check-philosophy.mjs +266 -0
  108. package/scripts/domain-metrics.mjs +13 -4
  109. package/scripts/generate-config-reference.mjs +14 -1
  110. package/scripts/generate-error-reference.mjs +68 -14
  111. package/scripts/generate-events-reference.mjs +83 -0
  112. package/scripts/generate-tools-reference.mjs +11 -0
  113. package/scripts/issue-audit.mjs +2 -2
  114. package/scripts/lib/surface.mjs +87 -0
  115. package/scripts/pack-manifest.txt +58 -22
  116. package/scripts/regression-audit.mjs +164 -0
  117. package/scripts/release-check.mjs +1 -1
  118. package/scripts/surface-baseline.json +21 -0
  119. package/scripts/surface-baseline.mjs +120 -0
  120. package/tools/adopt.js +2 -3
  121. package/tools/batch.js +2 -3
  122. package/tools/complete.js +2 -3
  123. package/tools/diff.js +3 -3
  124. package/tools/doctor.js +7 -10
  125. package/tools/finding.js +2 -3
  126. package/tools/findings.js +3 -3
  127. package/tools/import-legacy.js +3 -5
  128. package/tools/inbox.js +3 -3
  129. package/tools/index.js +7 -7
  130. package/tools/init.js +3 -6
  131. package/tools/interrupt.js +2 -3
  132. package/tools/legacy-map.js +3 -5
  133. package/tools/merge.js +3 -3
  134. package/tools/mission.js +2 -3
  135. package/tools/plan.js +6 -5
  136. package/tools/preflight.js +2 -3
  137. package/tools/rebase.js +3 -3
  138. package/tools/recover.js +38 -13
  139. package/tools/reload.js +3 -6
  140. package/tools/resource.js +3 -5
  141. package/tools/review.js +3 -4
  142. package/tools/roster.js +2 -3
  143. package/tools/send.js +2 -2
  144. package/tools/spawn.js +8 -5
  145. package/tools/status.js +3 -3
  146. package/tools/teardown.js +2 -3
  147. package/tools/test.js +2 -2
  148. package/types/index.d.ts +1 -1
  149. package/ui-bundle/README.md +37 -3
  150. package/ui-bundle/client.cards.js +356 -0
  151. package/ui-bundle/client.denied.js +142 -0
  152. package/ui-bundle/client.dock.js +333 -0
  153. package/ui-bundle/client.helpers.js +271 -0
  154. package/ui-bundle/client.js +79 -1747
  155. package/ui-bundle/client.locales.js +247 -0
  156. package/ui-bundle/client.mode.js +206 -0
  157. package/ui-bundle/client.overlay.js +64 -0
  158. package/ui-bundle/client.styles.js +151 -0
  159. package/ui-bundle/client.toolview.js +82 -0
  160. package/ui-bundle/package.json +1 -0
  161. package/lib/persistence.js +0 -356
  162. package/lib/store.js +0 -133
  163. /package/{lib → adapters}/stateFile.js +0 -0
  164. /package/{lib → app}/briefing.js +0 -0
  165. /package/{lib → app}/spawn-support.js +0 -0
  166. /package/{lib → host}/compatibility.js +0 -0
  167. /package/{lib → host}/config.js +0 -0
  168. /package/{lib → host}/hmr.js +0 -0
  169. /package/{lib → host}/modeState.js +0 -0
  170. /package/{lib → host}/presets.js +0 -0
package/app/init.js CHANGED
@@ -6,8 +6,6 @@
6
6
  // the repo-scoped git mutex, never under the state lock.
7
7
  // commit — one short state lock writes/claims/reanchors, revalidating the
8
8
  // state revision captured by prepare.
9
- import { withLock, withGitLock, exists, ensureDir, writeJsonAtomic, appendJsonl } from '../lib/store.js'
10
- import { detectCaseSensitivity } from '../lib/caseSensitivity.js'
11
9
  import { validateState } from '../domain/state.js'
12
10
  import { TowerToolError } from '../domain/errors.js'
13
11
  import {
@@ -36,16 +34,13 @@ function fail(message, code = 'invalid-argument') {
36
34
  }
37
35
 
38
36
  /**
39
- * Probe the checkout's own filesystem through the injected seam. The default
40
- * composition uses the real probe; a composition that predates #90 keeps
41
- * working through the fallback.
37
+ * Probe the checkout's own filesystem through the injected case-sensitivity
38
+ * port. tower_init probes once and records the answer in state (#90).
42
39
  * @param {any} tower
43
40
  * @returns {Promise<boolean | null>}
44
41
  */
45
- async function detectCaseMode(tower) {
46
- const detect = tower.deps?.caseSensitivity?.detect
47
- if (typeof detect === 'function') return detect(tower.root)
48
- return detectCaseSensitivity(tower.root)
42
+ function detectCaseMode(tower) {
43
+ return tower.deps.caseSensitivity.detect(tower.root)
49
44
  }
50
45
 
51
46
  /** Phase 1 of `Tower.init` (#51). A read-only init returns its result here. */
@@ -68,7 +63,7 @@ async function prepareInit(tower, input) {
68
63
  return /** @type {{ base: string }} */ (fallback).base
69
64
  }
70
65
 
71
- if (await exists(tower.paths.state)) {
66
+ if (await tower.deps.store.exists(tower.paths.state)) {
72
67
  const state = await tower.load()
73
68
  const owner = state.towerAgentId ? String(state.towerAgentId) : null
74
69
  const caller = input.towerAgentId ? String(input.towerAgentId) : null
@@ -108,8 +103,8 @@ async function prepareInit(tower, input) {
108
103
  * revalidated first so a concurrent init cannot leave an unreferenced snapshot.
109
104
  */
110
105
  async function actInitCreate(tower, prepared) {
111
- return withGitLock(tower.paths, async () => {
112
- if (await exists(tower.paths.state)) return { stale: true }
106
+ return tower.deps.store.withGitLock(tower.paths, async () => {
107
+ if (await tower.deps.store.exists(tower.paths.state)) return { stale: true }
113
108
  if (!prepared.snapshot) return { snapshotCommit: null, snapshotRef: null }
114
109
  const snapshotCommit = ((await tower.tryGit(tower.root, ['stash', 'create'])) ?? '').trim() || null
115
110
  if (!snapshotCommit) {
@@ -120,12 +115,12 @@ async function actInitCreate(tower, prepared) {
120
115
  const snapshotRef = snapshotRefFor(snapshotCommit)
121
116
  await tower.git(tower.root, ['update-ref', snapshotRef, snapshotCommit])
122
117
  return { snapshotCommit, snapshotRef }
123
- })
118
+ }, tower.gitMutexOptions({ phase: 'init' }))
124
119
  }
125
120
 
126
121
  /** Phase 2b of `Tower.init` (#51): snapshot-ref cleanup for a base reanchor. */
127
122
  async function actInitReanchor(tower, prepared) {
128
- return withGitLock(tower.paths, async () => {
123
+ return tower.deps.store.withGitLock(tower.paths, async () => {
129
124
  const state = await tower.load()
130
125
  if (state.revision !== prepared.stateRevision) return { staleRevision: state.revision }
131
126
  if (state.base === prepared.base) return { deleted: [] }
@@ -143,26 +138,26 @@ async function actInitReanchor(tower, prepared) {
143
138
  }
144
139
  }
145
140
  return { deleted }
146
- })
141
+ }, tower.gitMutexOptions({ phase: 'init' }))
147
142
  }
148
143
 
149
144
  /** Restore snapshot refs when the reanchor commit is rejected after phase 2. */
150
145
  async function restoreInitRefs(tower, deleted) {
151
146
  const entries = deleted.filter((entry) => typeof entry.sha === 'string' && entry.sha.length > 0)
152
147
  if (entries.length === 0) return
153
- await withGitLock(tower.paths, async () => {
148
+ await tower.deps.store.withGitLock(tower.paths, async () => {
154
149
  for (const entry of entries) {
155
150
  await tower.git(tower.root, ['update-ref', entry.ref, entry.sha]).catch(() => {})
156
151
  }
157
- })
152
+ }, tower.gitMutexOptions({ phase: 'init' }))
158
153
  }
159
154
 
160
155
  /** Phase 3a of `Tower.init` (#51): write the initial state under a short lock. */
161
156
  async function commitInitCreate(tower, prepared, acted, input) {
162
- return withLock(tower.paths, async () => {
157
+ return tower.deps.store.withLock(tower.paths, async () => {
163
158
  // Revision revalidation for the create path: prepare saw "absent", so a
164
159
  // state file that appeared meanwhile must never be overwritten.
165
- if (await exists(tower.paths.state)) {
160
+ if (await tower.deps.store.exists(tower.paths.state)) {
166
161
  throw new TowerToolError('stale-base', 'tower state appeared while init was being prepared; retry')
167
162
  }
168
163
  const now = Date.now()
@@ -178,8 +173,8 @@ async function commitInitCreate(tower, prepared, acted, input) {
178
173
  now,
179
174
  })
180
175
  validateState(state, tower.paths.state)
181
- await writeJsonAtomic(tower.paths.state, state)
182
- await appendJsonl(tower.paths.activity, {
176
+ await tower.deps.store.writeJsonAtomic(tower.paths.state, state)
177
+ await tower.deps.store.appendJsonl(tower.paths.activity, {
183
178
  at: now,
184
179
  type: 'init',
185
180
  text: initActivityText({
@@ -204,7 +199,7 @@ async function commitInitCreate(tower, prepared, acted, input) {
204
199
 
205
200
  /** Phase 3b of `Tower.init` (#51): owner claim and reanchor writes under a short lock. */
206
201
  async function commitInitExisting(tower, prepared, input) {
207
- return withLock(tower.paths, async () => {
202
+ return tower.deps.store.withLock(tower.paths, async () => {
208
203
  const state = await tower.load()
209
204
  if (state.revision !== prepared.stateRevision) {
210
205
  throw new TowerToolError(
@@ -224,8 +219,8 @@ async function commitInitExisting(tower, prepared, input) {
224
219
  state.revision += 1
225
220
  state.updatedAt = now
226
221
  validateState(state, tower.paths.state)
227
- await writeJsonAtomic(tower.paths.state, state)
228
- await appendJsonl(tower.paths.activity, {
222
+ await tower.deps.store.writeJsonAtomic(tower.paths.state, state)
223
+ await tower.deps.store.appendJsonl(tower.paths.activity, {
229
224
  at: now,
230
225
  type: 'init',
231
226
  text: ownerClaimActivityText(claimant),
@@ -240,8 +235,8 @@ async function commitInitExisting(tower, prepared, input) {
240
235
  state.revision += 1
241
236
  state.updatedAt = now
242
237
  validateState(state, tower.paths.state)
243
- await writeJsonAtomic(tower.paths.state, state)
244
- await appendJsonl(tower.paths.activity, {
238
+ await tower.deps.store.writeJsonAtomic(tower.paths.state, state)
239
+ await tower.deps.store.appendJsonl(tower.paths.activity, {
245
240
  at: now,
246
241
  type: 'init',
247
242
  text: caseProbeActivityText(prepared.caseInsensitive),
@@ -256,8 +251,8 @@ async function commitInitExisting(tower, prepared, input) {
256
251
  state.revision += 1
257
252
  state.updatedAt = now
258
253
  validateState(state, tower.paths.state)
259
- await writeJsonAtomic(tower.paths.state, state)
260
- await appendJsonl(tower.paths.activity, { at: now, type: 'init', text: reanchorActivityText(prepared.base) })
254
+ await tower.deps.store.writeJsonAtomic(tower.paths.state, state)
255
+ await tower.deps.store.appendJsonl(tower.paths.activity, { at: now, type: 'init', text: reanchorActivityText(prepared.base) })
261
256
  }
262
257
  return existingInitWriteResult({ state, statePath: tower.paths.state, dirty: prepared.dirty, reanchored })
263
258
  })
@@ -265,7 +260,7 @@ async function commitInitExisting(tower, prepared, input) {
265
260
 
266
261
  /** #51 two-phase init: prepare/act/commit with stale-state retries. */
267
262
  export async function init(tower, input = {}) {
268
- await ensureDir(tower.paths.dir)
263
+ await tower.deps.store.ensureDir(tower.paths.dir)
269
264
  const prepared = await prepareInit(tower, input)
270
265
  if (prepared.early) return prepared.early
271
266
  if (prepared.create) {
package/app/merge.js CHANGED
@@ -17,10 +17,11 @@
17
17
  // from. A writer that slipped in between observation and write makes the
18
18
  // prepare stale, so the whole prepare is retried, never partially applied.
19
19
  import path from 'node:path'
20
- import { withLock, withGitLock, writeJsonAtomic, appendJsonl, exists } from '../lib/store.js'
21
20
  import { validateState } from '../domain/state.js'
22
21
  import {
23
22
  alreadyMergedResult,
23
+ applyLandedMerge,
24
+ applyMergeSuccess,
24
25
  buildMergeIntent,
25
26
  classifyCommitOutcome,
26
27
  gateTipDrift,
@@ -37,7 +38,6 @@ import {
37
38
  mergeIntentEvidence,
38
39
  mergeIntentReconcileNote,
39
40
  mergeShapeDetail,
40
- mergeSuccessNote,
41
41
  missionMissingResult,
42
42
  pendingMergeIntentResult,
43
43
  staleBaseResult,
@@ -83,7 +83,7 @@ async function observeMergeIntent(tower, state, mission) {
83
83
  } catch {
84
84
  return null
85
85
  }
86
- const mergeInProgress = await exists(path.join(tower.commonDir, 'MERGE_HEAD'))
86
+ const mergeInProgress = await tower.deps.store.exists(path.join(tower.commonDir, 'MERGE_HEAD'))
87
87
  if (mergeInProgress) return null
88
88
  if (head === intent.baseTip) return mergeIntentEvidence({ head, baseTip: intent.baseTip, mergeInProgress, landed: null })
89
89
  const tips = new Set([intent.branchTip, intent.baseTip].filter(Boolean))
@@ -98,7 +98,7 @@ async function observeMergeIntent(tower, state, mission) {
98
98
  * the new state instead of overwriting it.
99
99
  */
100
100
  async function applyMergeIntentReconcile(tower, stateRevision, missionId, evidence) {
101
- return withLock(tower.paths, async () => {
101
+ return tower.deps.store.withLock(tower.paths, async () => {
102
102
  const state = await tower.load()
103
103
  if (state.revision !== stateRevision) return { retry: true }
104
104
  const mission = state.missions.find((entry) => entry.id === missionId)
@@ -108,8 +108,8 @@ async function applyMergeIntentReconcile(tower, stateRevision, missionId, eviden
108
108
  delete state.mergeIntent
109
109
  state.revision += 1
110
110
  state.updatedAt = now
111
- await writeJsonAtomic(tower.paths.state, state)
112
- await appendJsonl(tower.paths.activity, {
111
+ await tower.deps.store.writeJsonAtomic(tower.paths.state, state)
112
+ await tower.deps.store.appendJsonl(tower.paths.activity, {
113
113
  at: now,
114
114
  type: 'merge',
115
115
  missionId: mission.id,
@@ -173,31 +173,30 @@ async function observeLandedMerge(tower, state, mission) {
173
173
 
174
174
  /** Locked write half of `reconcileLandedMerge`, guarded by the revision CAS. */
175
175
  async function applyLandedMergeReconcile(tower, stateRevision, missionId, observed) {
176
- return withLock(tower.paths, async () => {
176
+ return tower.deps.store.withLock(tower.paths, async () => {
177
177
  const state = await tower.load()
178
178
  if (state.revision !== stateRevision) return { retry: true }
179
179
  const mission = state.missions.find((entry) => entry.id === missionId)
180
180
  if (!mission) return { result: missionMissingResult(missionId) }
181
181
  const now = Date.now()
182
182
  if (state.mergeIntent?.missionId === mission.id) delete state.mergeIntent
183
- mission.status = 'merged'
184
- mission.mergedAt = now
185
- mission.mergedCommit = observed.mergedCommit
186
- mission.blockers = []
187
- mission.notes.push(
188
- landedMergeNote({ found: observed.found, mergedCommit: observed.mergedCommit, base: state.base }),
189
- )
190
- mission.updatedAt = now
183
+ const applied = applyLandedMerge({
184
+ mission,
185
+ mergedCommit: observed.mergedCommit,
186
+ note: landedMergeNote({ found: observed.found, mergedCommit: observed.mergedCommit, base: state.base }),
187
+ now,
188
+ })
191
189
  state.revision += 1
192
190
  state.updatedAt = now
193
191
  validateState(state, tower.paths.state)
194
- await writeJsonAtomic(tower.paths.state, state)
195
- await appendJsonl(tower.paths.activity, {
192
+ await tower.deps.store.writeJsonAtomic(tower.paths.state, state)
193
+ await tower.deps.store.appendJsonl(tower.paths.activity, {
196
194
  at: now,
197
195
  type: 'merge',
198
196
  missionId: mission.id,
199
197
  text: `reconciled already-landed ${observed.mergedCommit.slice(0, 7)} on ${state.base}`,
200
198
  })
199
+ tower.emitEvents(applied.events, { revision: state.revision, at: now })
201
200
  return { result: landedMergeResult(observed) }
202
201
  })
203
202
  }
@@ -302,7 +301,7 @@ async function prepareFromState(tower, input, deps) {
302
301
  * next tower_merge/preflight instead of leaving an unexplained base tip.
303
302
  */
304
303
  async function recordMergeIntent(tower, plan) {
305
- return withLock(tower.paths, async () => {
304
+ return tower.deps.store.withLock(tower.paths, async () => {
306
305
  const state = await tower.load()
307
306
  // State revision policy: the plan was built from `plan.stateRevision`; a
308
307
  // writer that slipped in before any Git mutation makes the plan stale and
@@ -336,7 +335,7 @@ async function recordMergeIntent(tower, plan) {
336
335
  })
337
336
  state.revision += 1
338
337
  state.updatedAt = now
339
- await writeJsonAtomic(tower.paths.state, state)
338
+ await tower.deps.store.writeJsonAtomic(tower.paths.state, state)
340
339
  return { intent: state.mergeIntent, intentRevision: state.revision }
341
340
  })
342
341
  }
@@ -361,7 +360,7 @@ async function runMergeGit(tower, plan, intent, deps) {
361
360
  }
362
361
 
363
362
  const newTip = await tower.revParse(tower.root, 'HEAD')
364
- const parents = await commitParents(tower.root, newTip)
363
+ const parents = await commitParents(tower, tower.root, newTip)
365
364
  const expectedShape = isExpectedMergeShape(parents, intent.baseTip, intent.branchTip)
366
365
  const warnings = []
367
366
 
@@ -384,12 +383,12 @@ async function runMergeGit(tower, plan, intent, deps) {
384
383
  const conflict = unmerged.trim().length > 0
385
384
  let cleanup = 'none'
386
385
  const mergeHeadPath = path.join(tower.commonDir, 'MERGE_HEAD')
387
- if (await exists(mergeHeadPath)) {
386
+ if (await tower.deps.store.exists(mergeHeadPath)) {
388
387
  try {
389
388
  await tower.git(tower.root, ['merge', '--abort'])
390
389
  cleanup = 'aborted'
391
390
  } catch {
392
- await removeStaleGitLocks(tower.commonDir)
391
+ await removeStaleGitLocks(tower, tower.commonDir)
393
392
  try {
394
393
  await tower.git(tower.root, ['merge', '--abort'])
395
394
  cleanup = 'aborted-after-lock-cleanup'
@@ -411,7 +410,7 @@ async function runMergeGit(tower, plan, intent, deps) {
411
410
  if (dirtyAfter.length > 0) {
412
411
  const changed = new Set(plan.changedFiles)
413
412
  if (newTip === intent.baseTip && dirtyAfter.every((file) => changed.has(file))) {
414
- await removeStaleGitLocks(tower.commonDir)
413
+ await removeStaleGitLocks(tower, tower.commonDir)
415
414
  await tower.git(tower.root, ['reset', '--hard', intent.baseTip]).catch(() => {})
416
415
  cleanup = cleanup === 'none' ? 'reset' : `${cleanup}+reset`
417
416
  } else {
@@ -435,7 +434,7 @@ async function runMergeGit(tower, plan, intent, deps) {
435
434
 
436
435
  /** Phase 3: apply the observed outcome under a short state lock. */
437
436
  async function commitMerge(tower, plan, recorded, outcome) {
438
- return withLock(tower.paths, async () => {
437
+ return tower.deps.store.withLock(tower.paths, async () => {
439
438
  const state = await tower.load()
440
439
  const mission = state.missions.find((entry) => entry.id === plan.missionId)
441
440
  if (!mission) return missionMissingResult(plan.missionId)
@@ -463,21 +462,24 @@ async function commitMerge(tower, plan, recorded, outcome) {
463
462
  if (kind === 'merged') {
464
463
  const now = Date.now()
465
464
  if (intentActive) delete state.mergeIntent
466
- mission.status = 'merged'
467
- mission.mergedAt = now
468
- mission.mergedCommit = outcome.newTip
469
- mission.updatedAt = now
470
- mission.notes.push(mergeSuccessNote(recorded.intent.branchTip, state.base, outcome.newTip))
465
+ const applied = applyMergeSuccess({
466
+ mission,
467
+ branchTip: recorded.intent.branchTip,
468
+ base: state.base,
469
+ newTip: outcome.newTip,
470
+ now,
471
+ })
471
472
  state.revision += 1
472
473
  state.updatedAt = now
473
474
  validateState(state, tower.paths.state)
474
- await writeJsonAtomic(tower.paths.state, state)
475
- await appendJsonl(tower.paths.activity, {
475
+ await tower.deps.store.writeJsonAtomic(tower.paths.state, state)
476
+ await tower.deps.store.appendJsonl(tower.paths.activity, {
476
477
  at: now,
477
478
  type: 'merge',
478
479
  missionId: mission.id,
479
480
  text: `${recorded.intent.branchTip.slice(0, 7)} -> ${outcome.newTip.slice(0, 7)} on ${state.base}`,
480
481
  })
482
+ tower.emitEvents(applied.events, { revision: state.revision, at: now })
481
483
  return {
482
484
  ok: true,
483
485
  mergedCommit: outcome.newTip,
@@ -497,8 +499,8 @@ async function commitMerge(tower, plan, recorded, outcome) {
497
499
  state.mergeIntent.problem = { code: 'merge-shape', newTip: outcome.newTip, parents: outcome.parents, at: Date.now() }
498
500
  state.revision += 1
499
501
  state.updatedAt = Date.now()
500
- await writeJsonAtomic(tower.paths.state, state)
501
- await appendJsonl(tower.paths.activity, {
502
+ await tower.deps.store.writeJsonAtomic(tower.paths.state, state)
503
+ await tower.deps.store.appendJsonl(tower.paths.activity, {
502
504
  at: state.updatedAt,
503
505
  type: 'merge',
504
506
  missionId: mission.id,
@@ -528,8 +530,8 @@ async function commitMerge(tower, plan, recorded, outcome) {
528
530
  mission.updatedAt = now
529
531
  state.revision += 1
530
532
  state.updatedAt = now
531
- await writeJsonAtomic(tower.paths.state, state)
532
- await appendJsonl(tower.paths.activity, {
533
+ await tower.deps.store.writeJsonAtomic(tower.paths.state, state)
534
+ await tower.deps.store.appendJsonl(tower.paths.activity, {
533
535
  at: now,
534
536
  type: 'merge',
535
537
  missionId: mission.id,
@@ -550,23 +552,28 @@ export async function merge(tower, input, deps) {
550
552
  const plan = await prepareMerge(tower, input, deps)
551
553
  if (plan.result) return plan.result
552
554
 
553
- const acted = await withGitLock(tower.paths, async () => {
554
- // No Git mutation has happened yet: a base that moved after prepare is a
555
- // clean retryable stale-base rejection, with no intent left behind.
556
- const head = await tower.revParse(tower.root, 'HEAD')
557
- if (head !== plan.baseTip) {
558
- return {
559
- result: staleBaseResult(
560
- plan.missionId,
561
- `base moved after gate (${plan.baseTip.slice(0, 7)} -> ${head.slice(0, 7)}); re-run preflight`,
562
- ),
555
+ const acted = await tower.deps.store.withGitLock(
556
+ tower.paths,
557
+ async () => {
558
+ // No Git mutation has happened yet: a base that moved after prepare is a
559
+ // clean retryable stale-base rejection, with no intent left behind.
560
+ const head = await tower.revParse(tower.root, 'HEAD')
561
+ if (head !== plan.baseTip) {
562
+ return {
563
+ result: staleBaseResult(
564
+ plan.missionId,
565
+ `base moved after gate (${plan.baseTip.slice(0, 7)} -> ${head.slice(0, 7)}); re-run preflight`,
566
+ ),
567
+ }
563
568
  }
564
- }
565
- const recorded = await recordMergeIntent(tower, plan)
566
- if (recorded.result) return recorded
567
- const outcome = await runMergeGit(tower, plan, recorded.intent, deps)
568
- return { recorded, outcome }
569
- })
569
+ const recorded = await recordMergeIntent(tower, plan)
570
+ if (recorded.result) return recorded
571
+ const outcome = await runMergeGit(tower, plan, recorded.intent, deps)
572
+ return { recorded, outcome }
573
+ },
574
+ // The merge itself may hold the mutex for plan.timeoutMs (#93).
575
+ tower.gitMutexOptions({ phase: 'merge', mergeTimeoutMs: plan.timeoutMs }),
576
+ )
570
577
  if (acted.result) return acted.result
571
578
 
572
579
  return commitMerge(tower, plan, acted.recorded, acted.outcome)
package/app/messages.js CHANGED
@@ -1,5 +1,4 @@
1
1
  // Inbox/message use cases.
2
- import { withLock, writeJsonAtomic, appendJsonl } from '../lib/store.js'
3
2
  import { validateState } from '../domain/state.js'
4
3
  import { TowerToolError } from '../domain/errors.js'
5
4
  import {
@@ -14,7 +13,7 @@ function fail(message) {
14
13
  }
15
14
 
16
15
  export async function recordMessage(tower, input) {
17
- return withLock(tower.paths, async () => {
16
+ return tower.deps.store.withLock(tower.paths, async () => {
18
17
  const state = await tower.load()
19
18
  if (!Array.isArray(state.messages)) state.messages = []
20
19
  assertMessageRecipient({ roster: state.roster ?? [], to: input.to, from: input.from })
@@ -27,8 +26,8 @@ export async function recordMessage(tower, input) {
27
26
  state.updatedAt = now
28
27
 
29
28
  validateState(state, tower.paths.state)
30
- await writeJsonAtomic(tower.paths.state, state)
31
- await appendJsonl(tower.paths.activity, {
29
+ await tower.deps.store.writeJsonAtomic(tower.paths.state, state)
30
+ await tower.deps.store.appendJsonl(tower.paths.activity, {
32
31
  at: now,
33
32
  type: 'message',
34
33
  text: `${input.from} -> ${input.to}: ${input.subject}`,
@@ -38,20 +37,20 @@ export async function recordMessage(tower, input) {
38
37
  }
39
38
 
40
39
  export async function recordDelivery(tower, messageId, results) {
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 message = (state.messages ?? []).find((entry) => entry.id === messageId)
44
43
  if (!message) fail(`unknown message "${messageId}"`)
45
44
  addDelivery(message, results)
46
45
  state.revision += 1
47
46
  state.updatedAt = Date.now()
48
- await writeJsonAtomic(tower.paths.state, state)
47
+ await tower.deps.store.writeJsonAtomic(tower.paths.state, state)
49
48
  return message.delivery
50
49
  })
51
50
  }
52
51
 
53
52
  export async function inbox(tower, input) {
54
- return withLock(tower.paths, async () => {
53
+ return tower.deps.store.withLock(tower.paths, async () => {
55
54
  const state = await tower.load()
56
55
  const now = Date.now()
57
56
  const { payload, changed } = inboxView(state.messages ?? [], input, now)
@@ -59,7 +58,7 @@ export async function recordMessage(tower, input) {
59
58
  state.revision += 1
60
59
  state.updatedAt = now
61
60
  validateState(state, tower.paths.state)
62
- await writeJsonAtomic(tower.paths.state, state)
61
+ await tower.deps.store.writeJsonAtomic(tower.paths.state, state)
63
62
  }
64
63
  return payload
65
64
  })
package/app/mission.js CHANGED
@@ -3,7 +3,6 @@ import { TowerToolError } from '../domain/errors.js'
3
3
  import { applyMissionUpdate } from '../domain/missions/lifecycle.js'
4
4
  import { validateState } from '../domain/state.js'
5
5
  import { fullMission } from '../domain/summary.js'
6
- import { withLock, writeJsonAtomic, appendJsonl } from '../lib/store.js'
7
6
  import { sweepArchive } from './archive.js'
8
7
 
9
8
  function fail(message, code = 'invalid-argument') {
@@ -13,11 +12,11 @@ function fail(message, code = 'invalid-argument') {
13
12
  export async function updateMission(tower, input = {}) {
14
13
  const missionId = typeof input.id === 'string' ? input.id.trim() : input.id
15
14
  if (typeof missionId !== 'string' || missionId.length === 0) fail('tower_mission requires id')
16
- return withLock(tower.paths, async () => {
15
+ return tower.deps.store.withLock(tower.paths, async () => {
17
16
  const state = await tower.load()
18
17
  const mission = state.missions.find((entry) => entry.id === missionId)
19
18
  if (!mission) fail(`unknown mission "${missionId}"`, 'mission-not-found')
20
- const { changes } = applyMissionUpdate({ mission, state, input, config: tower.config })
19
+ const { changes, events } = applyMissionUpdate({ mission, state, input, config: tower.config })
21
20
  const now = Date.now()
22
21
  mission.updatedAt = now
23
22
  await sweepArchive(tower, state, { now })
@@ -25,13 +24,14 @@ export async function updateMission(tower, input = {}) {
25
24
  state.updatedAt = now
26
25
 
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: 'mission',
32
31
  missionId: mission.id,
33
32
  text: changes.join('; '),
34
33
  })
34
+ tower.emitEvents(events, { revision: state.revision, at: now })
35
35
  return { revision: state.revision, mission: fullMission(mission) }
36
36
  })
37
37
  }
package/app/plan.js CHANGED
@@ -5,15 +5,15 @@ import {
5
5
  assignMissionIds,
6
6
  assertKnownDependencies,
7
7
  newMission,
8
+ applyPlan,
8
9
  } from '../domain/missions/plan.js'
9
10
  import { validateState } from '../domain/state.js'
10
11
  import { fullMission } from '../domain/summary.js'
11
- import { withLock, writeJsonAtomic, appendJsonl } from '../lib/store.js'
12
12
  import { sweepArchive } from './archive.js'
13
13
 
14
14
  export async function plan(tower, input = {}) {
15
15
  const prepared = normalizePlanMissions(input)
16
- return withLock(tower.paths, async () => {
16
+ return tower.deps.store.withLock(tower.paths, async () => {
17
17
  const state = await tower.load()
18
18
  const now = Date.now()
19
19
  // Archived ids stay in the known set: a new mission must not reuse an id
@@ -28,18 +28,21 @@ export async function plan(tower, input = {}) {
28
28
  assertAcyclic([...known, ...assigned])
29
29
 
30
30
  const created = assigned.map((mission) => newMission(mission.id, mission, now))
31
- state.missions.push(...created)
31
+ const applied = applyPlan(state, created)
32
32
  const swept = await sweepArchive(tower, state, { now })
33
33
  state.revision += 1
34
34
  state.updatedAt = now
35
35
 
36
36
  validateState(state, tower.paths.state)
37
- await writeJsonAtomic(tower.paths.state, state)
38
- await appendJsonl(tower.paths.activity, {
37
+ await tower.deps.store.writeJsonAtomic(tower.paths.state, state)
38
+ await tower.deps.store.appendJsonl(tower.paths.activity, {
39
39
  at: now,
40
40
  type: 'plan',
41
41
  text: `created ${created.map((mission) => mission.id).join(', ')}`,
42
42
  })
43
+ // Persist first, then describe: the event carries the revision the caller
44
+ // can read back from state.json (#75).
45
+ tower.emitEvents(applied.events, { revision: state.revision, at: now })
43
46
  return {
44
47
  revision: state.revision,
45
48
  missions: created.map((mission) => fullMission(mission)),
package/app/provision.js CHANGED
@@ -7,9 +7,7 @@
7
7
  // mission still has no worktree, then records it. A rejected
8
8
  // commit rolls the created worktree/branch back outside the lock.
9
9
  import path from 'node:path'
10
- import { withLock, withGitLock, exists, ensureDir, writeJsonAtomic, appendJsonl, shortHash } from '../lib/store.js'
11
10
  import { validateState } from '../domain/state.js'
12
- import { canonicalPath } from '../lib/worktreeOwnership.js'
13
11
  import { TowerToolError } from '../domain/errors.js'
14
12
  import {
15
13
  baseCommitChoice,
@@ -53,13 +51,13 @@ async function prepareProvision(tower, input) {
53
51
  worktreeRoot: input.worktreeRoot,
54
52
  configuredRoot: tower.config.worktrees.root,
55
53
  repoRoot: tower.root,
56
- commonDirHash: shortHash(tower.commonDir),
54
+ commonDirHash: tower.deps.ids.shortHash(tower.commonDir),
57
55
  })
58
56
  if (!path.isAbsolute(root)) fail('worktreeRoot must be an absolute path')
59
57
  tower.assertOutside(root)
60
- await ensureDir(root)
58
+ await tower.deps.store.ensureDir(root)
61
59
  const wtPath = worktreePath(root, mission.id)
62
- if (await exists(wtPath)) fail(`worktree path already exists: ${wtPath}`)
60
+ if (await tower.deps.store.exists(wtPath)) fail(`worktree path already exists: ${wtPath}`)
63
61
  if ((await tower.tryGit(tower.root, ['show-ref', '--verify', '--quiet', `refs/heads/${branch}`])) !== null) {
64
62
  fail(`branch already exists: ${branch}`)
65
63
  }
@@ -68,7 +66,7 @@ async function prepareProvision(tower, input) {
68
66
 
69
67
  /** Phase 2 of `Tower.provision` (#51): no state lock, worktree add under the git mutex. */
70
68
  async function actProvision(tower, prepared) {
71
- return withGitLock(tower.paths, async () => {
69
+ return tower.deps.store.withGitLock(tower.paths, async () => {
72
70
  let fresh
73
71
  try {
74
72
  fresh = await tower.load()
@@ -84,7 +82,7 @@ async function actProvision(tower, prepared) {
84
82
  await tower.assertWorktree(mission.worktree)
85
83
  return { phase: 'existing', mission, worktree: mission.worktree }
86
84
  }
87
- if (await exists(prepared.wtPath)) fail(`worktree path already exists: ${prepared.wtPath}`)
85
+ if (await tower.deps.store.exists(prepared.wtPath)) fail(`worktree path already exists: ${prepared.wtPath}`)
88
86
  if ((await tower.tryGit(tower.root, ['show-ref', '--verify', '--quiet', `refs/heads/${prepared.branch}`])) !== null) {
89
87
  fail(`branch already exists: ${prepared.branch}`)
90
88
  }
@@ -95,15 +93,15 @@ async function actProvision(tower, prepared) {
95
93
  branch: prepared.branch,
96
94
  baseCommit: prepared.baseCommit,
97
95
  createdAt: Date.now(),
98
- realPath: await canonicalPath(prepared.wtPath),
96
+ realPath: await tower.deps.ownership.canonicalPath(prepared.wtPath),
99
97
  })
100
98
  return { phase: 'created', worktree }
101
- })
99
+ }, tower.gitMutexOptions({ phase: 'provision' }))
102
100
  }
103
101
 
104
102
  /** Phase 3 of `Tower.provision` (#51): record the worktree under a short lock. */
105
103
  async function commitProvision(tower, prepared, acted) {
106
- return withLock(tower.paths, async () => {
104
+ return tower.deps.store.withLock(tower.paths, async () => {
107
105
  const state = await tower.load()
108
106
  const mission = state.missions.find((entry) => entry.id === prepared.missionId)
109
107
  if (!mission) throw new TowerToolError('mission-not-found', `unknown mission "${prepared.missionId}"`)
@@ -119,11 +117,11 @@ async function commitProvision(tower, prepared, acted) {
119
117
  state.updatedAt = now
120
118
  try {
121
119
  validateState(state, tower.paths.state)
122
- await writeJsonAtomic(tower.paths.state, state)
120
+ await tower.deps.store.writeJsonAtomic(tower.paths.state, state)
123
121
  } catch (error) {
124
122
  return { phase: 'persist-error', error }
125
123
  }
126
- await appendJsonl(tower.paths.activity, {
124
+ await tower.deps.store.appendJsonl(tower.paths.activity, {
127
125
  at: now,
128
126
  type: 'worktree',
129
127
  missionId: prepared.missionId,
@@ -135,10 +133,10 @@ async function commitProvision(tower, prepared, acted) {
135
133
 
136
134
  /** Undo a created worktree/branch outside the state lock (git mutex only). */
137
135
  async function rollbackProvision(tower, prepared) {
138
- await withGitLock(tower.paths, async () => {
136
+ await tower.deps.store.withGitLock(tower.paths, async () => {
139
137
  await tower.git(tower.root, ['worktree', 'remove', '--force', prepared.wtPath]).catch(() => {})
140
138
  await tower.git(tower.root, ['branch', '-D', prepared.branch]).catch(() => {})
141
- })
139
+ }, tower.gitMutexOptions({ phase: 'provision' }))
142
140
  }
143
141
 
144
142
  /** #51 two-phase provision: prepare/act/commit with rollback on rejection. */