dsh-tower 2.1.0-beta.1 → 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 (188) hide show
  1. package/CHANGELOG.md +154 -0
  2. package/COMPATIBILITY.md +6 -1
  3. package/CONTRIBUTING.md +71 -5
  4. package/PRINCIPLES.md +145 -0
  5. package/README.en.md +14 -5
  6. package/README.md +14 -5
  7. package/SECURITY.md +6 -0
  8. package/adapters/archive.js +279 -0
  9. package/adapters/caseSensitivity.js +66 -0
  10. package/adapters/default.js +51 -8
  11. package/{lib → adapters}/git.js +1 -1
  12. package/{lib → adapters}/leases.js +15 -6
  13. package/{lib → adapters}/legacy.js +20 -6
  14. package/adapters/persistence.js +543 -0
  15. package/adapters/stateFile.js +99 -0
  16. package/adapters/store-fs.js +28 -1
  17. package/adapters/store.js +204 -0
  18. package/{lib → adapters}/tests.js +15 -7
  19. package/adapters/vcs-git.js +15 -1
  20. package/{lib → adapters}/workspaceLock.js +1 -1
  21. package/{lib → adapters}/worktreeOwnership.js +18 -7
  22. package/app/archive.js +188 -0
  23. package/app/batch.js +9 -10
  24. package/app/board.js +4 -4
  25. package/app/checkCompletion.js +23 -11
  26. package/app/enrichedMissions.js +2 -4
  27. package/app/findings.js +6 -7
  28. package/app/gate.js +32 -15
  29. package/app/init.js +59 -22
  30. package/app/merge.js +64 -51
  31. package/app/messages.js +7 -8
  32. package/app/mission.js +7 -5
  33. package/app/plan.js +20 -9
  34. package/app/provision.js +12 -14
  35. package/app/read.js +65 -6
  36. package/app/rebase.js +14 -16
  37. package/app/recording.js +7 -8
  38. package/app/review.js +17 -8
  39. package/app/roster.js +14 -15
  40. package/{lib → app}/spawn-prechecks.js +4 -3
  41. package/app/spawn.js +12 -13
  42. package/app/teardown.js +28 -8
  43. package/app/tower.js +121 -39
  44. package/docs/README.md +6 -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 +50 -0
  49. package/docs/adr/README.md +51 -0
  50. package/docs/agents/domain.md +38 -0
  51. package/docs/agents/issue-tracker.md +46 -0
  52. package/docs/agents/triage-labels.md +15 -0
  53. package/docs/agents/workflow.md +51 -0
  54. package/docs/architecture/overview.md +170 -37
  55. package/docs/guide/hot-reload.md +1 -1
  56. package/docs/guide/install-upgrade-migration.md +18 -1
  57. package/docs/guide/release.md +1 -1
  58. package/docs/operations/incident.md +82 -0
  59. package/docs/operations/runbook.md +12 -3
  60. package/docs/reference/blockers.md +3 -3
  61. package/docs/reference/config.generated.md +5 -0
  62. package/docs/reference/config.md +4 -0
  63. package/docs/reference/doctor.md +104 -8
  64. package/docs/reference/errors.generated.md +41 -35
  65. package/docs/reference/errors.md +54 -40
  66. package/docs/reference/events.md +38 -6
  67. package/docs/reference/surface-budget.md +128 -0
  68. package/docs/reference/tools.generated.md +9 -8
  69. package/docs/reference/tools.md +9 -8
  70. package/docs/zh/guide/hot-reload.md +1 -1
  71. package/docs/zh/guide/install-upgrade-migration.md +13 -1
  72. package/docs/zh/reference/blockers.md +3 -3
  73. package/docs/zh/reference/config.md +4 -0
  74. package/docs/zh/reference/doctor.md +63 -2
  75. package/docs/zh/reference/errors.md +4 -2
  76. package/docs/zh/reference/tools.md +6 -6
  77. package/domain/batch.js +2 -2
  78. package/domain/config/schema.js +51 -4
  79. package/domain/contracts/outputs.js +33 -3
  80. package/domain/contracts/tool-inputs/missions.js +2 -1
  81. package/domain/contracts/tool-inputs/ops.js +1 -0
  82. package/domain/errors.js +297 -40
  83. package/domain/events.js +207 -0
  84. package/domain/integration/gate.js +41 -12
  85. package/domain/integration/init.js +40 -4
  86. package/domain/integration/merge.js +35 -0
  87. package/domain/lockBudget.js +30 -0
  88. package/domain/mission.js +1 -1
  89. package/domain/missions/completion.js +72 -5
  90. package/domain/missions/lifecycle.js +11 -4
  91. package/domain/missions/plan.js +14 -0
  92. package/domain/missions/review.js +90 -7
  93. package/domain/planning.js +6 -3
  94. package/domain/read/views.js +17 -1
  95. package/domain/report.js +9 -1
  96. package/domain/scope.js +83 -11
  97. package/domain/state/archive.js +257 -0
  98. package/domain/state/migrations.js +145 -0
  99. package/domain/state/schema.js +6 -0
  100. package/domain/state.js +154 -4
  101. package/domain/summary.js +2 -0
  102. package/domain/surface.js +248 -0
  103. package/host/doctor.js +555 -0
  104. package/{lib → host}/modeStore.js +1 -1
  105. package/{lib → host}/tool-registry.js +106 -31
  106. package/host/tower.js +32 -0
  107. package/index.js +12 -9
  108. package/package.json +10 -3
  109. package/ports/archive.js +18 -0
  110. package/ports/caseSensitivity.js +15 -0
  111. package/ports/clock.js +10 -2
  112. package/ports/config.js +19 -0
  113. package/ports/contract.js +38 -0
  114. package/ports/events.js +24 -0
  115. package/ports/ids.js +10 -2
  116. package/ports/index.js +54 -2
  117. package/ports/ownership.js +17 -0
  118. package/ports/state.js +16 -0
  119. package/ports/store.js +52 -3
  120. package/ports/vcs.js +25 -4
  121. package/scripts/architecture-waivers.json +19 -0
  122. package/scripts/architecture.mjs +634 -23
  123. package/scripts/check-docs.mjs +8 -35
  124. package/scripts/check-pack.mjs +68 -39
  125. package/scripts/check-philosophy.mjs +266 -0
  126. package/scripts/domain-metrics.mjs +13 -4
  127. package/scripts/generate-config-reference.mjs +14 -1
  128. package/scripts/generate-error-reference.mjs +68 -14
  129. package/scripts/generate-events-reference.mjs +83 -0
  130. package/scripts/generate-tools-reference.mjs +11 -0
  131. package/scripts/issue-audit.mjs +2 -2
  132. package/scripts/lib/surface.mjs +87 -0
  133. package/scripts/pack-manifest.txt +64 -19
  134. package/scripts/regression-audit.mjs +164 -0
  135. package/scripts/release-check.mjs +1 -1
  136. package/scripts/surface-baseline.json +21 -0
  137. package/scripts/surface-baseline.mjs +120 -0
  138. package/tools/adopt.js +2 -3
  139. package/tools/batch.js +2 -3
  140. package/tools/complete.js +3 -4
  141. package/tools/diff.js +3 -3
  142. package/tools/doctor.js +7 -10
  143. package/tools/finding.js +2 -3
  144. package/tools/findings.js +3 -3
  145. package/tools/import-legacy.js +3 -5
  146. package/tools/inbox.js +3 -3
  147. package/tools/index.js +7 -7
  148. package/tools/init.js +4 -7
  149. package/tools/interrupt.js +2 -3
  150. package/tools/legacy-map.js +3 -5
  151. package/tools/merge.js +3 -3
  152. package/tools/mission.js +2 -3
  153. package/tools/plan.js +7 -6
  154. package/tools/preflight.js +3 -3
  155. package/tools/rebase.js +3 -3
  156. package/tools/recover.js +38 -13
  157. package/tools/reload.js +3 -6
  158. package/tools/resource.js +3 -5
  159. package/tools/review.js +3 -4
  160. package/tools/roster.js +2 -3
  161. package/tools/send.js +2 -2
  162. package/tools/spawn.js +8 -5
  163. package/tools/status.js +16 -8
  164. package/tools/teardown.js +3 -4
  165. package/tools/test.js +2 -2
  166. package/types/index.d.ts +35 -1
  167. package/ui-bundle/README.md +37 -3
  168. package/ui-bundle/client.cards.js +356 -0
  169. package/ui-bundle/client.denied.js +142 -0
  170. package/ui-bundle/client.dock.js +333 -0
  171. package/ui-bundle/client.helpers.js +271 -0
  172. package/ui-bundle/client.js +79 -1747
  173. package/ui-bundle/client.locales.js +247 -0
  174. package/ui-bundle/client.mode.js +206 -0
  175. package/ui-bundle/client.overlay.js +64 -0
  176. package/ui-bundle/client.styles.js +151 -0
  177. package/ui-bundle/client.toolview.js +82 -0
  178. package/ui-bundle/package.json +1 -0
  179. package/lib/doctor.js +0 -270
  180. package/lib/persistence.js +0 -349
  181. package/lib/store.js +0 -118
  182. /package/{lib → app}/briefing.js +0 -0
  183. /package/{lib → app}/spawn-support.js +0 -0
  184. /package/{lib → host}/compatibility.js +0 -0
  185. /package/{lib → host}/config.js +0 -0
  186. /package/{lib → host}/hmr.js +0 -0
  187. /package/{lib → host}/modeState.js +0 -0
  188. /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
