dsh-tower 2.0.4 → 2.1.0-beta.1

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 (171) hide show
  1. package/CHANGELOG.md +101 -0
  2. package/COMPATIBILITY.md +38 -0
  3. package/CONTRIBUTING.md +150 -2
  4. package/README.en.md +40 -3
  5. package/README.md +38 -3
  6. package/SECURITY.md +27 -0
  7. package/adapters/default.js +10 -0
  8. package/adapters/store-fs.js +6 -0
  9. package/adapters/vcs-git.js +4 -0
  10. package/app/batch.js +79 -0
  11. package/app/board.js +25 -0
  12. package/app/checkCompletion.js +76 -0
  13. package/app/enrichedMissions.js +26 -0
  14. package/app/findings.js +78 -0
  15. package/app/gate.js +108 -0
  16. package/app/init.js +249 -0
  17. package/app/merge.js +567 -0
  18. package/app/messages.js +66 -0
  19. package/app/mission.js +35 -0
  20. package/app/missionContext.js +14 -0
  21. package/app/plan.js +42 -0
  22. package/app/provision.js +193 -0
  23. package/app/read.js +57 -0
  24. package/app/rebase.js +302 -0
  25. package/app/recording.js +72 -0
  26. package/app/review.js +145 -0
  27. package/app/roster.js +123 -0
  28. package/app/spawn.js +125 -0
  29. package/app/teardown.js +184 -0
  30. package/app/tower.js +524 -0
  31. package/cordis.patch.yml +61 -53
  32. package/docs/README.md +3 -1
  33. package/docs/architecture/overview.md +111 -12
  34. package/docs/board.md +72 -0
  35. package/docs/guide/hot-reload.md +1 -1
  36. package/docs/guide/install-upgrade-migration.md +8 -1
  37. package/docs/guide/release.md +125 -0
  38. package/docs/operations/security.md +47 -2
  39. package/docs/reference/config.generated.md +66 -0
  40. package/docs/reference/config.md +4 -1
  41. package/docs/reference/errors.generated.md +39 -0
  42. package/docs/reference/errors.md +4 -2
  43. package/docs/reference/tools.generated.md +33 -0
  44. package/docs/reference/tools.md +1 -1
  45. package/docs/zh/README.md +3 -2
  46. package/docs/zh/guide/hot-reload.md +1 -1
  47. package/docs/zh/guide/install-upgrade-migration.md +6 -1
  48. package/docs/zh/operations/security.md +30 -1
  49. package/docs/zh/reference/config.md +4 -1
  50. package/docs/zh/reference/errors.md +3 -1
  51. package/docs/zh/reference/tools.md +1 -1
  52. package/domain/collab/batch.js +112 -0
  53. package/domain/collab/messages.js +127 -0
  54. package/domain/collab/roster.js +158 -0
  55. package/domain/collab/spawn.js +188 -0
  56. package/domain/config/schema.js +404 -0
  57. package/domain/contracts/outputs.js +279 -0
  58. package/domain/contracts/tool-inputs/collaboration.js +58 -0
  59. package/domain/contracts/tool-inputs/findings.js +29 -0
  60. package/domain/contracts/tool-inputs/index.js +94 -0
  61. package/domain/contracts/tool-inputs/integration.js +37 -0
  62. package/domain/contracts/tool-inputs/missions.js +94 -0
  63. package/domain/contracts/tool-inputs/ops.js +75 -0
  64. package/domain/contracts/tool-inputs/shared.js +25 -0
  65. package/domain/errors.js +96 -0
  66. package/domain/gate.js +125 -0
  67. package/domain/integration/gate.js +176 -0
  68. package/domain/integration/init.js +225 -0
  69. package/domain/integration/liveness.js +31 -0
  70. package/domain/integration/merge.js +377 -0
  71. package/domain/integration/provision.js +124 -0
  72. package/domain/integration/rebase.js +236 -0
  73. package/domain/integration/review.js +52 -0
  74. package/domain/integration/teardown.js +118 -0
  75. package/domain/mission.js +68 -0
  76. package/domain/missions/completion.js +114 -0
  77. package/domain/missions/findings.js +201 -0
  78. package/domain/missions/lifecycle.js +185 -0
  79. package/domain/missions/plan.js +149 -0
  80. package/domain/missions/review.js +179 -0
  81. package/domain/planning.js +118 -0
  82. package/domain/read/enrichedMissions.js +58 -0
  83. package/domain/read/missionContext.js +53 -0
  84. package/domain/read/views.js +128 -0
  85. package/domain/report.js +59 -0
  86. package/domain/roster.js +40 -0
  87. package/domain/scope.js +105 -0
  88. package/domain/state/schema.js +233 -0
  89. package/domain/state/semantics.js +117 -0
  90. package/domain/state.js +33 -0
  91. package/domain/summary.js +114 -0
  92. package/domain/vocabulary.js +179 -0
  93. package/index.js +63 -20
  94. package/lib/briefing.js +176 -0
  95. package/lib/compatibility.js +108 -0
  96. package/lib/config.js +33 -375
  97. package/lib/doctor.js +97 -4
  98. package/lib/git.js +60 -16
  99. package/lib/hmr.js +4 -3
  100. package/lib/leases.js +3 -2
  101. package/lib/legacy.js +20 -9
  102. package/lib/modeState.js +39 -0
  103. package/lib/modeStore.js +11 -43
  104. package/lib/persistence.js +349 -0
  105. package/lib/presets.js +35 -0
  106. package/lib/spawn-prechecks.js +168 -0
  107. package/lib/spawn-support.js +79 -0
  108. package/lib/store.js +54 -137
  109. package/lib/tests.js +11 -10
  110. package/lib/tool-registry.js +544 -0
  111. package/lib/workspaceLock.js +2 -2
  112. package/lib/worktreeOwnership.js +5 -4
  113. package/locale/en.json +23 -1
  114. package/locale/zh.json +22 -0
  115. package/package.json +32 -4
  116. package/ports/clock.js +6 -0
  117. package/ports/ids.js +7 -0
  118. package/ports/index.js +2 -0
  119. package/ports/store.js +11 -0
  120. package/ports/vcs.js +11 -0
  121. package/scripts/architecture.mjs +94 -0
  122. package/scripts/check-docs.mjs +9 -3
  123. package/scripts/check-pack.mjs +49 -0
  124. package/scripts/domain-metrics.mjs +48 -0
  125. package/scripts/generate-config-reference.mjs +46 -0
  126. package/scripts/generate-error-reference.mjs +29 -0
  127. package/scripts/generate-tools-reference.mjs +56 -0
  128. package/scripts/issue-audit.mjs +259 -0
  129. package/scripts/lib/cli.mjs +76 -0
  130. package/scripts/lib/tmp.mjs +20 -0
  131. package/scripts/mount-smoke/fixtures.mjs +162 -0
  132. package/scripts/mount-smoke/host.json +4 -0
  133. package/scripts/mount-smoke/probe.mjs +125 -0
  134. package/scripts/mount-smoke/surface.mjs +141 -0
  135. package/scripts/mount-smoke.mjs +270 -0
  136. package/scripts/pack-manifest.txt +203 -0
  137. package/scripts/release-check.mjs +303 -0
  138. package/tools/adopt.js +72 -0
  139. package/tools/batch.js +78 -0
  140. package/tools/complete.js +53 -0
  141. package/tools/diff.js +39 -0
  142. package/tools/doctor.js +50 -0
  143. package/tools/finding.js +40 -0
  144. package/tools/findings.js +39 -0
  145. package/tools/import-legacy.js +42 -0
  146. package/tools/inbox.js +39 -0
  147. package/tools/index.js +39 -0
  148. package/tools/init.js +63 -0
  149. package/tools/interrupt.js +42 -0
  150. package/tools/legacy-map.js +44 -0
  151. package/tools/merge.js +39 -0
  152. package/tools/mission.js +41 -0
  153. package/tools/plan.js +39 -0
  154. package/tools/preflight.js +44 -0
  155. package/tools/rebase.js +39 -0
  156. package/tools/recover.js +45 -0
  157. package/tools/reload.js +52 -0
  158. package/tools/resource.js +54 -0
  159. package/tools/review.js +45 -0
  160. package/tools/roster.js +60 -0
  161. package/tools/send.js +147 -0
  162. package/tools/spawn.js +196 -0
  163. package/tools/status.js +94 -0
  164. package/tools/teardown.js +42 -0
  165. package/tools/test.js +61 -0
  166. package/types/cordis.d.ts +6 -0
  167. package/types/index.d.ts +339 -0
  168. package/lib/errors.js +0 -123
  169. package/lib/tools.js +0 -2460
  170. package/lib/tower.js +0 -2912
  171. /package/{lib → domain}/batch.js +0 -0
