dsh-tower 2.0.4 → 2.1.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 (184) hide show
  1. package/CHANGELOG.md +152 -0
  2. package/COMPATIBILITY.md +39 -0
  3. package/CONTRIBUTING.md +157 -2
  4. package/README.en.md +46 -3
  5. package/README.md +44 -3
  6. package/SECURITY.md +27 -0
  7. package/adapters/default.js +12 -0
  8. package/adapters/store-fs.js +6 -0
  9. package/adapters/vcs-git.js +4 -0
  10. package/app/archive.js +190 -0
  11. package/app/batch.js +79 -0
  12. package/app/board.js +27 -0
  13. package/app/checkCompletion.js +89 -0
  14. package/app/enrichedMissions.js +26 -0
  15. package/app/findings.js +78 -0
  16. package/app/gate.js +126 -0
  17. package/app/init.js +291 -0
  18. package/app/merge.js +573 -0
  19. package/app/messages.js +66 -0
  20. package/app/mission.js +37 -0
  21. package/app/missionContext.js +14 -0
  22. package/app/plan.js +50 -0
  23. package/app/provision.js +193 -0
  24. package/app/read.js +113 -0
  25. package/app/rebase.js +302 -0
  26. package/app/recording.js +72 -0
  27. package/app/review.js +147 -0
  28. package/app/roster.js +123 -0
  29. package/app/spawn.js +125 -0
  30. package/app/teardown.js +205 -0
  31. package/app/tower.js +552 -0
  32. package/cordis.patch.yml +61 -53
  33. package/docs/README.md +3 -1
  34. package/docs/agents/domain.md +38 -0
  35. package/docs/agents/issue-tracker.md +46 -0
  36. package/docs/agents/triage-labels.md +15 -0
  37. package/docs/architecture/overview.md +114 -13
  38. package/docs/board.md +72 -0
  39. package/docs/guide/hot-reload.md +1 -1
  40. package/docs/guide/install-upgrade-migration.md +25 -1
  41. package/docs/guide/release.md +125 -0
  42. package/docs/operations/security.md +47 -2
  43. package/docs/reference/blockers.md +1 -1
  44. package/docs/reference/config.generated.md +70 -0
  45. package/docs/reference/config.md +8 -1
  46. package/docs/reference/doctor.md +58 -8
  47. package/docs/reference/errors.generated.md +41 -0
  48. package/docs/reference/errors.md +7 -3
  49. package/docs/reference/tools.generated.md +33 -0
  50. package/docs/reference/tools.md +7 -6
  51. package/docs/zh/README.md +3 -2
  52. package/docs/zh/guide/hot-reload.md +1 -1
  53. package/docs/zh/guide/install-upgrade-migration.md +18 -1
  54. package/docs/zh/operations/security.md +30 -1
  55. package/docs/zh/reference/blockers.md +1 -1
  56. package/docs/zh/reference/config.md +8 -1
  57. package/docs/zh/reference/doctor.md +35 -2
  58. package/docs/zh/reference/errors.md +6 -2
  59. package/docs/zh/reference/tools.md +3 -3
  60. package/domain/collab/batch.js +112 -0
  61. package/domain/collab/messages.js +127 -0
  62. package/domain/collab/roster.js +158 -0
  63. package/domain/collab/spawn.js +188 -0
  64. package/domain/config/schema.js +427 -0
  65. package/domain/contracts/outputs.js +305 -0
  66. package/domain/contracts/tool-inputs/collaboration.js +58 -0
  67. package/domain/contracts/tool-inputs/findings.js +29 -0
  68. package/domain/contracts/tool-inputs/index.js +94 -0
  69. package/domain/contracts/tool-inputs/integration.js +37 -0
  70. package/domain/contracts/tool-inputs/missions.js +95 -0
  71. package/domain/contracts/tool-inputs/ops.js +75 -0
  72. package/domain/contracts/tool-inputs/shared.js +25 -0
  73. package/domain/errors.js +98 -0
  74. package/domain/gate.js +125 -0
  75. package/domain/integration/gate.js +190 -0
  76. package/domain/integration/init.js +261 -0
  77. package/domain/integration/liveness.js +31 -0
  78. package/domain/integration/merge.js +377 -0
  79. package/domain/integration/provision.js +124 -0
  80. package/domain/integration/rebase.js +236 -0
  81. package/domain/integration/review.js +52 -0
  82. package/domain/integration/teardown.js +118 -0
  83. package/domain/mission.js +68 -0
  84. package/domain/missions/completion.js +167 -0
  85. package/domain/missions/findings.js +201 -0
  86. package/domain/missions/lifecycle.js +186 -0
  87. package/domain/missions/plan.js +149 -0
  88. package/domain/missions/review.js +179 -0
  89. package/domain/planning.js +121 -0
  90. package/domain/read/enrichedMissions.js +58 -0
  91. package/domain/read/missionContext.js +53 -0
  92. package/domain/read/views.js +141 -0
  93. package/domain/report.js +67 -0
  94. package/domain/roster.js +40 -0
  95. package/domain/scope.js +177 -0
  96. package/domain/state/archive.js +257 -0
  97. package/domain/state/migrations.js +145 -0
  98. package/domain/state/schema.js +239 -0
  99. package/domain/state/semantics.js +117 -0
  100. package/domain/state.js +183 -0
  101. package/domain/summary.js +116 -0
  102. package/domain/vocabulary.js +179 -0
  103. package/index.js +63 -20
  104. package/lib/archive.js +279 -0
  105. package/lib/briefing.js +176 -0
  106. package/lib/caseSensitivity.js +66 -0
  107. package/lib/compatibility.js +108 -0
  108. package/lib/config.js +33 -375
  109. package/lib/doctor.js +275 -4
  110. package/lib/git.js +60 -16
  111. package/lib/hmr.js +4 -3
  112. package/lib/leases.js +17 -7
  113. package/lib/legacy.js +39 -14
  114. package/lib/modeState.js +39 -0
  115. package/lib/modeStore.js +11 -43
  116. package/lib/persistence.js +356 -0
  117. package/lib/presets.js +35 -0
  118. package/lib/spawn-prechecks.js +168 -0
  119. package/lib/spawn-support.js +79 -0
  120. package/lib/stateFile.js +99 -0
  121. package/lib/store.js +67 -135
  122. package/lib/tests.js +17 -12
  123. package/lib/tool-registry.js +544 -0
  124. package/lib/workspaceLock.js +2 -2
  125. package/lib/worktreeOwnership.js +22 -10
  126. package/locale/en.json +23 -1
  127. package/locale/zh.json +22 -0
  128. package/package.json +32 -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 +9 -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 +212 -0
  150. package/scripts/release-check.mjs +303 -0
  151. package/tools/adopt.js +72 -0
  152. package/tools/batch.js +78 -0
  153. package/tools/complete.js +53 -0
  154. package/tools/diff.js +39 -0
  155. package/tools/doctor.js +50 -0
  156. package/tools/finding.js +40 -0
  157. package/tools/findings.js +39 -0
  158. package/tools/import-legacy.js +42 -0
  159. package/tools/inbox.js +39 -0
  160. package/tools/index.js +39 -0
  161. package/tools/init.js +63 -0
  162. package/tools/interrupt.js +42 -0
  163. package/tools/legacy-map.js +44 -0
  164. package/tools/merge.js +39 -0
  165. package/tools/mission.js +41 -0
  166. package/tools/plan.js +39 -0
  167. package/tools/preflight.js +45 -0
  168. package/tools/rebase.js +39 -0
  169. package/tools/recover.js +45 -0
  170. package/tools/reload.js +52 -0
  171. package/tools/resource.js +54 -0
  172. package/tools/review.js +45 -0
  173. package/tools/roster.js +60 -0
  174. package/tools/send.js +147 -0
  175. package/tools/spawn.js +196 -0
  176. package/tools/status.js +102 -0
  177. package/tools/teardown.js +42 -0
  178. package/tools/test.js +61 -0
  179. package/types/cordis.d.ts +6 -0
  180. package/types/index.d.ts +373 -0
  181. package/lib/errors.js +0 -123
  182. package/lib/tools.js +0 -2460
  183. package/lib/tower.js +0 -2912
  184. /package/{lib → domain}/batch.js +0 -0
