dsh-tower 2.0.3 → 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 (193) hide show
  1. package/CHANGELOG.md +141 -0
  2. package/COMPATIBILITY.md +38 -0
  3. package/CONTRIBUTING.md +150 -2
  4. package/README.en.md +59 -16
  5. package/README.md +57 -16
  6. package/SECURITY.md +31 -1
  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 +33 -1
  33. package/docs/architecture/overview.md +114 -14
  34. package/docs/board.md +72 -0
  35. package/docs/examples/first-mission.md +2 -1
  36. package/docs/faq.md +11 -7
  37. package/docs/guide/hot-reload.md +1 -1
  38. package/docs/guide/install-upgrade-migration.md +22 -6
  39. package/docs/guide/quickstart.md +12 -7
  40. package/docs/guide/release.md +125 -0
  41. package/docs/guide/scenarios-and-philosophy.md +86 -69
  42. package/docs/operations/security.md +65 -14
  43. package/docs/reference/blockers.md +11 -10
  44. package/docs/reference/config.generated.md +66 -0
  45. package/docs/reference/config.md +11 -7
  46. package/docs/reference/errors.generated.md +39 -0
  47. package/docs/reference/errors.md +10 -2
  48. package/docs/reference/events.md +7 -4
  49. package/docs/reference/testing.md +2 -1
  50. package/docs/reference/tools.generated.md +33 -0
  51. package/docs/reference/tools.md +8 -7
  52. package/docs/zh/README.md +4 -3
  53. package/docs/zh/faq.md +9 -6
  54. package/docs/zh/guide/hot-reload.md +1 -1
  55. package/docs/zh/guide/install-upgrade-migration.md +17 -5
  56. package/docs/zh/guide/quickstart.md +13 -8
  57. package/docs/zh/guide/scenarios-and-philosophy.md +55 -50
  58. package/docs/zh/operations/security.md +43 -7
  59. package/docs/zh/reference/blockers.md +10 -10
  60. package/docs/zh/reference/config.md +11 -7
  61. package/docs/zh/reference/errors.md +4 -1
  62. package/docs/zh/reference/events.md +6 -3
  63. package/docs/zh/reference/testing.md +3 -2
  64. package/docs/zh/reference/tools.md +8 -7
  65. package/domain/collab/batch.js +112 -0
  66. package/domain/collab/messages.js +127 -0
  67. package/domain/collab/roster.js +158 -0
  68. package/domain/collab/spawn.js +188 -0
  69. package/domain/config/schema.js +404 -0
  70. package/domain/contracts/outputs.js +279 -0
  71. package/domain/contracts/tool-inputs/collaboration.js +58 -0
  72. package/domain/contracts/tool-inputs/findings.js +29 -0
  73. package/domain/contracts/tool-inputs/index.js +94 -0
  74. package/domain/contracts/tool-inputs/integration.js +37 -0
  75. package/domain/contracts/tool-inputs/missions.js +94 -0
  76. package/domain/contracts/tool-inputs/ops.js +75 -0
  77. package/domain/contracts/tool-inputs/shared.js +25 -0
  78. package/domain/errors.js +96 -0
  79. package/domain/gate.js +125 -0
  80. package/domain/integration/gate.js +176 -0
  81. package/domain/integration/init.js +225 -0
  82. package/domain/integration/liveness.js +31 -0
  83. package/domain/integration/merge.js +377 -0
  84. package/domain/integration/provision.js +124 -0
  85. package/domain/integration/rebase.js +236 -0
  86. package/domain/integration/review.js +52 -0
  87. package/domain/integration/teardown.js +118 -0
  88. package/domain/mission.js +68 -0
  89. package/domain/missions/completion.js +114 -0
  90. package/domain/missions/findings.js +201 -0
  91. package/domain/missions/lifecycle.js +185 -0
  92. package/domain/missions/plan.js +149 -0
  93. package/domain/missions/review.js +179 -0
  94. package/domain/planning.js +118 -0
  95. package/domain/read/enrichedMissions.js +58 -0
  96. package/domain/read/missionContext.js +53 -0
  97. package/domain/read/views.js +128 -0
  98. package/domain/report.js +59 -0
  99. package/domain/roster.js +40 -0
  100. package/domain/scope.js +105 -0
  101. package/domain/state/schema.js +233 -0
  102. package/domain/state/semantics.js +117 -0
  103. package/domain/state.js +33 -0
  104. package/domain/summary.js +114 -0
  105. package/domain/vocabulary.js +179 -0
  106. package/index.js +86 -34
  107. package/lib/briefing.js +176 -0
  108. package/lib/compatibility.js +108 -0
  109. package/lib/config.js +54 -302
  110. package/lib/doctor.js +108 -9
  111. package/lib/git.js +93 -15
  112. package/lib/hmr.js +4 -3
  113. package/lib/leases.js +3 -2
  114. package/lib/legacy.js +20 -9
  115. package/lib/modeState.js +39 -0
  116. package/lib/modeStore.js +35 -33
  117. package/lib/persistence.js +349 -0
  118. package/lib/presets.js +35 -0
  119. package/lib/spawn-prechecks.js +168 -0
  120. package/lib/spawn-support.js +79 -0
  121. package/lib/store.js +57 -85
  122. package/lib/tests.js +46 -11
  123. package/lib/tool-registry.js +544 -0
  124. package/lib/workspaceLock.js +46 -22
  125. package/lib/worktreeOwnership.js +5 -4
  126. package/locale/en.json +23 -1
  127. package/locale/zh.json +22 -0
  128. package/package.json +33 -4
  129. package/ports/clock.js +6 -0
  130. package/ports/ids.js +7 -0
  131. package/ports/index.js +2 -0
  132. package/ports/store.js +11 -0
  133. package/ports/vcs.js +11 -0
  134. package/scripts/architecture.mjs +94 -0
  135. package/scripts/check-docs.mjs +44 -3
  136. package/scripts/check-pack.mjs +49 -0
  137. package/scripts/domain-metrics.mjs +48 -0
  138. package/scripts/generate-config-reference.mjs +46 -0
  139. package/scripts/generate-error-reference.mjs +29 -0
  140. package/scripts/generate-tools-reference.mjs +56 -0
  141. package/scripts/issue-audit.mjs +259 -0
  142. package/scripts/lib/cli.mjs +76 -0
  143. package/scripts/lib/tmp.mjs +20 -0
  144. package/scripts/mount-smoke/fixtures.mjs +162 -0
  145. package/scripts/mount-smoke/host.json +4 -0
  146. package/scripts/mount-smoke/probe.mjs +125 -0
  147. package/scripts/mount-smoke/surface.mjs +141 -0
  148. package/scripts/mount-smoke.mjs +270 -0
  149. package/scripts/pack-manifest.txt +203 -0
  150. package/scripts/release-check.mjs +303 -0
  151. package/skills/tower-recovery.md +7 -5
  152. package/tools/adopt.js +72 -0
  153. package/tools/batch.js +78 -0
  154. package/tools/complete.js +53 -0
  155. package/tools/diff.js +39 -0
  156. package/tools/doctor.js +50 -0
  157. package/tools/finding.js +40 -0
  158. package/tools/findings.js +39 -0
  159. package/tools/import-legacy.js +42 -0
  160. package/tools/inbox.js +39 -0
  161. package/tools/index.js +39 -0
  162. package/tools/init.js +63 -0
  163. package/tools/interrupt.js +42 -0
  164. package/tools/legacy-map.js +44 -0
  165. package/tools/merge.js +39 -0
  166. package/tools/mission.js +41 -0
  167. package/tools/plan.js +39 -0
  168. package/tools/preflight.js +44 -0
  169. package/tools/rebase.js +39 -0
  170. package/tools/recover.js +45 -0
  171. package/tools/reload.js +52 -0
  172. package/tools/resource.js +54 -0
  173. package/tools/review.js +45 -0
  174. package/tools/roster.js +60 -0
  175. package/tools/send.js +147 -0
  176. package/tools/spawn.js +196 -0
  177. package/tools/status.js +94 -0
  178. package/tools/teardown.js +42 -0
  179. package/tools/test.js +61 -0
  180. package/types/cordis.d.ts +6 -0
  181. package/types/index.d.ts +339 -0
  182. package/ui-bundle/README.md +111 -0
  183. package/ui-bundle/client.js +1980 -0
  184. package/ui-bundle/cordis.patch.yml +3 -0
  185. package/ui-bundle/icon.svg +6 -0
  186. package/ui-bundle/index.js +2 -0
  187. package/ui-bundle/locale/en.json +6 -0
  188. package/ui-bundle/locale/zh.json +6 -0
  189. package/ui-bundle/package.json +35 -0
  190. package/lib/errors.js +0 -111
  191. package/lib/tools.js +0 -2250
  192. package/lib/tower.js +0 -2689
  193. /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,53 +62,57 @@ 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))
