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/app/tower.js ADDED
@@ -0,0 +1,524 @@
1
+ // Tower domain: repo-scoped mission state machine, validation, and gates.
2
+ import path from 'node:path'
3
+ import { fsp } from '../adapters/store-fs.js'
4
+ import { defaultDeps } from '../adapters/default.js'
5
+ import { validateCreate as validateBatchCreate, validateClose as validateBatchClose, summarize as summarizeBatch } from '../domain/batch.js'
6
+ import { assertOwnership } from '../lib/worktreeOwnership.js'
7
+
8
+ import { runtimeConfig, resolveRepoConfig } from '../lib/config.js'
9
+ import { rebaseWaived } from '../domain/gate.js'
10
+ export { ROSTER_INACTIVE_STATES } from '../domain/roster.js'
11
+
12
+ export { fileInScope, scopesOverlap, validateTitle, normalizeScope } from '../domain/scope.js'
13
+ export { missionSummary } from '../domain/summary.js'
14
+
15
+ export { MISSION_STATUSES, SCHEMA_VERSION, MERGE_VERDICTS, FINDING_TYPES, FINDING_SEVERITIES, FINDING_STATUSES, FINDING_TRANSITIONS } from '../domain/mission.js'
16
+ import { TowerToolError } from '../domain/errors.js'
17
+ import { plan } from './plan.js'
18
+ import { updateMission } from './mission.js'
19
+ import { checkCompletion, complete } from './checkCompletion.js'
20
+ import { missionContext } from './missionContext.js'
21
+ import { enrichedMissions } from './enrichedMissions.js'
22
+ import { writeBoard, boardFile } from './board.js'
23
+ import { recordSpawnOutcome, recordInterrupt, reserveRoster, recordSpawn, rosterView } from './spawn.js'
24
+ import { recordTestRun, recordLegacySnapshot, recordLegacyMap } from './recording.js'
25
+
26
+ export const ROLES = ['worker', 'reviewer']
27
+ const MAX_REVIEW_ROUNDS = 5
28
+ const DEFAULT_MERGE_TIMEOUT_MS = 15 * 60 * 1000
29
+ const MERGE_MAX_BUFFER = 64 * 1024 * 1024
30
+
31
+ function mergeTimeoutMs(value, config) {
32
+ if (value === undefined || value === null) {
33
+ const configured = (config ?? runtimeConfig()).merge.timeoutMs
34
+ return Number.isInteger(configured) && configured >= 1000 ? configured : DEFAULT_MERGE_TIMEOUT_MS
35
+ }
36
+ const ms = Number(value)
37
+ if (!Number.isInteger(ms) || ms < 1000) fail('timeoutMs must be an integer of at least 1000 milliseconds')
38
+ return ms
39
+ }
40
+
41
+ async function commitParents(cwd, sha) {
42
+ const line = await defaultDeps.vcs.tryGit(cwd, ['rev-list', '--parents', '-n', '1', sha])
43
+ return line ? line.trim().split(/\s+/).slice(1) : []
44
+ }
45
+
46
+ /**
47
+ * Find a reachable merge commit whose second parent is one of the known tips.
48
+ * `--no-ff` merges put the integrated branch on the second parent, so this is
49
+ * the concrete evidence that a mission's work already landed in the base.
50
+ */
51
+ async function findLandedMerge(cwd, baseTip, tips) {
52
+ const revs = (await defaultDeps.vcs.tryGit(cwd, ['rev-list', '--merges', baseTip])) ?? ''
53
+ for (const sha of revs.split('\n').filter(Boolean)) {
54
+ const parents = await commitParents(cwd, sha)
55
+ if (parents.length >= 2 && tips.has(parents[1])) return { commit: sha, parents }
56
+ }
57
+ return null
58
+ }
59
+
60
+ function describeGitFailure(error) {
61
+ const prefix = error?.timedOut ? 'timed out: ' : error?.bufferOverflow ? 'output buffer overflow: ' : ''
62
+ return `${prefix}${String(error?.message ?? error)}`
63
+ }
64
+
65
+ /** Remove only lock files that are provably old. A fresh lock may belong to a
66
+ * concurrent Git process; deleting it would let two writers into the index. */
67
+ async function removeStaleGitLocks(commonDir, maxAgeMs = 60000) {
68
+ const removed = []
69
+ for (const name of ['index.lock', 'HEAD.lock', 'MERGE_HEAD.lock', 'ORIG_HEAD.lock']) {
70
+ const file = path.join(commonDir, name)
71
+ try {
72
+ const stat = await fsp.stat(file)
73
+ if (Date.now() - stat.mtimeMs < maxAgeMs) continue
74
+ await fsp.unlink(file)
75
+ removed.push(name)
76
+ } catch {
77
+ // absent or in use; never force it
78
+ }
79
+ }
80
+ return removed
81
+ }
82
+
83
+ function fail(message, code = 'invalid-argument') {
84
+ throw new TowerToolError(code, message)
85
+ }
86
+
87
+ import { validateState } from '../domain/state.js'
88
+ import { boardMarkdown } from '../domain/report.js'
89
+ import { diff, missionDetail, status } from './read.js'
90
+ import { evaluateGate } from './gate.js'
91
+ import { submitReview } from './review.js'
92
+ import { teardown } from './teardown.js'
93
+ import { rebase } from './rebase.js'
94
+ import { merge } from './merge.js'
95
+ import { init } from './init.js'
96
+ import { provision } from './provision.js'
97
+ import { addFinding, readFindings, findings } from './findings.js'
98
+ import { recordMessage, recordDelivery, inbox } from './messages.js'
99
+ import { createBatch, closeBatch, abandonBatch, batchStatus } from './batch.js'
100
+ import { recordRosterObservations, reconcileObservedInactive, markRetired, markMissing, sweepRoster, unreadFor } from './roster.js'
101
+ export { validateState } from '../domain/state.js'
102
+
103
+ export class Tower {
104
+ /**
105
+ * @param {{ root: string, commonDir: string, branch?: string | null }} repo
106
+ * @param {import('../types/index.js').TowerConfig} [config]
107
+ */
108
+ constructor(repo, config, deps = defaultDeps) {
109
+ this.root = repo.root
110
+ this.commonDir = repo.commonDir
111
+ this.branch = repo.branch
112
+ this.deps = deps
113
+ this.paths = this.deps.store.pathsFor(repo.commonDir)
114
+ // Effective config for this repository: code defaults < profile config <
115
+ // repo config file. Per-call tool arguments are applied by the caller.
116
+ this.config = /** @type {import('../types/index.js').TowerConfig} */ (config ?? { ...runtimeConfig(), configPath: null, repoConfigPresent: false })
117
+ }
118
+
119
+ static async open(repoRoot) {
120
+ const repo = await defaultDeps.vcs.resolveRepo(repoRoot)
121
+ const config = await resolveRepoConfig(repo)
122
+ return new Tower(repo, config)
123
+ }
124
+
125
+ git(cwd, args, options = {}) {
126
+ return this.deps.vcs.git(cwd, args, { timeoutMs: this.config.git?.timeoutMs, ...options })
127
+ }
128
+
129
+ tryGit(cwd, args, options = {}) {
130
+ return this.deps.vcs.tryGit(cwd, args, { timeoutMs: this.config.git?.timeoutMs, ...options })
131
+ }
132
+
133
+ async revParse(cwd, ref) {
134
+ return (await this.git(cwd, ['rev-parse', ref])).trim()
135
+ }
136
+
137
+ async isClean(cwd) {
138
+ return (await this.git(cwd, ['status', '--porcelain'])).trim().length === 0
139
+ }
140
+
141
+ async isAncestor(cwd, ancestor, descendant) {
142
+ return (await this.tryGit(cwd, ['merge-base', '--is-ancestor', ancestor, descendant].filter(Boolean))) !== null
143
+ }
144
+
145
+ async load() {
146
+ if (!(await this.deps.store.exists(this.paths.state))) {
147
+ fail(`tower is not initialized for ${this.root} — run tower_init first`, 'not-initialized')
148
+ }
149
+ const state = await this.deps.store.readJson(this.paths.state)
150
+ return validateState(state, this.paths.state)
151
+ }
152
+
153
+ /** #51 two-phase init: see the prepare/act/commit helpers in `./tower/init.js`. */
154
+ async init(input = {}) {
155
+ return init(this, input)
156
+ }
157
+
158
+ async plan(input = {}) {
159
+ return plan(this, input)
160
+ }
161
+
162
+ async mission(input = {}) {
163
+ return updateMission(this, input)
164
+ }
165
+
166
+ assertOutside(target) {
167
+ const resolved = path.resolve(target)
168
+ const inside = (parent) => {
169
+ const rel = path.relative(path.resolve(parent), resolved)
170
+ return rel !== '' && !rel.startsWith('..') && !path.isAbsolute(rel)
171
+ }
172
+ if (resolved === path.resolve(this.root) || inside(this.root)) {
173
+ fail(`worktree path must be outside the main checkout: ${resolved}`)
174
+ }
175
+ if (resolved === path.resolve(this.commonDir) || inside(this.commonDir)) {
176
+ fail(`worktree path must be outside Git metadata: ${resolved}`)
177
+ }
178
+ return resolved
179
+ }
180
+
181
+ async assertWorktree(worktree) {
182
+ if (!worktree || typeof worktree.path !== 'string') fail('mission has no worktree')
183
+ const owned = await assertOwnership({
184
+ storedPath: worktree.path,
185
+ storedRealPath: worktree.realPath,
186
+ branch: worktree.branch,
187
+ commonDir: this.commonDir,
188
+ missionId: worktree.missionId,
189
+ })
190
+ worktree.realPath = owned.realPath
191
+ const registered = await this.deps.vcs.listWorktrees(this.root)
192
+ if (!registered.some((entry) => path.resolve(entry.path) === path.resolve(owned.path))) {
193
+ fail(`worktree ${owned.path} is not registered with git`)
194
+ }
195
+ return worktree
196
+ }
197
+
198
+ /** Record that a spawned agent failed or went idle, so roster state is truthful. */
199
+ async recordSpawnOutcome(input) {
200
+ return recordSpawnOutcome(this, input)
201
+ }
202
+
203
+ /** Record that a live agent was interrupted by the tower. */
204
+ async recordInterrupt(input) {
205
+ return recordInterrupt(this, input)
206
+ }
207
+
208
+ /** #51 two-phase provision: see the prepare/act/commit helpers in `./tower/provision.js`. */
209
+ async provision(input = {}) {
210
+ return provision(this, input)
211
+ }
212
+
213
+ async missionContext(missionId) {
214
+ return missionContext(this, missionId)
215
+ }
216
+
217
+ /** Reserve a roster name before activation so a failed spawn is recoverable. */
218
+ async reserveRoster(input) {
219
+ return reserveRoster(this, input)
220
+ }
221
+
222
+ async recordSpawn(input) {
223
+ return recordSpawn(this, input)
224
+ }
225
+
226
+ async rosterView() {
227
+ return rosterView(this)
228
+ }
229
+
230
+ async recordTestRun(input = {}) {
231
+ return recordTestRun(this, input)
232
+ }
233
+
234
+ async recordLegacySnapshot(snapshot) {
235
+ return recordLegacySnapshot(this, snapshot)
236
+ }
237
+
238
+ async recordLegacyMap(map) {
239
+ return recordLegacyMap(this, map)
240
+ }
241
+
242
+ async createBatch(input = {}, actor = 'tower') {
243
+ return createBatch(this, input, actor)
244
+ }
245
+
246
+ async closeBatch(batchId, options = {}, actor = 'tower') {
247
+ return closeBatch(this, batchId, options, actor)
248
+ }
249
+
250
+ async abandonBatch(batchId, actor = 'tower', reason = null) {
251
+ return abandonBatch(this, batchId, actor, reason)
252
+ }
253
+
254
+ async batchStatus(batchId) {
255
+ return batchStatus(this, batchId)
256
+ }
257
+
258
+ async recordRosterObservations(input = {}) {
259
+ return recordRosterObservations(this, input)
260
+ }
261
+
262
+ async reconcileObservedInactive(activity = {}) {
263
+ return reconcileObservedInactive(this, activity)
264
+ }
265
+
266
+ async markRetired(agentIds) {
267
+ return markRetired(this, agentIds)
268
+ }
269
+
270
+ async markMissing(agentIds) {
271
+ return markMissing(this, agentIds)
272
+ }
273
+
274
+ async sweepRoster(input = {}) {
275
+ return sweepRoster(this, input)
276
+ }
277
+
278
+ async recordMessage(input) {
279
+ return recordMessage(this, input)
280
+ }
281
+
282
+ async recordDelivery(messageId, results) {
283
+ return recordDelivery(this, messageId, results)
284
+ }
285
+
286
+ async inbox(input) {
287
+ return inbox(this, input)
288
+ }
289
+
290
+ async checkCompletion(input) {
291
+ return checkCompletion(this, input)
292
+ }
293
+
294
+ async complete(input) {
295
+ return complete(this, input)
296
+ }
297
+
298
+ async addFinding(input) {
299
+ return addFinding(this, input)
300
+ }
301
+
302
+ async readFindings(input = {}) {
303
+ return readFindings(this, input)
304
+ }
305
+
306
+ async findings(input = {}) {
307
+ return findings(this, input)
308
+ }
309
+
310
+ async submitReview(input) {
311
+ return submitReview(this, input)
312
+ }
313
+
314
+ /** Full detail for one mission, including reviews, findings and recent activity. */
315
+ async missionDetail(missionId) {
316
+ return missionDetail(this, missionId)
317
+ }
318
+
319
+ /** Human-readable mission board (markdown) for the current state. */
320
+ boardMarkdown(state) {
321
+ return boardMarkdown(state)
322
+ }
323
+
324
+ async ensureExcluded(target) {
325
+ if (this.config.init?.autoExclude === false) return { inside: false, excluded: false }
326
+ const absolute = path.resolve(String(target))
327
+ const relative = path.relative(this.root, absolute)
328
+ if (relative === '' || relative.startsWith('..') || path.isAbsolute(relative)) {
329
+ return { inside: false, excluded: false }
330
+ }
331
+ const pattern = relative.split(path.sep).join('/')
332
+ const file = path.join(this.commonDir, 'info', 'exclude')
333
+ await this.deps.store.ensureDir(path.dirname(file))
334
+ let text = ''
335
+ try {
336
+ text = await fsp.readFile(file, 'utf8')
337
+ } catch {
338
+ text = ''
339
+ }
340
+ const lines = text.split(/\r?\n/)
341
+ if (lines.includes(pattern) || lines.includes(`${pattern}/`)) return { inside: true, excluded: true, pattern }
342
+ const suffix = text === '' || text.endsWith('\n') ? '' : '\n'
343
+ await fsp.appendFile(file, `${suffix}${pattern}\n`, 'utf8')
344
+ return { inside: true, excluded: true, pattern }
345
+ }
346
+
347
+ async writeBoard() {
348
+ return writeBoard(this)
349
+ }
350
+
351
+ unreadFor(state, name) {
352
+ return unreadFor(this, state, name)
353
+ }
354
+
355
+ boardFile() {
356
+ return boardFile(this)
357
+ }
358
+
359
+ async enrichedMissions(state, onlyIds) {
360
+ return enrichedMissions(this, state, onlyIds)
361
+ }
362
+
363
+ rebaseWaived(mission, review) {
364
+ return rebaseWaived(mission, review)
365
+ }
366
+
367
+ async evaluateGate(input) {
368
+ return evaluateGate(this, input)
369
+ }
370
+
371
+ async diff(input = {}) {
372
+ return diff(this, input)
373
+ }
374
+
375
+ /**
376
+ * Record a merge that already landed in Git without being recorded here:
377
+ * a crashed/timed-out merge, a manual merge, or a rebase that fast-forwarded
378
+ * the branch to the base tip. The evidence is concrete: a clean merge review
379
+ * exists and either the branch tip or the reviewed commit is already an
380
+ * ancestor of the base.
381
+ */
382
+ /** Clear a stale merge intent only when the crash left no merge in progress
383
+ * or no merge commit. Returns true when the intent was consumed. */
384
+ async reconcileMergeIntent(state, mission) {
385
+ const intent = state.mergeIntent
386
+ if (!intent || intent.missionId !== mission.id || !mission.worktree) return false
387
+ let head = null
388
+ try {
389
+ head = await this.revParse(this.root, 'HEAD')
390
+ } catch {
391
+ return false
392
+ }
393
+ const mergeHeadPath = path.join(this.commonDir, 'MERGE_HEAD')
394
+ const inProgress = await this.deps.store.exists(mergeHeadPath)
395
+ if (inProgress) return false
396
+ let landed = null
397
+ if (head === intent.baseTip) {
398
+ landed = null // no merge commit was created; the intent is safe to drop
399
+ } else {
400
+ landed = await findLandedMerge(
401
+ this.root,
402
+ head,
403
+ new Set([intent.branchTip, intent.baseTip].filter(Boolean)),
404
+ ).catch(() => null)
405
+ if (!landed) return false
406
+ }
407
+ const now = Date.now()
408
+ delete state.mergeIntent
409
+ state.revision += 1
410
+ state.updatedAt = now
411
+ await this.deps.store.writeJsonAtomic(this.paths.state, state)
412
+ await this.deps.store.appendJsonl(this.paths.activity, {
413
+ at: now,
414
+ type: 'merge',
415
+ missionId: mission.id,
416
+ text: landed
417
+ ? `reconciled merge intent; found landed commit ${String(landed.commit).slice(0, 7)}`
418
+ : 'cleared a merge intent that created no commit',
419
+ })
420
+ return true
421
+ }
422
+
423
+ async reconcileLandedMerge(state, mission) {
424
+ if (!['completed', 'blocked'].includes(mission.status)) return null
425
+ if (!mission.worktree) return null
426
+ const branch = mission.worktree.branch
427
+ const reviews = (state.reviews ?? []).filter((review) => review.branch === branch)
428
+ const exempt =
429
+ this.config.review?.requireReview === false ||
430
+ (mission.reviewPolicy === 'none' &&
431
+ this.config.gates?.allowReviewExemption === true &&
432
+ String(mission.reviewExemptionReason ?? '').trim().length > 0)
433
+ if (reviews.length === 0 && !exempt) return null
434
+ const latest = reviews.length > 0 ? reviews.reduce((a, b) => (a.round >= b.round ? a : b)) : null
435
+ if (latest && !exempt && (latest.status !== 'clean' || latest.mergeVerdict !== 'merge')) return null
436
+ if (!latest && !exempt) return null
437
+ let baseTip
438
+ let branchTip
439
+ try {
440
+ baseTip = await this.revParse(this.root, state.base)
441
+ branchTip = await this.revParse(mission.worktree.path, 'HEAD')
442
+ } catch {
443
+ return null
444
+ }
445
+ const reviewedTip = latest?.reviewedCommit ?? null
446
+ const branchLanded = await this.isAncestor(this.root, branchTip, baseTip)
447
+ const reviewedLanded = reviewedTip ? await this.isAncestor(this.root, reviewedTip, baseTip) : false
448
+ if (!branchLanded) {
449
+ if (reviewedLanded) {
450
+ return {
451
+ ok: false,
452
+ missionId: mission.id,
453
+ blockers: [
454
+ {
455
+ code: 'review-stale',
456
+ detail: `reviewed commit ${reviewedTip.slice(0, 7)} is contained in ${state.base}, but branch tip ${branchTip.slice(0, 7)} is not; the branch was partially landed or moved — re-review or repair manually`,
457
+ },
458
+ ],
459
+ }
460
+ }
461
+ return null
462
+ }
463
+ const tips = new Set([branchTip, reviewedTip].filter(Boolean))
464
+ const found = await findLandedMerge(this.root, baseTip, tips)
465
+ const mergedCommit = found ? found.commit : branchTip
466
+ const now = Date.now()
467
+ if (state.mergeIntent?.missionId === mission.id) delete state.mergeIntent
468
+ mission.status = 'merged'
469
+ mission.mergedAt = now
470
+ mission.mergedCommit = mergedCommit
471
+ mission.blockers = []
472
+ mission.notes.push(
473
+ found
474
+ ? `reconciled already-landed merge ${mergedCommit.slice(0, 7)} with parents [${found.parents.map((parent) => parent.slice(0, 7)).join(', ')}]`
475
+ : `reconciled already-landed merge: ${mergedCommit.slice(0, 7)} is contained in ${state.base} (no merge commit found)`,
476
+ )
477
+ mission.updatedAt = now
478
+ state.revision += 1
479
+ state.updatedAt = now
480
+ validateState(state, this.paths.state)
481
+ await this.deps.store.writeJsonAtomic(this.paths.state, state)
482
+ await this.deps.store.appendJsonl(this.paths.activity, {
483
+ at: now,
484
+ type: 'merge',
485
+ missionId: mission.id,
486
+ text: `reconciled already-landed ${mergedCommit.slice(0, 7)} on ${state.base}`,
487
+ })
488
+ return {
489
+ ok: true,
490
+ reconciled: true,
491
+ alreadyLanded: true,
492
+ mergedCommit,
493
+ baseTip,
494
+ branchTip,
495
+ parents: found ? found.parents : null,
496
+ review: latest
497
+ ? {
498
+ round: latest.round,
499
+ status: latest.status,
500
+ mergeVerdict: latest.mergeVerdict,
501
+ reviewer: latest.reviewer,
502
+ reviewedCommit: reviewedTip,
503
+ }
504
+ : null,
505
+ }
506
+ }
507
+
508
+ async merge(input) {
509
+ return merge(this, input, { describeGitFailure, mergeTimeoutMs, commitParents, removeStaleGitLocks, MERGE_MAX_BUFFER })
510
+ }
511
+
512
+ async rebase(input) {
513
+ return rebase(this, input)
514
+ }
515
+
516
+ async teardown(input) {
517
+ return teardown(this, input)
518
+ }
519
+
520
+ async status(input = {}) {
521
+ return status(this, input)
522
+ }
523
+
524
+ }
package/cordis.patch.yml CHANGED
@@ -1,55 +1,63 @@
1
+ # dsh-tower is an all-or-nothing bundle. The single group row is the only
2
+ # user-visible component: disabling it disables every internal section (host,
3
+ # skills, worker/reviewer presets) together, so no orphan preset or skill can
4
+ # point at missing tower_* tools. See docs/architecture/overview.md.
1
5
  - insert:
2
- - id: tower
3
- name: 'dsh-tower'
6
+ - id: tower-bundle
7
+ name: cordis:group
8
+ group: true
4
9
  config:
5
- mode:
6
- allowAgentControl: true
7
- workspaceLock: true
8
- workspaceLockScope: all
9
- planModePolicy: clear
10
- init:
11
- autoGitInit: true
12
- send:
13
- interruptDefault: true
14
- spawn:
15
- staleMs: 15000
16
- - id: tower-skills
17
- name: 'dsh-tower/skills'
18
- - id: preset-tower-worker
19
- name: '@deepseek-ai/dsh-agent-preset'
20
- config:
21
- id: tower-worker
22
- name: Tower Worker
23
- description: Executes one Tower mission inside its assigned git worktree.
24
- order: 60
25
- plugins:
26
- - id: tower-worker-policy
27
- name: 'dsh-tower/worker'
28
- - id: tower-worker-fs
29
- name: '@deepseek-ai/dsh-tool-fs'
30
- - id: tower-worker-fs-search
31
- name: '@deepseek-ai/dsh-tool-fs-search'
32
- config:
33
- sampleOverCapGlobResults: false
34
- - id: tower-worker-pwsh
35
- name: '@deepseek-ai/dsh-tool-pwsh'
36
- - id: preset-tower-reviewer
37
- name: '@deepseek-ai/dsh-agent-preset'
38
- config:
39
- id: tower-reviewer
40
- name: Tower Reviewer
41
- description: Read-only independent reviewer for a completed Tower mission.
42
- order: 61
43
- plugins:
44
- - id: tower-reviewer-policy
45
- name: 'dsh-tower/reviewer'
46
- - id: tower-reviewer-fs
47
- name: '@deepseek-ai/dsh-tool-fs'
48
- config:
49
- write: false
50
- edit: false
51
- readImage: false
52
- - id: tower-reviewer-fs-search
53
- name: '@deepseek-ai/dsh-tool-fs-search'
54
- config:
55
- sampleOverCapGlobResults: false
10
+ - id: tower
11
+ name: 'dsh-tower'
12
+ config:
13
+ mode:
14
+ allowAgentControl: true
15
+ workspaceLock: true
16
+ workspaceLockScope: all
17
+ planModePolicy: clear
18
+ init:
19
+ autoGitInit: true
20
+ send:
21
+ interruptDefault: true
22
+ spawn:
23
+ staleMs: 15000
24
+ - id: tower-skills
25
+ name: 'dsh-tower/skills'
26
+ - id: preset-tower-worker
27
+ name: '@deepseek-ai/dsh-agent-preset'
28
+ config:
29
+ id: tower-worker
30
+ name: Tower Worker
31
+ description: Executes one Tower mission inside its assigned git worktree.
32
+ order: 60
33
+ plugins:
34
+ - id: tower-worker-policy
35
+ name: 'dsh-tower/worker'
36
+ - id: tower-worker-fs
37
+ name: '@deepseek-ai/dsh-tool-fs'
38
+ - id: tower-worker-fs-search
39
+ name: '@deepseek-ai/dsh-tool-fs-search'
40
+ config:
41
+ sampleOverCapGlobResults: false
42
+ - id: tower-worker-pwsh
43
+ name: '@deepseek-ai/dsh-tool-pwsh'
44
+ - id: preset-tower-reviewer
45
+ name: '@deepseek-ai/dsh-agent-preset'
46
+ config:
47
+ id: tower-reviewer
48
+ name: Tower Reviewer
49
+ description: Read-only independent reviewer for a completed Tower mission.
50
+ order: 61
51
+ plugins:
52
+ - id: tower-reviewer-policy
53
+ name: 'dsh-tower/reviewer'
54
+ - id: tower-reviewer-fs
55
+ name: '@deepseek-ai/dsh-tool-fs'
56
+ config:
57
+ write: false
58
+ edit: false
59
+ readImage: false
60
+ - id: tower-reviewer-fs-search
61
+ name: '@deepseek-ai/dsh-tool-fs-search'
62
+ config:
63
+ sampleOverCapGlobResults: false
package/docs/README.md CHANGED
@@ -9,7 +9,7 @@ Choose a path:
9
9
  | New user | [When to use Tower](guide/scenarios-and-philosophy.md) → [Quick start](guide/quickstart.md) → [Tools](reference/tools.md) → [Example](examples/first-mission.md) |