@@ -288,8 +290,12 @@ export function testProcessEnv(source = process.env) {
288
290
  return env
289
291
  }
290
292
 
291
- /** Platform shell for discovery commands (PowerShell 7 on Windows, bash on POSIX). */
292
- function shellSpec(platform = process.platform) {
293
+ /**
294
+ * Platform shell for discovery commands (PowerShell 7 on Windows with a
295
+ * `powershell` fallback, bash on POSIX). Exported so both branches are asserted
296
+ * on every OS instead of only on the platform that happens to run CI (#86).
297
+ */
298
+ export function shellSpec(platform = process.platform) {
293
299
  if (platform === 'win32') {
294
300
  const args = (command) => ['-NoProfile', '-NonInteractive', '-Command', command]
295
301
  return {
@@ -336,7 +342,8 @@ function closeStreams(streams) {
336
342
  return Promise.all(
337
343
  streams.map(
338
344
  (stream) =>
339
- new Promise((resolve) => {
345
+ /** @type {Promise<void>} */
346
+ (new Promise((resolve) => {
340
347
  if (stream.closed || stream.destroyed) {
341
348
  resolve()
342
349
  return
@@ -344,7 +351,7 @@ function closeStreams(streams) {
344
351
  stream.once('close', resolve)
345
352
  stream.once('error', resolve)
346
353
  stream.end()
347
- }),
354
+ })),
348
355
  ),
349
356
  )
350
357
  }
@@ -363,6 +370,7 @@ function spawnToFiles({ command, cwd, timeoutMs, maxBytes, stdoutPath, stderrPat
363
370
 
364
371
  return new Promise((resolve) => {
365
372
  let settled = false
373
+ /** @type {import('node:child_process').ChildProcess | null} */
366
374
  let child = null
367
375
  let timer = null
368
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 { 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'
@@ -22,18 +22,29 @@ function isNonEmptyString(value) {
22
22
  return typeof value === 'string' && value.trim().length > 0
23
23
  }
24
24
 
25
- function compareKey(target) {
26
- const resolved = path.resolve(target)
27
- return process.platform === 'win32' ? resolved.toLowerCase() : resolved
25
+ // Path identity follows the injected platform's semantics so both branches are
26
+ // testable on any OS (#86); on the real platform this is exactly `path`.
27
+ export function compareKey(target, platform = process.platform) {
28
+ const pathImpl = platform === 'win32' ? path.win32 : path.posix
29
+ const resolved = pathImpl.resolve(target)
30
+ return platform === 'win32' ? resolved.toLowerCase() : resolved
31
+ }
32
+
33
+ function nativeRealpath(target) {
34
+ try {
35
+ return fs.realpathSync.native(target)
36
+ } catch {
37
+ return path.resolve(target)
38
+ }
28
39
  }
29
40
 
30
41
  function samePath(a, b) {
31
- return compareKey(a) === compareKey(b)
42
+ return compareKey(nativeRealpath(a)) === compareKey(nativeRealpath(b))
32
43
  }
33
44
 
34
45
  async function realpathOrResolve(target) {
35
46
  try {
36
- return await fsp.realpath(target)
47
+ return fs.realpathSync.native(target)
37
48
  } catch (error) {
38
49
  if (error?.code === 'ENOENT') return path.resolve(target)
39
50
  throw error
@@ -48,7 +59,7 @@ export async function canonicalPath(target) {
48
59
  if (!isNonEmptyString(target)) fail('invalid-argument', 'path is required')
49
60
  if (!path.isAbsolute(target)) fail('invalid-argument', `path must be an absolute path: ${target}`)
50
61
  try {
51
- return await fsp.realpath(target)
62
+ return fs.realpathSync.native(target)
52
63
  } catch (error) {
53
64
  if (error?.code === 'ENOENT') fail('invalid-transition', `path does not exist: ${path.resolve(target)}`)
54
65
  throw error
package/app/archive.js ADDED
@@ -0,0 +1,188 @@
1
+ // Terminal-mission archival use case (#73).
2
+ //
3
+ // The policy sweep runs inside an existing state lock, immediately before the
4
+ // caller writes state.json: archive files are written first, then the state
5
+ // document is compacted, so a crash between the two leaves the mission in state
6
+ // and the archive entry is deduped by id on retry. Nothing is deleted: the full
7
+ // mission, its linked reviews and its findings are moved to archive/<year>.json.
8
+ import { hasLiveAgent } from '../domain/integration/liveness.js'
9
+ import { missionSummary } from '../domain/summary.js'
10
+ import {
11
+ archiveYearFor,
12
+ archivedEntry,
13
+ isClosedFinding,
14
+ linkedFindings,
15
+ linkedReviews,
16
+ selectForArchive,
17
+ } from '../domain/state/archive.js'
18
+
19
+ /** @typedef {import('../types/index.js').Mission} Mission */
20
+ /** @typedef {import('../types/index.js').TowerState} TowerState */
21
+
22
+ /**
23
+ * Read `archive/index.json` through the Tower's store. Never throws: the
24
+ * returned `ok:false` carries the diagnostic that status/doctor surface.
25
+ * @param {any} tower
26
+ * @returns {Promise<{ ok: boolean, file: string, missions: any[], error?: string, exists: boolean }>}
27
+ */
28
+ export function archiveIndex(tower) {
29
+ return tower.deps.archive.readIndex(tower.deps.store, tower.paths)
30
+ }
31
+
32
+ /**
33
+ * Load one archived mission with its linked reviews and findings.
34
+ * @param {any} tower
35
+ * @param {string} missionId
36
+ */
37
+ export function archivedMission(tower, missionId) {
38
+ return tower.deps.archive.readEntry(tower.deps.store, tower.paths, missionId)
39
+ }
40
+
41
+ /**
42
+ * A compact archived mission is rendered through the same summary projection as
43
+ * an active one, plus `archived: true`. The completion report and task bodies
44
+ * are intentionally absent: they live in the year file and are loaded only by
45
+ * `tower_status missionId`.
46
+ * @param {Record<string, any>} entry
47
+ * @returns {any}
48
+ */
49
+ export function archivedSummary(entry) {
50
+ const mission = /** @type {any} */ ({
51
+ id: entry.id,
52
+ title: entry.title,
53
+ kind: entry.kind ?? 'build',
54
+ status: entry.status,
55
+ scope: Array.isArray(entry.scope) ? entry.scope : [],
56
+ deps: Array.isArray(entry.deps) ? entry.deps : [],
57
+ owner: entry.owner ?? null,
58
+ worktree: null,
59
+ blockers: [],
60
+ tasks: [],
61
+ reviewPolicy: 'required-clean',
62
+ mergedCommit: entry.mergedCommit ?? null,
63
+ mergedAt: entry.mergedAt ?? null,
64
+ updatedAt: entry.updatedAt ?? null,
65
+ })
66
+ return {
67
+ ...missionSummary(mission),
68
+ archived: true,
69
+ archivedAt: entry.archivedAt,
70
+ archivedYear: entry.archivedYear ?? null,
71
+ branch: entry.branch ?? null,
72
+ }
73
+ }
74
+
75
+ /**
76
+ * Status/board view of the archive index: a count plus a diagnostic, never a
77
+ * thrown error.
78
+ * @param {{ ok: boolean, file: string, missions: any[], error?: string, exists: boolean }} index
79
+ * @returns {Record<string, any>}
80
+ */
81
+ export function archiveView(index) {
82
+ const years = index.ok
83
+ ? [...new Set(index.missions.map((entry) => entry.archivedYear).filter((year) => Number.isInteger(year)))].sort(
84
+ (left, right) => left - right,
85
+ )
86
+ : []
87
+ return {
88
+ count: index.ok ? index.missions.length : 0,
89
+ years,
90
+ file: index.file,
91
+ present: index.exists === true,
92
+ unreadable: index.ok ? false : true,
93
+ ...(index.ok ? {} : { message: index.error ?? 'tower archive is unreadable' }),
94
+ }
95
+ }
96
+
97
+ /**
98
+ * Move the eligible terminal missions out of `state` and into the archive.
99
+ * Mutates `state` in memory (missions, linked reviews and closed findings) and
100
+ * leaves the caller to validate/persist it under the lock it already holds.
101
+ *
102
+ * @param {any} tower
103
+ * @param {TowerState} state
104
+ * @param {{ now?: number, force?: boolean, activity?: Record<string, string | undefined> }} [options]
105
+ * @returns {Promise<{ archived: string[], written: boolean, files?: string[], error: string | null, year?: number }>}
106
+ */
107
+ export async function sweepArchive(tower, state, options = {}) {
108
+ const policy = tower.config.archive ?? {}
109
+ if (policy.enabled === false) return { archived: [], written: false, error: null }
110
+ const now = options.now ?? Date.now()
111
+ const activity = options.activity ?? {}
112
+
113
+ // Open batches still need their members; a live agent's mission is never
114
+ // archived even if it is terminal (defence in depth; teardown keeps it too).
115
+ const protectedIds = new Set()
116
+ for (const batch of state.batches ?? []) {
117
+ const record = /** @type {Record<string, any>} */ (batch)
118
+ if (record.status !== 'open') continue
119
+ for (const missionId of Array.isArray(record.missionIds) ? record.missionIds : []) {
120
+ protectedIds.add(String(missionId))
121
+ }
122
+ }
123
+ for (const mission of state.missions) {
124
+ if (hasLiveAgent(state, mission.id, activity)) protectedIds.add(mission.id)
125
+ }
126
+
127
+ const selected = selectForArchive(state.missions, policy, {
128
+ now,
129
+ force: options.force === true,
130
+ protectedIds,
131
+ })
132
+ if (selected.length === 0) return { archived: [], written: false, error: null }
133
+
134
+ const archivedYear = archiveYearFor(now)
135
+ const entries = selected.map((mission) =>
136
+ archivedEntry(mission, {
137
+ archivedAt: now,
138
+ archivedYear,
139
+ reviews: linkedReviews(mission, state.reviews),
140
+ findings: linkedFindings(mission, state.findings),
141
+ }),
142
+ )
143
+
144
+ let written
145
+ try {
146
+ written = await tower.deps.archive.writeEntries(tower.deps.store, tower.paths, { entries, now })
147
+ } catch (error) {
148
+ return { archived: [], written: false, error: `archive write failed: ${String(error?.message ?? error)}` }
149
+ }
150
+ if (!written.ok) return { archived: [], written: false, error: written.error ?? 'archive write failed' }
151
+
152
+ // The archive now holds the full record: compact state.json in the same lock.
153
+ const ids = new Set(selected.map((mission) => mission.id))
154
+ const branches = new Set(
155
+ selected
156
+ .map((mission) => mission.worktree?.branch)
157
+ .filter((branch) => typeof branch === 'string'),
158
+ )
159
+ state.missions = state.missions.filter((mission) => !ids.has(mission.id))
160
+ if (Array.isArray(state.reviews)) {
161
+ state.reviews = state.reviews.filter((review) => !ids.has(review.missionId) && !branches.has(review.branch))
162
+ }
163
+ // Open findings stay actionable in state.json; the archive keeps a snapshot.
164
+ if (Array.isArray(state.findings)) {
165
+ state.findings = state.findings.filter(
166
+ (finding) => !(finding.missionId != null && ids.has(finding.missionId) && isClosedFinding(finding)),
167
+ )
168
+ }
169
+ state.revision += 1
170
+ state.updatedAt = now
171
+
172
+ for (const mission of selected) {
173
+ await tower.deps.store.appendJsonl(tower.paths.activity, {
174
+ at: now,
175
+ type: 'archive',
176
+ missionId: mission.id,
177
+ text: `archived to archive/${archivedYear}.json`,
178
+ }).catch(() => {})
179
+ }
180
+
181
+ return {
182
+ archived: [...ids],
183
+ written: true,
184
+ files: written.files,
185
+ error: null,
186
+ year: archivedYear,
187
+ }
188
+ }
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,6 @@
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'
3
+ import { archiveView } from './archive.js'
5
4
 
6
5
  export function boardFile(tower) {
7
6
  return tower.config.docs?.boardLocation === 'repo'
@@ -13,11 +12,12 @@ export async function writeBoard(tower) {
13
12
  if (tower.config.docs?.board === false) return null
14
13
  try {
15
14
  const state = await tower.load()
15
+ const index = await tower.archiveIndex()
16
16
  const file = boardFile(tower)
17
17
  await tower.ensureExcluded(file)
18
18
  const tmp = `${file}.tmp-${process.pid}-${Date.now()}`
19
- await fsp.writeFile(tmp, tower.boardMarkdown(state), 'utf8')
20
- 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)
21
21
  return file
22
22
  } catch {
23
23
  return null