package/lib/store.js CHANGED
@@ -1,14 +1,31 @@
1
+ import { TowerToolError } from '../domain/errors.js'
1
2
  // Durable repo-scoped store: exclusive lock + atomic JSON + append-only activity.
2
- import fsp from 'node:fs/promises'
3
+ // The atomic/lock/JSON primitives are shared with the mode store (#56 P2b).
4
+ import { fsp } from '../adapters/store-fs.js'
5
+ import {
6
+ PROCESS_STARTED_AT,
7
+ acquireLock,
8
+ lockRecord,
9
+ readLock,
10
+ } from './persistence.js'
3
11
  import path from 'node:path'
4
12
  import crypto from 'node:crypto'
5
13
 
14
+ export {
15
+ readJson,
16
+ writeJsonAtomic,
17
+ writeJsonAtomicSync,
18
+ renameWithRetry,
19
+ appendJsonl,
20
+ } from './persistence.js'
21
+
6
22
  export function pathsFor(commonDir) {
7
23
  const dir = path.join(commonDir, 'dsh-tower')
8
24
  return {
9
25
  dir,
10
26
  state: path.join(dir, 'state.json'),
11
27
  lock: path.join(dir, 'state.lock'),
28
+ gitLock: path.join(dir, 'git.lock'),
12
29
  activity: path.join(dir, 'activity.jsonl'),
13
30
  }
14
31
  }