10
10
  | Choosing a workflow | [When to use Tower, and how it differs from Agent Teams, Ralph, Workflow and parallel subagents](guide/scenarios-and-philosophy.md) |
11
11
  | Operator | [Runbook](operations/runbook.md) → [Blockers](reference/blockers.md) → [Error codes](reference/errors.md) → [Events](reference/events.md) → [Doctor](reference/doctor.md) → [Testing](reference/testing.md) → [Batches/leases/legacy](reference/operations.md) → [Security](operations/security.md) |
12
- | Maintainer | [Architecture](architecture/overview.md) → [Config](reference/config.md) → [Hot reload](guide/hot-reload.md) → [Contributing](../CONTRIBUTING.md) |
12
+ | Maintainer | [Architecture](architecture/overview.md) → [Config](reference/config.md) → [Hot reload](guide/hot-reload.md) → [Release checklist](guide/release.md) → [Tracker board](board.md) → [Contributing](../CONTRIBUTING.md) |
13
13
 
14
14
  Reference pages are the source of truth for the current release; the [tools reference](reference/tools.md),
15
15
  [blocker list](reference/blockers.md) and [error codes](reference/errors.md) are checked against the
@@ -41,6 +41,8 @@ Other useful pages: [When to use Tower](guide/scenarios-and-philosophy.md), [Ins
41
41
 
42
42
  - [Operations runbook](operations/runbook.md)
43
43
  - [Security model](operations/security.md)
44
+ - [Release checklist](guide/release.md)
45
+ - [Tracker board](board.md)
44
46
  - [Architecture and state machine](architecture/overview.md)
45
47
  - [First mission example](examples/first-mission.md)
46
48
  - [FAQ](faq.md)