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.
- package/CHANGELOG.md +101 -0
- package/COMPATIBILITY.md +38 -0
- package/CONTRIBUTING.md +150 -2
- package/README.en.md +40 -3
- package/README.md +38 -3
- package/SECURITY.md +27 -0
- package/adapters/default.js +10 -0
- package/adapters/store-fs.js +6 -0
- package/adapters/vcs-git.js +4 -0
- package/app/batch.js +79 -0
- package/app/board.js +25 -0
- package/app/checkCompletion.js +76 -0
- package/app/enrichedMissions.js +26 -0
- package/app/findings.js +78 -0
- package/app/gate.js +108 -0
- package/app/init.js +249 -0
- package/app/merge.js +567 -0
- package/app/messages.js +66 -0
- package/app/mission.js +35 -0
- package/app/missionContext.js +14 -0
- package/app/plan.js +42 -0
- package/app/provision.js +193 -0
- package/app/read.js +57 -0
- package/app/rebase.js +302 -0
- package/app/recording.js +72 -0
- package/app/review.js +145 -0
- package/app/roster.js +123 -0
- package/app/spawn.js +125 -0
- package/app/teardown.js +184 -0
- package/app/tower.js +524 -0
- package/cordis.patch.yml +61 -53
- package/docs/README.md +3 -1
- package/docs/architecture/overview.md +111 -12
- package/docs/board.md +72 -0
- package/docs/guide/hot-reload.md +1 -1
- package/docs/guide/install-upgrade-migration.md +8 -1
- package/docs/guide/release.md +125 -0
- package/docs/operations/security.md +47 -2
- package/docs/reference/config.generated.md +66 -0
- package/docs/reference/config.md +4 -1
- package/docs/reference/errors.generated.md +39 -0
- package/docs/reference/errors.md +4 -2
- package/docs/reference/tools.generated.md +33 -0
- package/docs/reference/tools.md +1 -1
- package/docs/zh/README.md +3 -2
- package/docs/zh/guide/hot-reload.md +1 -1
- package/docs/zh/guide/install-upgrade-migration.md +6 -1
- package/docs/zh/operations/security.md +30 -1
- package/docs/zh/reference/config.md +4 -1
- package/docs/zh/reference/errors.md +3 -1
- package/docs/zh/reference/tools.md +1 -1
- package/domain/collab/batch.js +112 -0
- package/domain/collab/messages.js +127 -0
- package/domain/collab/roster.js +158 -0
- package/domain/collab/spawn.js +188 -0
- package/domain/config/schema.js +404 -0
- package/domain/contracts/outputs.js +279 -0
- package/domain/contracts/tool-inputs/collaboration.js +58 -0
- package/domain/contracts/tool-inputs/findings.js +29 -0
- package/domain/contracts/tool-inputs/index.js +94 -0
- package/domain/contracts/tool-inputs/integration.js +37 -0
- package/domain/contracts/tool-inputs/missions.js +94 -0
- package/domain/contracts/tool-inputs/ops.js +75 -0
- package/domain/contracts/tool-inputs/shared.js +25 -0
- package/domain/errors.js +96 -0
- package/domain/gate.js +125 -0
- package/domain/integration/gate.js +176 -0
- package/domain/integration/init.js +225 -0
- package/domain/integration/liveness.js +31 -0
- package/domain/integration/merge.js +377 -0
- package/domain/integration/provision.js +124 -0
- package/domain/integration/rebase.js +236 -0
- package/domain/integration/review.js +52 -0
- package/domain/integration/teardown.js +118 -0
- package/domain/mission.js +68 -0
- package/domain/missions/completion.js +114 -0
- package/domain/missions/findings.js +201 -0
- package/domain/missions/lifecycle.js +185 -0
- package/domain/missions/plan.js +149 -0
- package/domain/missions/review.js +179 -0
- package/domain/planning.js +118 -0
- package/domain/read/enrichedMissions.js +58 -0
- package/domain/read/missionContext.js +53 -0
- package/domain/read/views.js +128 -0
- package/domain/report.js +59 -0
- package/domain/roster.js +40 -0
- package/domain/scope.js +105 -0
- package/domain/state/schema.js +233 -0
- package/domain/state/semantics.js +117 -0
- package/domain/state.js +33 -0
- package/domain/summary.js +114 -0
- package/domain/vocabulary.js +179 -0
- package/index.js +63 -20
- package/lib/briefing.js +176 -0
- package/lib/compatibility.js +108 -0
- package/lib/config.js +33 -375
- package/lib/doctor.js +97 -4
- package/lib/git.js +60 -16
- package/lib/hmr.js +4 -3
- package/lib/leases.js +3 -2
- package/lib/legacy.js +20 -9
- package/lib/modeState.js +39 -0
- package/lib/modeStore.js +11 -43
- package/lib/persistence.js +349 -0
- package/lib/presets.js +35 -0
- package/lib/spawn-prechecks.js +168 -0
- package/lib/spawn-support.js +79 -0
- package/lib/store.js +54 -137
- package/lib/tests.js +11 -10
- package/lib/tool-registry.js +544 -0
- package/lib/workspaceLock.js +2 -2
- package/lib/worktreeOwnership.js +5 -4
- package/locale/en.json +23 -1
- package/locale/zh.json +22 -0
- package/package.json +32 -4
- package/ports/clock.js +6 -0
- package/ports/ids.js +7 -0
- package/ports/index.js +2 -0
- package/ports/store.js +11 -0
- package/ports/vcs.js +11 -0
- package/scripts/architecture.mjs +94 -0
- package/scripts/check-docs.mjs +9 -3
- package/scripts/check-pack.mjs +49 -0
- package/scripts/domain-metrics.mjs +48 -0
- package/scripts/generate-config-reference.mjs +46 -0
- package/scripts/generate-error-reference.mjs +29 -0
- package/scripts/generate-tools-reference.mjs +56 -0
- package/scripts/issue-audit.mjs +259 -0
- package/scripts/lib/cli.mjs +76 -0
- package/scripts/lib/tmp.mjs +20 -0
- package/scripts/mount-smoke/fixtures.mjs +162 -0
- package/scripts/mount-smoke/host.json +4 -0
- package/scripts/mount-smoke/probe.mjs +125 -0
- package/scripts/mount-smoke/surface.mjs +141 -0
- package/scripts/mount-smoke.mjs +270 -0
- package/scripts/pack-manifest.txt +203 -0
- package/scripts/release-check.mjs +303 -0
- package/tools/adopt.js +72 -0
- package/tools/batch.js +78 -0
- package/tools/complete.js +53 -0
- package/tools/diff.js +39 -0
- package/tools/doctor.js +50 -0
- package/tools/finding.js +40 -0
- package/tools/findings.js +39 -0
- package/tools/import-legacy.js +42 -0
- package/tools/inbox.js +39 -0
- package/tools/index.js +39 -0
- package/tools/init.js +63 -0
- package/tools/interrupt.js +42 -0
- package/tools/legacy-map.js +44 -0
- package/tools/merge.js +39 -0
- package/tools/mission.js +41 -0
- package/tools/plan.js +39 -0
- package/tools/preflight.js +44 -0
- package/tools/rebase.js +39 -0
- package/tools/recover.js +45 -0
- package/tools/reload.js +52 -0
- package/tools/resource.js +54 -0
- package/tools/review.js +45 -0
- package/tools/roster.js +60 -0
- package/tools/send.js +147 -0
- package/tools/spawn.js +196 -0
- package/tools/status.js +94 -0
- package/tools/teardown.js +42 -0
- package/tools/test.js +61 -0
- package/types/cordis.d.ts +6 -0
- package/types/index.d.ts +339 -0
- package/lib/errors.js +0 -123
- package/lib/tools.js +0 -2460
- package/lib/tower.js +0 -2912
- /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:
|
|
6
|
+
- id: tower-bundle
|
|
7
|
+
name: cordis:group
|
|
8
|
+
group: true
|
|
4
9
|
config:
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
id: tower-worker
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
id: tower-reviewer
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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)
|