@@ -26,55 +43,6 @@ export async function ensureDir(dir) {
26
43
  await fsp.mkdir(dir, { recursive: true })
27
44
  }
28
45
 
29
- export async function readJson(file) {
30
- const text = await fsp.readFile(file, 'utf8')
31
- try {
32
- return JSON.parse(text)
33
- } catch (error) {
34
- throw new Error(`corrupt tower state at ${file}: ${error.message}`)
35
- }
36
- }
37
-
38
- export async function writeJsonAtomic(file, value) {
39
- const dir = path.dirname(file)
40
- const tmp = path.join(dir, `.${path.basename(file)}.tmp-${process.pid}-${crypto.randomUUID()}`)
41
- const handle = await fsp.open(tmp, 'w')
42
- try {
43
- await handle.writeFile(JSON.stringify(value, null, 2), 'utf8')
44
- await handle.sync()
45
- } finally {
46
- await handle.close()
47
- }
48
- await renameWithRetry(tmp, file)
49
- }
50
-
51
- /**
52
- * Windows can reject a rename while an indexer, antivirus, or a concurrent
53
- * reader still holds the destination; retry briefly instead of failing a
54
- * successful mutation. The temp file is removed when the rename never lands.
55
- */
56
- async function renameWithRetry(tmp, file) {
57
- for (let attempt = 0; ; attempt += 1) {
58
- try {
59
- await fsp.rename(tmp, file)
60
- return
61
- } catch (error) {
62
- const transient = error?.code === 'EPERM' || error?.code === 'EBUSY' || error?.code === 'EACCES'
63
- if (!transient || attempt >= 12) {
64
- await fsp.unlink(tmp).catch(() => {})
65
- throw error
66
- }
67
- await sleep(10 + attempt * 15)
68
- }
69
- }
70
- }
71
-
72
- export { renameWithRetry }
73
-
74
- export async function appendJsonl(file, value) {
75
- await fsp.appendFile(file, `${JSON.stringify(value)}\n`, 'utf8')
76
- }
77
-
78
46
  export function shortHash(text) {
79
47
  return crypto.createHash('sha256').update(String(text)).digest('hex').slice(0, 12)
80
48
  }
@@ -94,66 +62,8 @@ export async function readActivityTail(file, limit = 12) {
94
62
  return out
95
63
  }
96
64
 
97
- function sleep(ms) {
98
- return new Promise((resolve) => setTimeout(resolve, ms))
99
- }
100
-
101
- const PROCESS_STARTED_AT = Date.now() - Math.round(process.uptime() * 1000)
102
-
103
- function processAlive(pid) {
104
- if (!Number.isInteger(Number(pid)) || Number(pid) <= 0) return null
105
- try {
106
- process.kill(Number(pid), 0)
107
- return true
108
- } catch (error) {
109
- if (error?.code === 'ESRCH') return false
110
- // EPERM means the process exists but belongs to another user.
111
- return true
112
- }
113
- }
114
-
115
- /** Read the current lock record without acquiring the lock. */
116
65
  export async function readStateLock(paths) {
117
- try {
118
- const text = await fsp.readFile(paths.lock, 'utf8')
119
- const record = JSON.parse(text)
120
- const alive = processAlive(record?.pid)
121
- return { path: paths.lock, record, alive, raw: text }
122
- } catch (error) {
123
- if (error?.code === 'ENOENT') return { path: paths.lock, record: null, alive: null, raw: null }
124
- return { path: paths.lock, record: null, alive: null, raw: null, error: String(error?.message ?? error) }
125
- }
126
- }
127
-
128
- async function reclaimDeadLock(paths) {
129
- let original
130
- try {
131
- original = await fsp.readFile(paths.lock, 'utf8')
132
- } catch {
133
- return false
134
- }
135
- let parsed
136
- try {
137
- parsed = JSON.parse(original)
138
- } catch {
139
- return false
140
- }
141
- if (processAlive(parsed?.pid) !== false) return false
142
- const quarantine = `${paths.lock}.stale-${process.pid}-${crypto.randomUUID()}`
143
- try {
144
- await fsp.rename(paths.lock, quarantine)
145
- } catch {
146
- return false
147
- }
148
- const moved = await fsp.readFile(quarantine, 'utf8').catch(() => null)
149
- if (moved === null || moved !== original) {
150
- // A concurrent writer replaced the lock between read and rename. Put the
151
- // newer record back rather than deleting it.
152
- if (moved !== null) await fsp.rename(quarantine, paths.lock).catch(() => {})
153
- return false
154
- }
155
- await fsp.unlink(quarantine).catch(() => {})
156
- return true
66
+ return readLock(paths.lock)
157
67
  }
