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
@@ -0,0 +1,204 @@
1
+ import { TowerToolError } from '../domain/errors.js'
2
+ // Durable repo-scoped store: exclusive lock + atomic JSON + append-only activity.
3
+ // The atomic/lock/JSON primitives are shared with the mode store (#56 P2b).
4
+ import { fsp } from './store-fs.js'
5
+ import {
6
+ PROCESS_STARTED_AT,
7
+ acquireLock,
8
+ lockRecord,
9
+ lockTiming,
10
+ readLock,
11
+ } from './persistence.js'
12
+ import path from 'node:path'
13
+ import crypto from 'node:crypto'
14
+
15
+ export {
16
+ readJson,
17
+ writeJsonAtomic,
18
+ writeJsonAtomicSync,
19
+ renameWithRetry,
20
+ appendJsonl,
21
+ } from './persistence.js'
22
+
23
+ export function pathsFor(commonDir) {
24
+ const dir = path.join(commonDir, 'dsh-tower')
25
+ return {
26
+ dir,
27
+ state: path.join(dir, 'state.json'),
28
+ lock: path.join(dir, 'state.lock'),
29
+ gitLock: path.join(dir, 'git.lock'),
30
+ activity: path.join(dir, 'activity.jsonl'),
31
+ }
32
+ }
33
+
34
+ export async function exists(file) {
35
+ try {
36
+ await fsp.access(file)
37
+ return true
38
+ } catch {
39
+ return false
40
+ }
41
+ }
42
+
43
+ export async function ensureDir(dir) {
44
+ await fsp.mkdir(dir, { recursive: true })
45
+ }
46
+
47
+ export function shortHash(text) {
48
+ return crypto.createHash('sha256').update(String(text)).digest('hex').slice(0, 12)
49
+ }
50
+
51
+ export async function readActivityTail(file, limit = 12) {
52
+ const text = await fsp.readFile(file, 'utf8').catch(() => '')
53
+ const lines = text.split('\n').filter(Boolean)
54
+ const tail = lines.slice(Math.max(0, lines.length - limit))
55
+ const out = []
56
+ for (const line of tail) {
57
+ try {
58
+ out.push(JSON.parse(line))
59
+ } catch {
60
+ // keep going: a truncated activity line must not hide the rest
61
+ }
62
+ }
63
+ return out
64
+ }
65
+
66
+ export async function readStateLock(paths, options = {}) {
67
+ return readLock(paths.lock, options)
68
+ }
69
+
70
+ /** Read the repo-scoped git mutex; a missing/free lock is not an error. */
71
+ export async function readGitLock(paths, options = {}) {
72
+ return readLock(paths.gitLock, options)
73
+ }
74
+
75
+ // The git-mutex budget is pure policy over the resolved config (#93) and is
76
+ // shared with the app layer, so it lives in the domain (#69). Re-exported here
77
+ // for the store's callers and tests; the state lock keeps its short 5 s default.
78
+ export { GIT_LOCK_MARGIN_MS, gitLockTimeoutMs } from '../domain/lockBudget.js'
79
+
80
+ /**
81
+ * Read-only projection of the git mutex for tower_status/tower_doctor: the
82
+ * shape is `{ held: true, pid, phase, heldMs }` while a holder exists and
83
+ * `{ held: false }` when it is free.
84
+ */
85
+ export function gitLockView(lock, options = {}) {
86
+ const record = lock?.record
87
+ if (!record) return { held: false }
88
+ return {
89
+ held: true,
90
+ pid: record.pid ?? null,
91
+ phase: record.phase ?? null,
92
+ heldMs: lockTiming(record, options).ageMs,
93
+ }
94
+ }
95
+
96
+ /**
97
+ * Paths objects whose state lock this process currently holds. A state
98
+ * migration may be persisted only inside `withLock`: the loader reads this to
99
+ * tell a locked mutation (which may write) from a read-only load (which must
100
+ * migrate in memory and leave the file alone).
101
+ */
102
+ const HELD_STATE_LOCKS = new WeakSet()
103
+
104
+ /** @param {object} [paths] @returns {boolean} */
105
+ export function isStateLockHeld(paths) {
106
+ return Boolean(paths) && HELD_STATE_LOCKS.has(paths)
107
+ }
108
+
109
+ /**
110
+ * Hold the repo-wide tower lock for one mutation. A lock whose recorded PID is
111
+ * provably dead is reclaimed atomically; a live lock is reported, never
112
+ * stolen. While held the record heartbeats, so another process can tell a live
113
+ * writer from a recycled PID (#92). `heartbeatMs`/`heartbeatStaleMs`/`now` are
114
+ * injectable for tests; production callers use the defaults.
115
+ */
116
+ export async function withLock(paths, fn, options = {}) {
117
+ const timeoutMs = options.timeoutMs ?? 5000
118
+ const pollMs = options.pollMs ?? 25
119
+ const token = crypto.randomUUID()
120
+ const record = lockRecord(token, { startedAt: PROCESS_STARTED_AT }, options)
121
+ const held = await acquireLock(paths.lock, { ...options, token, record, timeoutMs, pollMs })
122
+ if (!held) {
123
+ const owner = await readStateLock(paths, options)
124
+ const ownerRecord = owner.record
125
+ const payload = owner.raw ?? (ownerRecord ? JSON.stringify(ownerRecord) : null)
126
+ throw new TowerToolError(
127
+ 'state-locked',
128
+ `tower state is locked by another writer${payload ? ` (${payload})` : ''}; retry after it finishes`,
129
+ )
130
+ }
131
+ const startedAt = Date.now()
132
+ HELD_STATE_LOCKS.add(paths)
133
+ try {
134
+ return await fn(paths)
135
+ } finally {
136
+ HELD_STATE_LOCKS.delete(paths)
137
+ await held.release()
138
+ await fsp
139
+ .appendFile(path.join(paths.dir, 'lock-holds.jsonl'), `${JSON.stringify({ at: Date.now(), ms: Date.now() - startedAt, pid: process.pid })}\n`, 'utf8')
140
+ .catch(() => {})
141
+ }
142
+ }
143
+
144
+ function formatSeconds(ms) {
145
+ if (!Number.isFinite(ms)) return 'an unknown time'
146
+ return `${Math.max(0, Math.round(ms / 1000))} s`
147
+ }
148
+
149
+ /**
150
+ * Serialize git mutations without holding the state lock. The caller owns the
151
+ * wait budget: a merge passes its resolved `plan.timeoutMs`, and the other git
152
+ * mutations pass the same merge-derived budget (#93). While held the record
153
+ * carries `kind: 'git'` and the caller's `phase` (merge/rebase/init/teardown/
154
+ * provision), so a waiter and the read-only diagnostics can name the holder.
155
+ */
156
+ export async function withGitLock(paths, fn, options = {}) {
157
+ const timeoutMs = options.timeoutMs ?? 60000
158
+ const pollMs = options.pollMs ?? 25
159
+ const phase = options.phase ?? 'git'
160
+ const lock = paths.gitLock
161
+ const token = crypto.randomUUID()
162
+ const clock = typeof options.now === 'function' ? options.now : Date.now
163
+ const waitStartedAt = clock()
164
+ const startedAt = Date.now()
165
+ // The heartbeat matters here too: a legitimate pre-merge hook can hold this
166
+ // lock for minutes, and a stale-heartbeat reclaim must not mistake that for
167
+ // a crash (#92, #93).
168
+ const held = await acquireLock(lock, {
169
+ ...options,
170
+ token,
171
+ record: lockRecord(token, { kind: 'git', phase }, options),
172
+ timeoutMs,
173
+ pollMs,
174
+ })
175
+ if (!held) {
176
+ const owner = await readLock(lock, options)
177
+ const record = owner.record
178
+ const waitedMs = Math.max(0, clock() - waitStartedAt)
179
+ const heldMs = record ? lockTiming(record, options).ageMs : null
180
+ const holderPid = record?.pid ?? null
181
+ const holderPhase = record?.phase ?? null
182
+ const facts = record
183
+ ? `held by pid ${holderPid ?? 'unknown'}${holderPhase ? ` (${holderPhase})` : ''} for ${formatSeconds(heldMs)}; waited ${formatSeconds(waitedMs)}`
184
+ : `the lock was released before it could be inspected; waited ${formatSeconds(waitedMs)}`
185
+ throw new TowerToolError(
186
+ 'state-locked',
187
+ `another git mutation is in progress for this repository: ${facts}; retry after it finishes`,
188
+ { details: { holderPid, holderPhase, heldMs, waitedMs } },
189
+ )
190
+ }
191
+ const waitedMs = Math.max(0, clock() - waitStartedAt)
192
+ try {
193
+ return await fn(paths)
194
+ } finally {
195
+ await held.release()
196
+ await fsp
197
+ .appendFile(
198
+ path.join(paths.dir, 'lock-holds.jsonl'),
199
+ `${JSON.stringify({ at: Date.now(), ms: Date.now() - startedAt, pid: process.pid, kind: 'git', phase, budgetMs: timeoutMs, waitedMs })}\n`,
200
+ 'utf8',
201
+ )
202
+ .catch(() => {})
203
+ }
204
+ }
@@ -19,10 +19,10 @@ import { TowerToolError } from '../domain/errors.js'
19
19
  // minCount/required shortfall) returns `ok: false` in the receipt;