package/app/tower.js ADDED
@@ -0,0 +1,552 @@
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 { archiveIndex, archivedMission } from './archive.js'
24
+ import { recordSpawnOutcome, recordInterrupt, reserveRoster, recordSpawn, rosterView } from './spawn.js'
25
+ import { recordTestRun, recordLegacySnapshot, recordLegacyMap } from './recording.js'
26
+
27
+ export const ROLES = ['worker', 'reviewer']
28
+ const MAX_REVIEW_ROUNDS = 5
29
+ const DEFAULT_MERGE_TIMEOUT_MS = 15 * 60 * 1000
30
+ const MERGE_MAX_BUFFER = 64 * 1024 * 1024
31
+
32
+ function mergeTimeoutMs(value, config) {
33
+ if (value === undefined || value === null) {
34
+ const configured = (config ?? runtimeConfig()).merge.timeoutMs
35
+ return Number.isInteger(configured) && configured >= 1000 ? configured : DEFAULT_MERGE_TIMEOUT_MS
36
+ }
37
+ const ms = Number(value)
38
+ if (!Number.isInteger(ms) || ms < 1000) fail('timeoutMs must be an integer of at least 1000 milliseconds')
39
+ return ms
40
+ }
41
+
42
+ async function commitParents(cwd, sha) {
43
+ const line = await defaultDeps.vcs.tryGit(cwd, ['rev-list', '--parents', '-n', '1', sha])
44
+ return line ? line.trim().split(/\s+/).slice(1) : []
45
+ }
46
+
47
+ /**
48
+ * Find a reachable merge commit whose second parent is one of the known tips.
49
+ * `--no-ff` merges put the integrated branch on the second parent, so this is
50
+ * the concrete evidence that a mission's work already landed in the base.
51
+ */
52
+ async function findLandedMerge(cwd, baseTip, tips) {
53
+ const revs = (await defaultDeps.vcs.tryGit(cwd, ['rev-list', '--merges', baseTip])) ?? ''
54
+ for (const sha of revs.split('\n').filter(Boolean)) {
55
+ const parents = await commitParents(cwd, sha)
56
+ if (parents.length >= 2 && tips.has(parents[1])) return { commit: sha, parents }
57
+ }
58
+ return null
59
+ }
60
+
61
+ function describeGitFailure(error) {
62
+ const prefix = error?.timedOut ? 'timed out: ' : error?.bufferOverflow ? 'output buffer overflow: ' : ''
63
+ return `${prefix}${String(error?.message ?? error)}`
64
+ }
65
+
66
+ /** Remove only lock files that are provably old. A fresh lock may belong to a
67
+ * concurrent Git process; deleting it would let two writers into the index. */
68
+ async function removeStaleGitLocks(commonDir, maxAgeMs = 60000) {
69
+ const removed = []
70
+ for (const name of ['index.lock', 'HEAD.lock', 'MERGE_HEAD.lock', 'ORIG_HEAD.lock']) {
71
+ const file = path.join(commonDir, name)
72
+ try {
73
+ const stat = await fsp.stat(file)
74
+ if (Date.now() - stat.mtimeMs < maxAgeMs) continue
75
+ await fsp.unlink(file)
76
+ removed.push(name)
77
+ } catch {
78
+ // absent or in use; never force it
79
+ }
80
+ }
81
+ return removed
82
+ }
83
+
84
+ function fail(message, code = 'invalid-argument') {
85
+ throw new TowerToolError(code, message)
86
+ }
87
+
88
+ import { validateState } from '../domain/state.js'
89
+ import { loadStateFile } from '../lib/stateFile.js'
90
+ import { boardMarkdown } from '../domain/report.js'
91
+ import { diff, missionDetail, status } from './read.js'
92
+ import { evaluateGate } from './gate.js'
93
+ import { submitReview } from './review.js'
94
+ import { teardown } from './teardown.js'
95
+ import { rebase } from './rebase.js'
96
+ import { merge } from './merge.js'
97
+ import { init } from './init.js'
98
+ import { provision } from './provision.js'
99
+ import { addFinding, readFindings, findings } from './findings.js'
100
+ import { recordMessage, recordDelivery, inbox } from './messages.js'
101
+ import { createBatch, closeBatch, abandonBatch, batchStatus } from './batch.js'
102
+ import { recordRosterObservations, reconcileObservedInactive, markRetired, markMissing, sweepRoster, unreadFor } from './roster.js'
103
+ export { validateState } from '../domain/state.js'
104
+
105
+ export class Tower {
106
+ /**
107
+ * @param {{ root: string, commonDir: string, branch?: string | null }} repo
108
+ * @param {import('../types/index.js').TowerConfig} [config]
109
+ */
110
+ constructor(repo, config, deps = defaultDeps) {
111
+ this.root = repo.root
112
+ this.commonDir = repo.commonDir
113
+ this.branch = repo.branch
114
+ this.deps = deps
115
+ this.paths = this.deps.store.pathsFor(repo.commonDir)
116
+ // Effective config for this repository: code defaults < profile config <
117
+ // repo config file. Per-call tool arguments are applied by the caller.
118
+ this.config = /** @type {import('../types/index.js').TowerConfig} */ (config ?? { ...runtimeConfig(), configPath: null, repoConfigPresent: false })
119
+ }
120
+
121
+ static async open(repoRoot) {
122
+ const repo = await defaultDeps.vcs.resolveRepo(repoRoot)
123
+ const config = await resolveRepoConfig(repo)
124
+ return new Tower(repo, config)
125
+ }
126
+
127
+ git(cwd, args, options = {}) {
128
+ return this.deps.vcs.git(cwd, args, { timeoutMs: this.config.git?.timeoutMs, ...options })
129
+ }
130
+
131
+ tryGit(cwd, args, options = {}) {
132
+ return this.deps.vcs.tryGit(cwd, args, { timeoutMs: this.config.git?.timeoutMs, ...options })
133
+ }
134
+
135
+ async revParse(cwd, ref) {
136
+ return (await this.git(cwd, ['rev-parse', ref])).trim()
137
+ }
138
+
139
+ async isClean(cwd) {
140
+ return (await this.git(cwd, ['status', '--porcelain'])).trim().length === 0
141
+ }
142
+
143
+ async isAncestor(cwd, ancestor, descendant) {
144
+ return (await this.tryGit(cwd, ['merge-base', '--is-ancestor', ancestor, descendant].filter(Boolean))) !== null
145
+ }
146
+
147
+ /**
148
+ * Read the state document, migrating an older schema in memory and (while the
149
+ * caller holds the state lock) persisting that migration with one revision
150
+ * bump and a `state.migrated` activity entry (#72).
151
+ * @param {{ persist?: boolean }} [options]
152
+ */
153
+ async load(options = {}) {
154
+ if (!(await this.deps.store.exists(this.paths.state))) {
155
+ fail(`tower is not initialized for ${this.root} — run tower_init first`, 'not-initialized')
156
+ }
157
+ const loaded = await loadStateFile({
158
+ store: this.deps.store,
159
+ paths: this.paths,
160
+ now: this.deps.clock?.now ? this.deps.clock.now() : Date.now(),
161
+ persist: options.persist,
162
+ })
163
+ this.stateLoad = { ...loaded.version, migration: loaded.migration }
164
+ return loaded.state
165
+ }
166
+
167
+ /** #51 two-phase init: see the prepare/act/commit helpers in `./tower/init.js`. */
168
+ async init(input = {}) {
169
+ return init(this, input)
170
+ }
171
+
172
+ async plan(input = {}) {
173
+ return plan(this, input)
174
+ }
175
+
176
+ async mission(input = {}) {
177
+ return updateMission(this, input)
178
+ }
179
+
180
+ assertOutside(target) {
181
+ const resolved = path.resolve(target)
182
+ const inside = (parent) => {
183
+ const rel = path.relative(path.resolve(parent), resolved)
184
+ return rel !== '' && !rel.startsWith('..') && !path.isAbsolute(rel)
185
+ }
186
+ if (resolved === path.resolve(this.root) || inside(this.root)) {
187
+ fail(`worktree path must be outside the main checkout: ${resolved}`)
188
+ }
189
+ if (resolved === path.resolve(this.commonDir) || inside(this.commonDir)) {
190
+ fail(`worktree path must be outside Git metadata: ${resolved}`)
191
+ }
192
+ return resolved
193
+ }
194
+
195
+ async assertWorktree(worktree) {
196
+ if (!worktree || typeof worktree.path !== 'string') fail('mission has no worktree')
197
+ const owned = await assertOwnership({
198
+ storedPath: worktree.path,
199
+ storedRealPath: worktree.realPath,
200
+ branch: worktree.branch,
201
+ commonDir: this.commonDir,
202
+ missionId: worktree.missionId,
203
+ })
204
+ worktree.realPath = owned.realPath
205
+ const registered = await this.deps.vcs.listWorktrees(this.root)
206
+ if (!registered.some((entry) => path.resolve(entry.path) === path.resolve(owned.path))) {
207
+ fail(`worktree ${owned.path} is not registered with git`)
208
+ }
209
+ return worktree
210
+ }
211
+
212
+ /** Record that a spawned agent failed or went idle, so roster state is truthful. */
213
+ async recordSpawnOutcome(input) {
214
+ return recordSpawnOutcome(this, input)
215
+ }
216
+
217
+ /** Record that a live agent was interrupted by the tower. */
218
+ async recordInterrupt(input) {
219
+ return recordInterrupt(this, input)
220
+ }
221
+
222
+ /** #51 two-phase provision: see the prepare/act/commit helpers in `./tower/provision.js`. */
223
+ async provision(input = {}) {
224
+ return provision(this, input)
225
+ }
226
+
227
+ async missionContext(missionId) {
228
+ return missionContext(this, missionId)
229
+ }
230
+
231
+ /** Reserve a roster name before activation so a failed spawn is recoverable. */
232
+ async reserveRoster(input) {
233
+ return reserveRoster(this, input)
234
+ }
235
+
236
+ async recordSpawn(input) {
237
+ return recordSpawn(this, input)
238
+ }
239
+
240
+ async rosterView() {
241
+ return rosterView(this)
242
+ }
243
+
244
+ async recordTestRun(input = {}) {
245
+ return recordTestRun(this, input)
246
+ }
247
+
248
+ async recordLegacySnapshot(snapshot) {
249
+ return recordLegacySnapshot(this, snapshot)
250
+ }
251
+
252
+ async recordLegacyMap(map) {
253
+ return recordLegacyMap(this, map)
254
+ }
255
+
256
+ async createBatch(input = {}, actor = 'tower') {
257
+ return createBatch(this, input, actor)
258
+ }
259
+
260
+ async closeBatch(batchId, options = {}, actor = 'tower') {
261
+ return closeBatch(this, batchId, options, actor)
262
+ }
263
+
264
+ async abandonBatch(batchId, actor = 'tower', reason = null) {
265
+ return abandonBatch(this, batchId, actor, reason)
266
+ }
267
+
268
+ async batchStatus(batchId) {
269
+ return batchStatus(this, batchId)
270
+ }
271
+
272
+ async recordRosterObservations(input = {}) {
273
+ return recordRosterObservations(this, input)
274
+ }
275
+
276
+ async reconcileObservedInactive(activity = {}) {
277
+ return reconcileObservedInactive(this, activity)
278
+ }
279
+
280
+ async markRetired(agentIds) {
281
+ return markRetired(this, agentIds)
282
+ }
283
+
284
+ async markMissing(agentIds) {
285
+ return markMissing(this, agentIds)
286
+ }
287
+
288
+ async sweepRoster(input = {}) {
289
+ return sweepRoster(this, input)
290
+ }
291
+
292
+ async recordMessage(input) {
293
+ return recordMessage(this, input)
294
+ }
295
+
296
+ async recordDelivery(messageId, results) {
297
+ return recordDelivery(this, messageId, results)
298
+ }
299
+
300
+ async inbox(input) {
301
+ return inbox(this, input)
302
+ }
303
+
304
+ async checkCompletion(input) {
305
+ return checkCompletion(this, input)
306
+ }
307
+
308
+ async complete(input) {
309
+ return complete(this, input)
310
+ }
311
+
312
+ async addFinding(input) {
313
+ return addFinding(this, input)
314
+ }
315
+
316
+ async readFindings(input = {}) {
317
+ return readFindings(this, input)
318
+ }
319
+
320
+ async findings(input = {}) {
321
+ return findings(this, input)
322
+ }
323
+
324
+ async submitReview(input) {
325
+ return submitReview(this, input)
326
+ }
327
+
328
+ /** Full detail for one mission, including reviews, findings and recent activity. */
329
+ async missionDetail(missionId) {
330
+ return missionDetail(this, missionId)
331
+ }
332
+
333
+ /**
334
+ * Human-readable mission board (markdown) for the current state.
335
+ * @param {import('../types/index.js').TowerState} state
336
+ * @param {{ count?: number, unreadable?: boolean, message?: string } | null} [archive]
337
+ */
338
+ boardMarkdown(state, archive = null) {
339
+ return boardMarkdown(state, archive)
340
+ }
341
+
342
+ /** Read `archive/index.json`; `ok:false` carries a diagnostic instead of throwing. */
343
+ archiveIndex() {
344
+ return archiveIndex(this)
345
+ }
346
+
347
+ /** Load one archived mission (full record) with its linked reviews and findings. */
348
+ archivedMission(missionId) {
349
+ return archivedMission(this, missionId)
350
+ }
351
+
352
+ async ensureExcluded(target) {
353
+ if (this.config.init?.autoExclude === false) return { inside: false, excluded: false }
354
+ const absolute = path.resolve(String(target))
355
+ const relative = path.relative(this.root, absolute)
356
+ if (relative === '' || relative.startsWith('..') || path.isAbsolute(relative)) {
357
+ return { inside: false, excluded: false }
358
+ }
359
+ const pattern = relative.split(path.sep).join('/')
360
+ const file = path.join(this.commonDir, 'info', 'exclude')
361
+ await this.deps.store.ensureDir(path.dirname(file))
362
+ let text = ''
363
+ try {
364
+ text = await fsp.readFile(file, 'utf8')
365
+ } catch {
366
+ text = ''
367
+ }
368
+ const lines = text.split(/\r?\n/)
369
+ if (lines.includes(pattern) || lines.includes(`${pattern}/`)) return { inside: true, excluded: true, pattern }
370
+ const suffix = text === '' || text.endsWith('\n') ? '' : '\n'
371
+ await fsp.appendFile(file, `${suffix}${pattern}\n`, 'utf8')
372
+ return { inside: true, excluded: true, pattern }
373
+ }
374
+
375
+ async writeBoard() {
376
+ return writeBoard(this)
377
+ }
378
+
379
+ unreadFor(state, name) {
380
+ return unreadFor(this, state, name)
381
+ }
382
+
383
+ boardFile() {
384
+ return boardFile(this)
385
+ }
386
+
387
+ async enrichedMissions(state, onlyIds) {
388
+ return enrichedMissions(this, state, onlyIds)
389
+ }
390
+
391
+ rebaseWaived(mission, review) {
392
+ return rebaseWaived(mission, review)
393
+ }
394
+
395
+ async evaluateGate(input) {
396
+ return evaluateGate(this, input)
397
+ }
398
+
399
+ async diff(input = {}) {
400
+ return diff(this, input)
401
+ }
402
+
403
+ /**
404
+ * Record a merge that already landed in Git without being recorded here:
405
+ * a crashed/timed-out merge, a manual merge, or a rebase that fast-forwarded
406
+ * the branch to the base tip. The evidence is concrete: a clean merge review
407
+ * exists and either the branch tip or the reviewed commit is already an
408
+ * ancestor of the base.
409
+ */
410
+ /** Clear a stale merge intent only when the crash left no merge in progress
411
+ * or no merge commit. Returns true when the intent was consumed. */
412
+ async reconcileMergeIntent(state, mission) {
413
+ const intent = state.mergeIntent
414
+ if (!intent || intent.missionId !== mission.id || !mission.worktree) return false
415
+ let head = null
416
+ try {
417
+ head = await this.revParse(this.root, 'HEAD')
418
+ } catch {
419
+ return false
420
+ }
421
+ const mergeHeadPath = path.join(this.commonDir, 'MERGE_HEAD')
422
+ const inProgress = await this.deps.store.exists(mergeHeadPath)
423
+ if (inProgress) return false
424
+ let landed = null
425
+ if (head === intent.baseTip) {
426
+ landed = null // no merge commit was created; the intent is safe to drop
427
+ } else {
428
+ landed = await findLandedMerge(
429
+ this.root,
430
+ head,
431
+ new Set([intent.branchTip, intent.baseTip].filter(Boolean)),
432
+ ).catch(() => null)
433
+ if (!landed) return false
434
+ }
435
+ const now = Date.now()
436
+ delete state.mergeIntent
437
+ state.revision += 1
438
+ state.updatedAt = now
439
+ await this.deps.store.writeJsonAtomic(this.paths.state, state)
440
+ await this.deps.store.appendJsonl(this.paths.activity, {
441
+ at: now,
442
+ type: 'merge',
443
+ missionId: mission.id,
444
+ text: landed
445
+ ? `reconciled merge intent; found landed commit ${String(landed.commit).slice(0, 7)}`
446
+ : 'cleared a merge intent that created no commit',
447
+ })
448
+ return true
449
+ }
450
+
451
+ async reconcileLandedMerge(state, mission) {
452
+ if (!['completed', 'blocked'].includes(mission.status)) return null
453
+ if (!mission.worktree) return null
454
+ const branch = mission.worktree.branch
455
+ const reviews = (state.reviews ?? []).filter((review) => review.branch === branch)
456
+ const exempt =
457
+ this.config.review?.requireReview === false ||
458
+ (mission.reviewPolicy === 'none' &&
459
+ this.config.gates?.allowReviewExemption === true &&
460
+ String(mission.reviewExemptionReason ?? '').trim().length > 0)
461
+ if (reviews.length === 0 && !exempt) return null
462
+ const latest = reviews.length > 0 ? reviews.reduce((a, b) => (a.round >= b.round ? a : b)) : null
463
+ if (latest && !exempt && (latest.status !== 'clean' || latest.mergeVerdict !== 'merge')) return null
464
+ if (!latest && !exempt) return null
465
+ let baseTip
466
+ let branchTip
467
+ try {
468
+ baseTip = await this.revParse(this.root, state.base)
469
+ branchTip = await this.revParse(mission.worktree.path, 'HEAD')
470
+ } catch {
471
+ return null
472
+ }
473
+ const reviewedTip = latest?.reviewedCommit ?? null
474
+ const branchLanded = await this.isAncestor(this.root, branchTip, baseTip)
475
+ const reviewedLanded = reviewedTip ? await this.isAncestor(this.root, reviewedTip, baseTip) : false
476
+ if (!branchLanded) {
477
+ if (reviewedLanded) {
478
+ return {
479
+ ok: false,
480
+ missionId: mission.id,
481
+ blockers: [
482
+ {
483
+ code: 'review-stale',
484
+ 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`,
485
+ },
486
+ ],
487
+ }
488
+ }
489
+ return null
490
+ }
491
+ const tips = new Set([branchTip, reviewedTip].filter(Boolean))
492
+ const found = await findLandedMerge(this.root, baseTip, tips)
493
+ const mergedCommit = found ? found.commit : branchTip
494
+ const now = Date.now()
495
+ if (state.mergeIntent?.missionId === mission.id) delete state.mergeIntent
496
+ mission.status = 'merged'
497
+ mission.mergedAt = now
498
+ mission.mergedCommit = mergedCommit
499
+ mission.blockers = []
500
+ mission.notes.push(
501
+ found
502
+ ? `reconciled already-landed merge ${mergedCommit.slice(0, 7)} with parents [${found.parents.map((parent) => parent.slice(0, 7)).join(', ')}]`
503
+ : `reconciled already-landed merge: ${mergedCommit.slice(0, 7)} is contained in ${state.base} (no merge commit found)`,
504
+ )
505
+ mission.updatedAt = now
506
+ state.revision += 1
507
+ state.updatedAt = now
508
+ validateState(state, this.paths.state)
509
+ await this.deps.store.writeJsonAtomic(this.paths.state, state)
510
+ await this.deps.store.appendJsonl(this.paths.activity, {
511
+ at: now,
512
+ type: 'merge',
513
+ missionId: mission.id,
514
+ text: `reconciled already-landed ${mergedCommit.slice(0, 7)} on ${state.base}`,
515
+ })
516
+ return {
517
+ ok: true,
518
+ reconciled: true,
519
+ alreadyLanded: true,
520
+ mergedCommit,
521
+ baseTip,
522
+ branchTip,
523
+ parents: found ? found.parents : null,
524
+ review: latest
525
+ ? {
526
+ round: latest.round,
527
+ status: latest.status,
528
+ mergeVerdict: latest.mergeVerdict,
529
+ reviewer: latest.reviewer,
530
+ reviewedCommit: reviewedTip,
531
+ }
532
+ : null,
533
+ }
534
+ }
535
+
536
+ async merge(input) {
537
+ return merge(this, input, { describeGitFailure, mergeTimeoutMs, commitParents, removeStaleGitLocks, MERGE_MAX_BUFFER })
538
+ }
539
+
540
+ async rebase(input) {
541
+ return rebase(this, input)
542
+ }
543
+
544
+ async teardown(input) {
545
+ return teardown(this, input)
546
+ }
547
+
548
+ async status(input = {}) {
549
+ return status(this, input)
550
+ }
551
+
552
+ }
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)