158
68
 
159
69
  /**
@@ -164,38 +74,45 @@ export async function withLock(paths, fn, options = {}) {
164
74
  const timeoutMs = options.timeoutMs ?? 5000
165
75
  const pollMs = options.pollMs ?? 25
166
76
  const token = crypto.randomUUID()
167
- const record = JSON.stringify({ token, pid: process.pid, startedAt: PROCESS_STARTED_AT, at: Date.now() })
168
- const deadline = Date.now() + timeoutMs
169
-
170
- for (;;) {
171
- try {
172
- await fsp.writeFile(paths.lock, record, { flag: 'wx' })
173
- break
174
- } catch (error) {
175
- if (error.code !== 'EEXIST') throw error
176
- if (await reclaimDeadLock(paths)) continue
177
- if (Date.now() > deadline) {
178
- const owner = await readStateLock(paths)
179
- const payload = owner.raw ?? (owner.record ? JSON.stringify(owner.record) : null)
180
- throw new Error(
181
- `tower state is locked by another writer${payload ? ` (${payload})` : ''}; retry after it finishes`,
182
- )
183
- }
184
- await sleep(pollMs)
185
- }
77
+ const record = lockRecord(token, { startedAt: PROCESS_STARTED_AT })
78
+ const held = await acquireLock(paths.lock, { token, record, timeoutMs, pollMs })
79
+ if (!held) {
80
+ const owner = await readStateLock(paths)
81
+ const ownerRecord = owner.record
82
+ const payload = owner.raw ?? (ownerRecord ? JSON.stringify(ownerRecord) : null)
83
+ throw new TowerToolError(
84
+ 'state-locked',
85
+ `tower state is locked by another writer${payload ? ` (${payload})` : ''}; retry after it finishes`,
86
+ )
87
+ }
88
+ const startedAt = Date.now()
89
+ try {
90
+ return await fn(paths)
91
+ } finally {
92
+ await held.release()
93
+ await fsp
94
+ .appendFile(path.join(paths.dir, 'lock-holds.jsonl'), `${JSON.stringify({ at: Date.now(), ms: Date.now() - startedAt, pid: process.pid })}\n`, 'utf8')
95
+ .catch(() => {})
186
96
  }
97
+ }
187
98
 
99
+ /** Serialize git mutations without holding the state lock. */
100
+ export async function withGitLock(paths, fn, options = {}) {
101
+ const timeoutMs = options.timeoutMs ?? 60000
102
+ const pollMs = options.pollMs ?? 25
103
+ const lock = paths.gitLock
104
+ const token = crypto.randomUUID()
105
+ const startedAt = Date.now()
106
+ const held = await acquireLock(lock, { token, record: lockRecord(token, { kind: 'git' }), timeoutMs, pollMs })
107
+ if (!held) {
108
+ throw new TowerToolError('state-locked', 'another git mutation is in progress for this repository; retry after it finishes')
109
+ }
188
110
  try {
189
111
  return await fn(paths)
190
112
  } finally {
191
- try {
192
- const current = await fsp.readFile(paths.lock, 'utf8').catch(() => null)
193
- if (current) {
194
- const parsed = JSON.parse(current)
195
- if (parsed && parsed.token === token) await fsp.unlink(paths.lock)
196
- }
197
- } catch {
198
- // a lock left behind by a crash is reported by the next writer, not fixed here
199
- }
113
+ await held.release()
114
+ await fsp
115
+ .appendFile(path.join(paths.dir, 'lock-holds.jsonl'), `${JSON.stringify({ at: Date.now(), ms: Date.now() - startedAt, pid: process.pid, phase: 'git' })}\n`, 'utf8')
116
+ .catch(() => {})
200
117
  }
201
118
  }