65
+ export async function readStateLock(paths) {
66
+ return readLock(paths.lock)
99
67
  }
100
68
 
101
69
  /**
102
- * Hold the repo-wide tower lock for one mutation. Stale locks are reported,
103
- * never silently stolen (matches the fail-closed protocol).
70
+ * Hold the repo-wide tower lock for one mutation. A lock whose recorded PID is
71
+ * provably dead is reclaimed atomically; a live lock is reported, never stolen.
104
72
  */
105
73
  export async function withLock(paths, fn, options = {}) {
106
74
  const timeoutMs = options.timeoutMs ?? 5000
107
75
  const pollMs = options.pollMs ?? 25
108
76
  const token = crypto.randomUUID()
109
- const record = JSON.stringify({ token, pid: process.pid, at: Date.now() })
110
- const deadline = Date.now() + timeoutMs
111
-
112
- for (;;) {
113
- try {
114
- await fsp.writeFile(paths.lock, record, { flag: 'wx' })
115
- break
116
- } catch (error) {
117
- if (error.code !== 'EEXIST') throw error
118
- if (Date.now() > deadline) {
119
- let owner = null
120
- try {
121
- owner = await fsp.readFile(paths.lock, 'utf8')
122
- } catch {
123
- owner = null
124
- }
125
- throw new Error(
126
- `tower state is locked by another writer${owner ? ` (${owner})` : ''}; retry after it finishes`,
127
- )
128
- }
129
- await sleep(pollMs)
130
- }
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
+ )
131
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(() => {})
96
+ }
97
+ }
132
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
+ }
133
110
  try {
134
111
  return await fn(paths)
135
112
  } finally {
136
- try {
137
- const current = await fsp.readFile(paths.lock, 'utf8').catch(() => null)
138
- if (current) {
139
- const parsed = JSON.parse(current)
140
- if (parsed && parsed.token === token) await fsp.unlink(paths.lock)
141
- }
142
- } catch {
143
- // a lock left behind by a crash is reported by the next writer, not fixed here
144
- }
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(() => {})
145
117
  }