20
20
  // - an invalid discovery configuration (or invalid call arguments) throws a
21
21
  // plain `Error`, which the tool layer wraps as `tower:invalid-argument`.
22
- import { spawn } from '../adapters/vcs-git.js'
23
- import { createWriteStream } from '../adapters/store-fs.js'
22
+ import { spawn } from './vcs-git.js'
23
+ import { createWriteStream } from './store-fs.js'
24
24
  import crypto from 'node:crypto'
25
- import { fsp } from '../adapters/store-fs.js'
25
+ import { fsp } from './store-fs.js'
26
26
  import path from 'node:path'
27
27
 
28
28
  import { tryGit } from './git.js'
@@ -279,7 +279,9 @@ const TEST_ENV_ALLOW = new Set([
279
279
  'NODE_PATH',
280
280
  ])
281
281
 
282
+ /** @returns {NodeJS.ProcessEnv} */
282
283
  export function testProcessEnv(source = process.env) {
284
+ /** @type {NodeJS.ProcessEnv} */
283
285
  const env = {}
284
286
  for (const [key, value] of Object.entries(source)) {
285
287
  if (!TEST_ENV_ALLOW.has(key)) continue
@@ -340,7 +342,8 @@ function closeStreams(streams) {
340
342
  return Promise.all(
341
343
  streams.map(
342
344
  (stream) =>
343
- new Promise((resolve) => {
345
+ /** @type {Promise<void>} */
346
+ (new Promise((resolve) => {
344
347
  if (stream.closed || stream.destroyed) {
345
348
  resolve()
346
349
  return
@@ -348,7 +351,7 @@ function closeStreams(streams) {
348
351
  stream.once('close', resolve)
349
352
  stream.once('error', resolve)
350
353
  stream.end()
351
- }),
354
+ })),
352
355
  ),
353
356
  )
354
357
  }
@@ -367,6 +370,7 @@ function spawnToFiles({ command, cwd, timeoutMs, maxBytes, stdoutPath, stderrPat
367
370
 
368
371
  return new Promise((resolve) => {
369
372
  let settled = false
373
+ /** @type {import('node:child_process').ChildProcess | null} */
370
374
  let child = null
371
375
  let timer = null
372
376
  let stdoutBytes = 0
@@ -1,4 +1,18 @@
1
- // The only lib/ module that imports node:child_process (#50).
1
+ // The only module in the plugin that imports node:child_process (#50).
2
+ //
3
+ // Since #69 this is a real adapter, not a re-export shim: `createVcsPort()`
4
+ // returns a runtime implementation of VcsPort (ports/vcs.js) that the
5
+ // composition root injects into the app layer. The bounded-command
6
+ // implementation lives next door in adapters/git.js (#70).
2
7
  import { execFile, spawn } from 'node:child_process'
8
+ import * as git from './git.js'
3
9
 
4
10
  export { execFile, spawn }
11
+
12
+ /**
13
+ * Build the Git vcs port (#69). The composition root assembles this once and
14
+ * injects it; a scripted or in-memory vcs can replace it wholesale.
15
+ */
16
+ export function createVcsPort() {
17
+ return Object.freeze({ ...git })
18
+ }
@@ -2,7 +2,7 @@
2
2
  // `mode.workspaceLock` is enabled. Same-user trusted model, like the rest of
3
3
  // the Tower store: it prevents accidental double control, not a malicious
4
4
  // writer.
5
- import { fsp } from '../adapters/store-fs.js'
5
+ import { fsp } from './store-fs.js'
6
6
  import path from 'node:path'
7
7
  import crypto from 'node:crypto'
8
8
 
@@ -6,7 +6,7 @@
6
6
  // and must still be the Git worktree of the recorded branch in the recorded
7
7
  // repository. Every mismatch is rejected with an actionable message; nothing
8
8
  // is repaired or recreated here.
9
- import { fs, fsp } from '../adapters/store-fs.js'
9
+ import { fs, fsp } from './store-fs.js'
10
10
  import path from 'node:path'
11
11
 
12
12
  import { git } from './git.js'
package/app/archive.js CHANGED
@@ -5,8 +5,6 @@
5
5
  // document is compacted, so a crash between the two leaves the mission in state
6
6
  // and the archive entry is deduped by id on retry. Nothing is deleted: the full
7
7
  // mission, its linked reviews and its findings are moved to archive/<year>.json.
8
- import { appendJsonl } from '../lib/store.js'
9
- import { readArchiveIndex, readArchivedEntry, writeArchiveEntries } from '../lib/archive.js'
10
8
  import { hasLiveAgent } from '../domain/integration/liveness.js'
11
9
  import { missionSummary } from '../domain/summary.js'
12
10
  import {
@@ -28,7 +26,7 @@ import {
28
26
  * @returns {Promise<{ ok: boolean, file: string, missions: any[], error?: string, exists: boolean }>}
29
27
  */
30
28
  export function archiveIndex(tower) {
31
- return readArchiveIndex(tower.deps.store, tower.paths)
29
+ return tower.deps.archive.readIndex(tower.deps.store, tower.paths)
32
30
  }
33
31
 
34
32
  /**
@@ -37,7 +35,7 @@ export function archiveIndex(tower) {
37
35
  * @param {string} missionId
38
36
  */
39
37
  export function archivedMission(tower, missionId) {
40
- return readArchivedEntry(tower.deps.store, tower.paths, missionId)
38
+ return tower.deps.archive.readEntry(tower.deps.store, tower.paths, missionId)
41
39
  }
42
40
 
43
41
  /**
@@ -145,7 +143,7 @@ export async function sweepArchive(tower, state, options = {}) {
145
143
 
146
144
  let written
147
145
  try {
148
- written = await writeArchiveEntries(tower.deps.store, tower.paths, { entries, now })
146
+ written = await tower.deps.archive.writeEntries(tower.deps.store, tower.paths, { entries, now })
149
147
  } catch (error) {
150
148
  return { archived: [], written: false, error: `archive write failed: ${String(error?.message ?? error)}` }
151
149
  }
@@ -172,7 +170,7 @@ export async function sweepArchive(tower, state, options = {}) {
172
170
  state.updatedAt = now
173
171
 
174
172
  for (const mission of selected) {
175
- await appendJsonl(tower.paths.activity, {
173
+ await tower.deps.store.appendJsonl(tower.paths.activity, {
176
174
  at: now,
177
175
  type: 'archive',
178
176
  missionId: mission.id,
package/app/batch.js CHANGED
@@ -1,6 +1,5 @@
1
1
  // Batch use cases.
2
2
  import { TowerToolError } from '../domain/errors.js'
3
- import { withLock, writeJsonAtomic, appendJsonl } from '../lib/store.js'
4
3
  import { validateState } from '../domain/state.js'
5
4
  import { validateCreate as validateBatchCreate, validateClose as validateBatchClose } from '../domain/batch.js'
6
5
  import {
@@ -17,7 +16,7 @@ function fail(message, code = 'batch-invalid') {
17
16
  }
18
17
 
19
18
  export async function createBatch(tower, input = {}, actor = 'tower') {
20
- return withLock(tower.paths, async () => {
19
+ return tower.deps.store.withLock(tower.paths, async () => {
21
20
  const state = await tower.load()
22
21
  const normalized = validateBatchCreate(state, input)
23
22
  if (!Array.isArray(state.batches)) state.batches = []
@@ -26,8 +25,8 @@ export async function createBatch(tower, input = {}, actor = 'tower') {
26
25
  state.revision += 1
27
26
  state.updatedAt = batch.createdAt
28
27
  validateState(state, tower.paths.state)
29
- await writeJsonAtomic(tower.paths.state, state)
30
- await appendJsonl(tower.paths.activity, {
28
+ await tower.deps.store.writeJsonAtomic(tower.paths.state, state)
29
+ await tower.deps.store.appendJsonl(tower.paths.activity, {
31
30
  at: batch.createdAt,
32
31
  type: 'batch',
33
32
  text: `created ${batch.id}: ${batch.missionIds.join(', ')}`,
@@ -37,7 +36,7 @@ export async function createBatch(tower, input = {}, actor = 'tower') {
37
36
  }
38
37
 
39
38
  export async function closeBatch(tower, batchId, options = {}, actor = 'tower') {
40
- return withLock(tower.paths, async () => {
39
+ return tower.deps.store.withLock(tower.paths, async () => {
41
40
  const state = await tower.load()
42
41
  const batch = (state.batches ?? []).find((entry) => entry.id === batchId)
43
42
  if (!batch) fail(`unknown batch "${batchId}"`)
@@ -48,15 +47,15 @@ export async function createBatch(tower, input = {}, actor = 'tower') {
48
47
  state.revision += 1
49
48
  state.updatedAt = batch.closedAt
50
49
  validateState(state, tower.paths.state)
51
- await writeJsonAtomic(tower.paths.state, state)
52
- await appendJsonl(tower.paths.activity, { at: batch.closedAt, type: 'batch', text: `closed ${batch.id}` })
50
+ await tower.deps.store.writeJsonAtomic(tower.paths.state, state)
51
+ await tower.deps.store.appendJsonl(tower.paths.activity, { at: batch.closedAt, type: 'batch', text: `closed ${batch.id}` })
53
52
  return { ok: true, batch, blockers: [], checks: result.checks ?? [] }
54
53
  })
55
54
  }
56
55
 
57
56
  /** @param {string} batchId @param {string} [actor] @param {string | null} [reason] */
58
57
  export async function abandonBatch(tower, batchId, actor = 'tower', reason = null) {
59
- return withLock(tower.paths, async () => {
58
+ return tower.deps.store.withLock(tower.paths, async () => {
60
59
  const state = await tower.load()
61
60
  const batch = (state.batches ?? []).find((entry) => entry.id === batchId)
62
61
  if (!batch) fail(`unknown batch "${batchId}"`)
@@ -67,8 +66,8 @@ export async function createBatch(tower, input = {}, actor = 'tower') {
67
66
  state.revision += 1
68
67
  state.updatedAt = batch.abandonedAt
69
68
  validateState(state, tower.paths.state)
70
- await writeJsonAtomic(tower.paths.state, state)
71
- await appendJsonl(tower.paths.activity, { at: batch.abandonedAt, type: 'batch', text: `abandoned ${batch.id}` })
69
+ await tower.deps.store.writeJsonAtomic(tower.paths.state, state)
70
+ await tower.deps.store.appendJsonl(tower.paths.activity, { at: batch.abandonedAt, type: 'batch', text: `abandoned ${batch.id}` })
72
71
  return { ok: true, batch, alreadyAbandoned: false }
73
72
  })
74
73
  }
package/app/board.js CHANGED
@@ -1,7 +1,5 @@
1
1
  // Mission board (MISSIONS.md) writing use cases.
2
2
  import path from 'node:path'
3
- import { fsp } from '../adapters/store-fs.js'
4
- import { renameWithRetry } from '../lib/store.js'
5
3
  import { archiveView } from './archive.js'
6
4
 
7
5
  export function boardFile(tower) {
@@ -18,8 +16,8 @@ export async function writeBoard(tower) {
18
16
  const file = boardFile(tower)
19
17
  await tower.ensureExcluded(file)
20
18
  const tmp = `${file}.tmp-${process.pid}-${Date.now()}`
21
- await fsp.writeFile(tmp, tower.boardMarkdown(state, archiveView(index)), 'utf8')
22
- await renameWithRetry(tmp, file)
19
+ await tower.deps.store.writeText(tmp, tower.boardMarkdown(state, archiveView(index)), 'utf8')
20
+ await tower.deps.store.renameWithRetry(tmp, file)
23
21
  return file
24
22
  } catch {
25
23
  return null
@@ -5,11 +5,10 @@ import {
5
5
  assertCompletionRequest,
6
6
  assertCompletionInbox,
7
7
  completionReport,
8
+ applyCompletion,
8
9
  } from '../domain/missions/completion.js'
9
10
  import { fullMission } from '../domain/summary.js'
10
11
  import { validateState } from '../domain/state.js'
11
- import { countCommits, isClean } from '../lib/git.js'
12
- import { withLock, writeJsonAtomic, appendJsonl } from '../lib/store.js'
13
12
  import { sweepArchive } from './archive.js'
14
13
 
15
14
  export { normalizeChecks }
@@ -27,14 +26,14 @@ export async function checkCompletion(tower, input) {
27
26
  maxReportBytes: tower.config.gates?.maxReportBytes,
28
27
  })
29
28
  await tower.assertWorktree(mission.worktree)
30
- const commits = await countCommits(mission.worktree.path, mission.worktree.baseCommit)
29
+ const commits = await tower.deps.vcs.countCommits(mission.worktree.path, mission.worktree.baseCommit)
31
30
  if (commits < 1) {
32
31
  throw new TowerToolError(
33
32
  'invalid-argument',
34
33
  `mission branch has no commits since ${String(mission.worktree.baseCommit).slice(0, 12)}`,
35
34
  )
36
35
  }
37
- if (!(await isClean(mission.worktree.path))) {
36
+ if (!(await tower.deps.vcs.isClean(mission.worktree.path))) {
38
37
  const dirty = (await tower.git(mission.worktree.path, ['status', '--porcelain']))
39
38
  .split('\n')
40
39
  .filter(Boolean)
@@ -53,7 +52,7 @@ export async function complete(tower, input) {
53
52
  // Two-phase pilot (#51): expensive git/state checks run before the lock;
54
53
  // the lock only revalidates the state revision and commits the transition.
55
54
  const prepared = await tower.checkCompletion(input)
56
- return withLock(tower.paths, async () => {
55
+ return tower.deps.store.withLock(tower.paths, async () => {
57
56
  const state = await tower.load()
58
57
  if (state.revision !== prepared.state.revision) {
59
58
  throw new TowerToolError('stale-base', 'tower state changed while completion was being prepared; retry')
@@ -63,27 +62,27 @@ export async function complete(tower, input) {
63
62
  const { worker, commits } = prepared
64
63
  const checks = normalizeChecks(input.checks)
65
64
  const now = Date.now()
66
- mission.status = 'completed'
67
- mission.completionReport = completionReport({
65
+ const storedReport = completionReport({
68
66
  report: input.report,
69
67
  worker,
70
68
  checks,
71
69
  now,
72
70
  maxReportBytes: tower.config.gates?.maxReportBytes,
73
71
  })
74
- mission.updatedAt = now
72
+ const applied = applyCompletion({ mission, report: storedReport, now })
75
73
  await sweepArchive(tower, state, { now })
76
74
  state.revision += 1
77
75
  state.updatedAt = now
78
76
 
79
77
  validateState(state, tower.paths.state)
80
- await writeJsonAtomic(tower.paths.state, state)
81
- await appendJsonl(tower.paths.activity, {
78
+ await tower.deps.store.writeJsonAtomic(tower.paths.state, state)
79
+ await tower.deps.store.appendJsonl(tower.paths.activity, {
82
80
  at: now,
83
81
  type: 'complete',
84
82
  missionId: mission.id,
85
83
  text: `${worker.name} reported completion (${commits} commit(s))`,
86
84
  })
85
+ tower.emitEvents(applied.events, { revision: state.revision, at: now })
87
86
  return { mission: fullMission(mission), commits, unread: 0 }
88
87
  })
89
88
  }
@@ -1,6 +1,4 @@
1
1
  // Dashboard mission enrichment use case.
2
- import { exists } from '../lib/store.js'
3
- import { revParse } from '../lib/git.js'
4
2
  import {
5
3
  enrichedMissionView,
6
4
  latestReviewForMission,
@@ -12,9 +10,9 @@ export async function enrichedMissions(tower, state, onlyIds) {
12
10
  for (const mission of missionsToEnrich(state.missions, onlyIds)) {
13
11
  const latest = latestReviewForMission(state, mission)
14
12
  let tipMoved = false
15
- if (latest && mission.worktree && (await exists(mission.worktree.path))) {
13
+ if (latest && mission.worktree && (await tower.deps.store.exists(mission.worktree.path))) {
16
14
  try {
17
- const tip = await revParse(mission.worktree.path, 'HEAD')
15
+ const tip = await tower.deps.vcs.revParse(mission.worktree.path, 'HEAD')
18
16
  tipMoved = tip !== latest.reviewedCommit
19
17
  } catch {
20
18
  tipMoved = false
package/app/findings.js CHANGED
@@ -1,5 +1,4 @@
1
1
  // Findings 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 {
@@ -17,7 +16,7 @@ function fail(message, code = 'invalid-argument') {
17
16
  }
18
17
 
19
18
  export async function addFinding(tower, input) {
20
- return withLock(tower.paths, async () => {
19
+ return tower.deps.store.withLock(tower.paths, async () => {
21
20
  const state = await tower.load()
22
21
  if (!Array.isArray(state.findings)) state.findings = []
23
22
  const fields = normalizeFindingInput(input, state.missions)
@@ -30,8 +29,8 @@ export async function addFinding(tower, input) {
30
29
  state.updatedAt = now
31
30
 
32
31
  validateState(state, tower.paths.state)
33
- await writeJsonAtomic(tower.paths.state, state)
34
- await appendJsonl(tower.paths.activity, {
32
+ await tower.deps.store.writeJsonAtomic(tower.paths.state, state)
33
+ await tower.deps.store.appendJsonl(tower.paths.activity, {
35
34
  at: now,
36
35
  type: 'finding',
37
36
  missionId: finding.missionId,
@@ -55,7 +54,7 @@ export async function addFinding(tower, input) {
55
54
  const actor = normalizeFindingActor(input.actor)
56
55
  if (!wantsFindingUpdate(input)) return tower.readFindings(input)
57
56
 
58
- return withLock(tower.paths, async () => {
57
+ return tower.deps.store.withLock(tower.paths, async () => {
59
58
  const state = await tower.load()
60
59
  const all = state.findings ?? []
61
60
  const finding = all.find((entry) => entry.id === input.id)
@@ -65,8 +64,8 @@ export async function addFinding(tower, input) {
65
64
  state.revision += 1
66
65
  state.updatedAt = now
67
66
  validateState(state, tower.paths.state)
68
- await writeJsonAtomic(tower.paths.state, state)
69
- await appendJsonl(tower.paths.activity, {
67
+ await tower.deps.store.writeJsonAtomic(tower.paths.state, state)
68
+ await tower.deps.store.appendJsonl(tower.paths.activity, {
70
69
  at: now,
71
70
  type: 'finding',
72
71
  missionId: finding.missionId,
package/app/gate.js CHANGED
@@ -1,5 +1,4 @@
1
1
  // Merge/preflight gate evaluation.
2
- import { currentBranch } from '../lib/git.js'
3
2
  import { ROSTER_INACTIVE_STATES } from '../domain/roster.js'
4
3
  import { completionBlockers, mergeGateBlockers } from '../domain/gate.js'
5
4
  import { fullMission } from '../domain/summary.js'
@@ -105,7 +104,7 @@ export async function evaluateGate(tower, input) {
105
104
  rebaseWaived: latest ? tower.rebaseWaived(mission, latest) : false,
106
105
  })) push(blocker.code, blocker.detail)
107
106
 
108
- const checkoutBranch = await currentBranch(tower.root)
107
+ const checkoutBranch = await tower.deps.vcs.currentBranch(tower.root)
109
108
  const wrongBranch = baseWrongBranchBlocker({ checkoutBranch, base: state.base })
110
109
  if (wrongBranch) push(wrongBranch.code, wrongBranch.detail)
111
110