package/lib/tests.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { TowerToolError } from '../domain/errors.js'
1
2
  // Tower test discovery (2.1): fail-closed admission evidence from the
2
3
  // repository's own `package.json` `tower.testDiscovery` block.
3
4
  //
@@ -18,10 +19,10 @@
18
19
  // minCount/required shortfall) returns `ok: false` in the receipt;
19
20
  // - an invalid discovery configuration (or invalid call arguments) throws a
20
21
  // plain `Error`, which the tool layer wraps as `tower:invalid-argument`.
21
- import { spawn } from 'node:child_process'
22
- import { createWriteStream } from 'node:fs'
22
+ import { spawn } from '../adapters/vcs-git.js'
23
+ import { createWriteStream } from '../adapters/store-fs.js'
23
24
  import crypto from 'node:crypto'
24
- import fsp from 'node:fs/promises'
25
+ import { fsp } from '../adapters/store-fs.js'
25
26
  import path from 'node:path'
26
27
 
27
28
  import { tryGit } from './git.js'
@@ -45,7 +46,7 @@ function nonEmptyString(value) {
45
46
  }
46
47
 
47
48
  function normalizePositiveInteger(value, name) {
48
- if (!Number.isInteger(value) || value <= 0) throw new Error(`${name} must be an integer > 0`)
49
+ if (!Number.isInteger(value) || value <= 0) throw new TowerToolError('invalid-argument', `${name} must be an integer > 0`)
49
50
  return value
50
51
  }
51
52
 
@@ -480,7 +481,7 @@ async function resolveDiagnosticsDir({ worktreePath, diagnosticsDir, missionId }
480
481
 
481
482
  function normalizeDiscoveryInput(config) {
482
483
  if (isPlainObject(config) && Object.prototype.hasOwnProperty.call(config, 'configured')) {
483
- if (config.error) throw new Error(`test discovery configuration is invalid: ${config.error}`)
484
+ if (config.error) throw new TowerToolError('invalid-argument', `test discovery configuration is invalid: ${config.error}`)
484
485
  if (config.configured === false) return { configured: false, config: null, error: null }
485
486
  return { configured: true, config: config.config, error: null }
486
487
  }
@@ -527,16 +528,16 @@ export async function runDiscovery(options = {}) {
527
528
  } = options
528
529
  const startedAt = new Date().toISOString()
529
530
 
530
- if (!nonEmptyString(worktreePath)) throw new Error('worktreePath is required')
531
- if (!path.isAbsolute(worktreePath)) throw new Error(`worktreePath must be an absolute path: ${worktreePath}`)
531
+ if (!nonEmptyString(worktreePath)) throw new TowerToolError('invalid-argument', 'worktreePath is required')
532
+ if (!path.isAbsolute(worktreePath)) throw new TowerToolError('invalid-argument', `worktreePath must be an absolute path: ${worktreePath}`)
532
533
  const effectiveTimeout = normalizePositiveInteger(timeoutMs, 'timeoutMs')
533
534
  const effectiveMaxBytes = normalizePositiveInteger(maxBytes, 'maxBytes')
534
535
  if (runner !== undefined && runner !== null && typeof runner !== 'function') {
535
- throw new Error('runner must be a function')
536
+ throw new TowerToolError('invalid-argument', 'runner must be a function')
536
537
  }
537
538
  if (diagnosticsDir !== undefined && diagnosticsDir !== null) {
538
539
  if (!nonEmptyString(diagnosticsDir) || !path.isAbsolute(diagnosticsDir)) {
539
- throw new Error(`diagnosticsDir must be an absolute path: ${diagnosticsDir}`)
540
+ throw new TowerToolError('invalid-argument', `diagnosticsDir must be an absolute path: ${diagnosticsDir}`)
540
541
  }
541
542
  }
542
543
 
@@ -558,7 +559,7 @@ export async function runDiscovery(options = {}) {
558
559
 
559
560
  if (!discovery.configured) return finishUnconfigured(discovery.error)
560
561
  const normalized = normalizeDiscovery(discovery.config)
561
- if (!normalized.ok) throw new Error(`invalid tower.testDiscovery config: ${normalized.error}`)
562
+ if (!normalized.ok) throw new TowerToolError('invalid-argument', `invalid tower.testDiscovery config: ${normalized.error}`)
562
563
 
563
564
  const dir = await resolveDiagnosticsDir({ worktreePath, diagnosticsDir, missionId })
564
565
  const commands = []