146
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
 
@@ -253,6 +254,40 @@ export function parseJsonArray(stdout) {
253
254
  return { tests, ok: failed === 0, count: tests.length, failed, error: null }
254
255
  }
255
256
 
257
+ /** Test discovery is repository-controlled code: do not hand it secrets. */
258
+ const TEST_ENV_ALLOW = new Set([
259
+ 'PATH',
260
+ 'Path',
261
+ 'PATHEXT',
262
+ 'SystemRoot',
263
+ 'WINDIR',
264
+ 'ComSpec',
265
+ 'HOME',
266
+ 'USERPROFILE',
267
+ 'TMP',
268
+ 'TEMP',
269
+ 'TMPDIR',
270
+ 'LANG',
271
+ 'LC_ALL',
272
+ 'LC_CTYPE',
273
+ 'SHELL',
274
+ 'TERM',
275
+ 'APPDATA',
276
+ 'LOCALAPPDATA',
277
+ 'ProgramFiles',
278
+ 'ProgramFiles(x86)',
279
+ 'NODE_PATH',
280
+ ])
281
+
282
+ export function testProcessEnv(source = process.env) {
283
+ const env = {}
284
+ for (const [key, value] of Object.entries(source)) {
285
+ if (!TEST_ENV_ALLOW.has(key)) continue
286
+ env[key] = value
287
+ }
288
+ return env
289
+ }
290
+
256
291
  /** Platform shell for discovery commands (PowerShell 7 on Windows, bash on POSIX). */
257
292
  function shellSpec(platform = process.platform) {
258
293
  if (platform === 'win32') {
@@ -379,7 +414,7 @@ function spawnToFiles({ command, cwd, timeoutMs, maxBytes, stdoutPath, stderrPat
379
414
  windowsHide: true,
380
415
  // POSIX: a process group lets a timeout kill grandchildren too.
381
416
  detached: process.platform !== 'win32',
382
- env: process.env,
417
+ env: testProcessEnv(),
383
418
  })
384
419
  } catch (error) {
385
420
  spawnError = error
@@ -446,7 +481,7 @@ async function resolveDiagnosticsDir({ worktreePath, diagnosticsDir, missionId }
446
481
 
447
482
  function normalizeDiscoveryInput(config) {
448
483
  if (isPlainObject(config) && Object.prototype.hasOwnProperty.call(config, 'configured')) {
449
- 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}`)
450
485
  if (config.configured === false) return { configured: false, config: null, error: null }
451
486
  return { configured: true, config: config.config, error: null }
452
487
  }
@@ -493,16 +528,16 @@ export async function runDiscovery(options = {}) {
493
528
  } = options
494
529
  const startedAt = new Date().toISOString()
495
530
 
496
- if (!nonEmptyString(worktreePath)) throw new Error('worktreePath is required')
497
- 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}`)
498
533
  const effectiveTimeout = normalizePositiveInteger(timeoutMs, 'timeoutMs')
499
534
  const effectiveMaxBytes = normalizePositiveInteger(maxBytes, 'maxBytes')
500
535
  if (runner !== undefined && runner !== null && typeof runner !== 'function') {
501
- throw new Error('runner must be a function')
536
+ throw new TowerToolError('invalid-argument', 'runner must be a function')
502
537
  }
503
538
  if (diagnosticsDir !== undefined && diagnosticsDir !== null) {
504
539
  if (!nonEmptyString(diagnosticsDir) || !path.isAbsolute(diagnosticsDir)) {
505
- throw new Error(`diagnosticsDir must be an absolute path: ${diagnosticsDir}`)
540
+ throw new TowerToolError('invalid-argument', `diagnosticsDir must be an absolute path: ${diagnosticsDir}`)
506
541
  }
507
542
  }
508
543
 
@@ -524,7 +559,7 @@ export async function runDiscovery(options = {}) {
524
559
 
525
560
  if (!discovery.configured) return finishUnconfigured(discovery.error)
526
561
  const normalized = normalizeDiscovery(discovery.config)
527
- 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}`)
528
563
 
529
564
  const dir = await resolveDiagnosticsDir({ worktreePath, diagnosticsDir, missionId })
530
565
  const commands = []