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.
- package/CHANGELOG.md +152 -0
- package/COMPATIBILITY.md +39 -0
- package/CONTRIBUTING.md +157 -2
- package/README.en.md +46 -3
- package/README.md +44 -3
- package/SECURITY.md +27 -0
- package/adapters/default.js +12 -0
- package/adapters/store-fs.js +6 -0
- package/adapters/vcs-git.js +4 -0
- package/app/archive.js +190 -0
- package/app/batch.js +79 -0
- package/app/board.js +27 -0
- package/app/checkCompletion.js +89 -0
- package/app/enrichedMissions.js +26 -0
- package/app/findings.js +78 -0
- package/app/gate.js +126 -0
- package/app/init.js +291 -0
- package/app/merge.js +573 -0
- package/app/messages.js +66 -0
- package/app/mission.js +37 -0
- package/app/missionContext.js +14 -0
- package/app/plan.js +50 -0
- package/app/provision.js +193 -0
- package/app/read.js +113 -0
- package/app/rebase.js +302 -0
- package/app/recording.js +72 -0
- package/app/review.js +147 -0
- package/app/roster.js +123 -0
- package/app/spawn.js +125 -0
- package/app/teardown.js +205 -0
- package/app/tower.js +552 -0
- package/cordis.patch.yml +61 -53
- package/docs/README.md +3 -1
- package/docs/agents/domain.md +38 -0
- package/docs/agents/issue-tracker.md +46 -0
- package/docs/agents/triage-labels.md +15 -0
- package/docs/architecture/overview.md +114 -13
- package/docs/board.md +72 -0
- package/docs/guide/hot-reload.md +1 -1
- package/docs/guide/install-upgrade-migration.md +25 -1
- package/docs/guide/release.md +125 -0
- package/docs/operations/security.md +47 -2
- package/docs/reference/blockers.md +1 -1
- package/docs/reference/config.generated.md +70 -0
- package/docs/reference/config.md +8 -1
- package/docs/reference/doctor.md +58 -8
- package/docs/reference/errors.generated.md +41 -0
- package/docs/reference/errors.md +7 -3
- package/docs/reference/tools.generated.md +33 -0
- package/docs/reference/tools.md +7 -6
- package/docs/zh/README.md +3 -2
- package/docs/zh/guide/hot-reload.md +1 -1
- package/docs/zh/guide/install-upgrade-migration.md +18 -1
- package/docs/zh/operations/security.md +30 -1
- package/docs/zh/reference/blockers.md +1 -1
- package/docs/zh/reference/config.md +8 -1
- package/docs/zh/reference/doctor.md +35 -2
- package/docs/zh/reference/errors.md +6 -2
- package/docs/zh/reference/tools.md +3 -3
- 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 +427 -0
- package/domain/contracts/outputs.js +305 -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 +95 -0
- package/domain/contracts/tool-inputs/ops.js +75 -0
- package/domain/contracts/tool-inputs/shared.js +25 -0
- package/domain/errors.js +98 -0
- package/domain/gate.js +125 -0
- package/domain/integration/gate.js +190 -0
- package/domain/integration/init.js +261 -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 +167 -0
- package/domain/missions/findings.js +201 -0
- package/domain/missions/lifecycle.js +186 -0
- package/domain/missions/plan.js +149 -0
- package/domain/missions/review.js +179 -0
- package/domain/planning.js +121 -0
- package/domain/read/enrichedMissions.js +58 -0
- package/domain/read/missionContext.js +53 -0
- package/domain/read/views.js +141 -0
- package/domain/report.js +67 -0
- package/domain/roster.js +40 -0
- package/domain/scope.js +177 -0
- package/domain/state/archive.js +257 -0
- package/domain/state/migrations.js +145 -0
- package/domain/state/schema.js +239 -0
- package/domain/state/semantics.js +117 -0
- package/domain/state.js +183 -0
- package/domain/summary.js +116 -0
- package/domain/vocabulary.js +179 -0
- package/index.js +63 -20
- package/lib/archive.js +279 -0
- package/lib/briefing.js +176 -0
- package/lib/caseSensitivity.js +66 -0
- package/lib/compatibility.js +108 -0
- package/lib/config.js +33 -375
- package/lib/doctor.js +275 -4
- package/lib/git.js +60 -16
- package/lib/hmr.js +4 -3
- package/lib/leases.js +17 -7
- package/lib/legacy.js +39 -14
- package/lib/modeState.js +39 -0
- package/lib/modeStore.js +11 -43
- package/lib/persistence.js +356 -0
- package/lib/presets.js +35 -0
- package/lib/spawn-prechecks.js +168 -0
- package/lib/spawn-support.js +79 -0
- package/lib/stateFile.js +99 -0
- package/lib/store.js +67 -135
- package/lib/tests.js +17 -12
- package/lib/tool-registry.js +544 -0
- package/lib/workspaceLock.js +2 -2
- package/lib/worktreeOwnership.js +22 -10
- 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 +212 -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 +45 -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 +102 -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 +373 -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
|
@@ -0,0 +1,544 @@
|
|
|
1
|
+
// Tower tool registration and shared machinery (#56 P5).
|
|
2
|
+
//
|
|
3
|
+
// Per-tool handlers live in tools/*.js; this module owns the registration
|
|
4
|
+
// aggregator, actor guards, spawn throttling, repository preparation, and the
|
|
5
|
+
// best-effort roster watchdog. tools/index.js stays a thin public wiring module.
|
|
6
|
+
import { Tower } from '../app/tower.js'
|
|
7
|
+
import { TowerToolError, classifyError } from '../domain/errors.js'
|
|
8
|
+
import {
|
|
9
|
+
PARENT_STATUS_OK,
|
|
10
|
+
ROLE,
|
|
11
|
+
ROSTER_ACTIVITY,
|
|
12
|
+
ROSTER_INACTIVE_STATES,
|
|
13
|
+
isRunningActivity,
|
|
14
|
+
isTowerRole,
|
|
15
|
+
isUnknownRole,
|
|
16
|
+
} from '../domain/vocabulary.js'
|
|
17
|
+
import { git, resolveRepo, hasAnyCommit } from './git.js'
|
|
18
|
+
import { runtimeConfig } from './config.js'
|
|
19
|
+
import { registerDoctorTool } from '../tools/doctor.js'
|
|
20
|
+
import { registerRecoverTool } from '../tools/recover.js'
|
|
21
|
+
import { registerTestTool } from '../tools/test.js'
|
|
22
|
+
import { registerBatchTool } from '../tools/batch.js'
|
|
23
|
+
import { registerResourceTool } from '../tools/resource.js'
|
|
24
|
+
import { registerImportLegacyTool } from '../tools/import-legacy.js'
|
|
25
|
+
import { registerLegacyMapTool } from '../tools/legacy-map.js'
|
|
26
|
+
import { registerReloadTool } from '../tools/reload.js'
|
|
27
|
+
import { registerInitTool } from '../tools/init.js'
|
|
28
|
+
import { registerPlanTool } from '../tools/plan.js'
|
|
29
|
+
import { registerMissionTool } from '../tools/mission.js'
|
|
30
|
+
import { registerStatusTool } from '../tools/status.js'
|
|
31
|
+
import { registerRosterTool } from '../tools/roster.js'
|
|
32
|
+
import { registerInterruptTool } from '../tools/interrupt.js'
|
|
33
|
+
import { registerDiffTool } from '../tools/diff.js'
|
|
34
|
+
import { registerAdoptTool } from '../tools/adopt.js'
|
|
35
|
+
import { registerSendTool } from '../tools/send.js'
|
|
36
|
+
import { registerInboxTool } from '../tools/inbox.js'
|
|
37
|
+
import { registerReviewTool } from '../tools/review.js'
|
|
38
|
+
import { registerPreflightTool } from '../tools/preflight.js'
|
|
39
|
+
import { registerMergeTool } from '../tools/merge.js'
|
|
40
|
+
import { registerRebaseTool } from '../tools/rebase.js'
|
|
41
|
+
import { registerTeardownTool } from '../tools/teardown.js'
|
|
42
|
+
import { registerSpawnTool } from '../tools/spawn.js'
|
|
43
|
+
import { registerCompleteTool } from '../tools/complete.js'
|
|
44
|
+
import { registerFindingTool } from '../tools/finding.js'
|
|
45
|
+
import { registerFindingsTool } from '../tools/findings.js'
|
|
46
|
+
import { readDiscoveryConfig, normalizeDiscovery, runDiscovery } from './tests.js'
|
|
47
|
+
import { summarize as summarizeBatch } from '../domain/batch.js'
|
|
48
|
+
import path from 'node:path'
|
|
49
|
+
import { fsp } from '../adapters/store-fs.js'
|
|
50
|
+
import crypto from 'node:crypto'
|
|
51
|
+
|
|
52
|
+
// Mutable composition state is grouped in one const object: the registration
|
|
53
|
+
// aggregator is the composition root, not an ambient module-level binding.
|
|
54
|
+
const runtimeState = {
|
|
55
|
+
workspaceHook: null,
|
|
56
|
+
hmrBridge: null,
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function normalizeToolArgs(args, aliases = {}) {
|
|
60
|
+
const out = { ...(args ?? {}) }
|
|
61
|
+
for (const [canonical, names] of Object.entries(aliases)) {
|
|
62
|
+
const current = out[canonical]
|
|
63
|
+
if (current !== undefined && current !== null) continue
|
|
64
|
+
for (const name of names) {
|
|
65
|
+
if (out[name] !== undefined && out[name] !== null) {
|
|
66
|
+
out[canonical] = out[name]
|
|
67
|
+
break
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return out
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Resolve the calling agent to a roster role; unknown callers are rejected. */
|
|
75
|
+
async function actorFor(tower, exec) {
|
|
76
|
+
const agentId = exec?.agent?.id !== undefined ? String(exec.agent.id) : null
|
|
77
|
+
const unknown = { name: ROLE.unknown, role: ROLE.unknown, agentId, ownerMissing: false }
|
|
78
|
+
if (!agentId) return unknown
|
|
79
|
+
let state
|
|
80
|
+
try {
|
|
81
|
+
state = await tower.load()
|
|
82
|
+
} catch (error) {
|
|
83
|
+
if (error?.code !== 'not-initialized') throw error
|
|
84
|
+
// No readable state: only tower_init may claim ownership (handled in that tool).
|
|
85
|
+
return unknown
|
|
86
|
+
}
|
|
87
|
+
if (!state.towerAgentId) unknown.ownerMissing = true
|
|
88
|
+
if (state.towerAgentId && String(state.towerAgentId) === agentId) {
|
|
89
|
+
return { name: ROLE.tower, role: ROLE.tower, agentId }
|
|
90
|
+
}
|
|
91
|
+
let view
|
|
92
|
+
try {
|
|
93
|
+
view = await tower.rosterView()
|
|
94
|
+
} catch {
|
|
95
|
+
return unknown
|
|
96
|
+
}
|
|
97
|
+
const self = view.roster.find((entry) => entry.agentId === agentId)
|
|
98
|
+
if (!self) return unknown
|
|
99
|
+
if (isTowerRole(self.role)) return { name: self.name, role: ROLE.tower, agentId }
|
|
100
|
+
return { name: self.name, role: self.role, agentId }
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function requireKnownActor(actor, action) {
|
|
104
|
+
if (!actor || isUnknownRole(actor.role)) {
|
|
105
|
+
throw new TowerToolError('not-authorized', `${action} requires a registered roster agent or the tower${actor?.ownerMissing ? '; repository state has no towerAgentId — run tower_init to re-claim' : ''}`)
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function requireTowerActor(actor, action) {
|
|
110
|
+
if (!isTowerRole(actor.role)) {
|
|
111
|
+
throw new TowerToolError('not-authorized', `only the tower can ${action}; ${actor.name} is a ${actor.role}${actor.ownerMissing ? '; repository state has no towerAgentId — run tower_init to re-claim' : ''}`)
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const SEND_RATE = new Map()
|
|
116
|
+
const SPAWN_QUEUES = new Map()
|
|
117
|
+
const SPAWN_BACKOFF = new Map()
|
|
118
|
+
|
|
119
|
+
function spawnBackoffState(repoRoot, maxLiveWorkers) {
|
|
120
|
+
const key = String(repoRoot)
|
|
121
|
+
let state = SPAWN_BACKOFF.get(key)
|
|
122
|
+
if (!state) {
|
|
123
|
+
state = { failures: 0, blockedUntil: 0, budget: Math.max(1, Number(maxLiveWorkers) || 4) }
|
|
124
|
+
SPAWN_BACKOFF.set(key, state)
|
|
125
|
+
}
|
|
126
|
+
if (state.failures === 0) state.budget = Math.max(1, Number(maxLiveWorkers) || 4)
|
|
127
|
+
return state
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function recordSpawnThrottle(repoRoot, maxLiveWorkers) {
|
|
131
|
+
const state = spawnBackoffState(repoRoot, maxLiveWorkers)
|
|
132
|
+
state.failures += 1
|
|
133
|
+
state.budget = Math.max(1, (Number(maxLiveWorkers) || 4) - 1)
|
|
134
|
+
if (state.failures >= 3) {
|
|
135
|
+
const delay = Math.min(60000, 5000 * 2 ** Math.min(4, state.failures - 3))
|
|
136
|
+
state.blockedUntil = Date.now() + delay
|
|
137
|
+
}
|
|
138
|
+
return state
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function recordSpawnSuccess(repoRoot, maxLiveWorkers) {
|
|
142
|
+
const state = spawnBackoffState(repoRoot, maxLiveWorkers)
|
|
143
|
+
state.failures = 0
|
|
144
|
+
state.blockedUntil = 0
|
|
145
|
+
state.budget = Math.max(1, Number(maxLiveWorkers) || 4)
|
|
146
|
+
return state
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function spawnBackoffView(repoRoot, maxLiveWorkers, inflight) {
|
|
150
|
+
const state = spawnBackoffState(repoRoot, maxLiveWorkers)
|
|
151
|
+
return {
|
|
152
|
+
inflight,
|
|
153
|
+
budget: Math.min(state.budget, Math.max(1, Number(maxLiveWorkers) || 4)),
|
|
154
|
+
blockedUntil: state.blockedUntil > Date.now() ? state.blockedUntil : 0,
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
const WATCHED_REPOS = new Set()
|
|
158
|
+
function watchRepo(repoRoot) {
|
|
159
|
+
WATCHED_REPOS.add(repoRoot)
|
|
160
|
+
if (WATCHED_REPOS.size > 100) {
|
|
161
|
+
const oldest = WATCHED_REPOS.values().next().value
|
|
162
|
+
WATCHED_REPOS.delete(oldest)
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** Serialize spawn decisions per repository so precheck + activation + record are atomic. */
|
|
167
|
+
function enqueueSpawn(key, operation) {
|
|
168
|
+
const previous = SPAWN_QUEUES.get(key) ?? Promise.resolve()
|
|
169
|
+
const run = previous.catch(() => {}).then(operation)
|
|
170
|
+
const settled = run.catch(() => {})
|
|
171
|
+
SPAWN_QUEUES.set(key, settled)
|
|
172
|
+
settled.finally(() => {
|
|
173
|
+
if (SPAWN_QUEUES.get(key) === settled) SPAWN_QUEUES.delete(key)
|
|
174
|
+
})
|
|
175
|
+
return run
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** In-memory per-repository tower_send rate limit (process-local, advisory). */
|
|
179
|
+
function checkSendRate(config, repoRoot) {
|
|
180
|
+
const max = Number(config.send?.maxPerMinute)
|
|
181
|
+
if (!max || max <= 0) return
|
|
182
|
+
const key = String(repoRoot)
|
|
183
|
+
const now = Date.now()
|
|
184
|
+
const bucket = SEND_RATE.get(key)
|
|
185
|
+
if (!bucket || now - bucket.start >= 60000) {
|
|
186
|
+
SEND_RATE.set(key, { start: now, count: 1 })
|
|
187
|
+
return
|
|
188
|
+
}
|
|
189
|
+
if (bucket.count >= max) {
|
|
190
|
+
throw new TowerToolError('rate-limited', `tower_send rate limit reached (${max}/min for ${key}); retry later or raise send.maxPerMinute`)
|
|
191
|
+
}
|
|
192
|
+
bucket.count += 1
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
async function verifyBatchReceiptFiles(root, batch, args) {
|
|
196
|
+
const problems = []
|
|
197
|
+
const frozen = new Set(batch.receiptPaths ?? [])
|
|
198
|
+
const declared = new Map()
|
|
199
|
+
for (const [rawPath, rawHash] of Object.entries(args.receiptHashes ?? {})) {
|
|
200
|
+
declared.set(String(rawPath), String(rawHash).trim().toLowerCase())
|
|
201
|
+
}
|
|
202
|
+
for (const entry of args.receipts ?? []) {
|
|
203
|
+
if (entry && typeof entry.path === 'string' && typeof entry.hash === 'string') {
|
|
204
|
+
declared.set(entry.path, entry.hash.trim().toLowerCase())
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
for (const [rel, reported] of declared) {
|
|
208
|
+
if (frozen.size > 0 && !frozen.has(rel)) continue
|
|
209
|
+
let actual
|
|
210
|
+
try {
|
|
211
|
+
const abs = path.resolve(root, rel)
|
|
212
|
+
const relative = path.relative(root, abs)
|
|
213
|
+
if (relative.startsWith('..') || path.isAbsolute(relative)) throw new TowerToolError('invalid-argument', 'path escapes the repository')
|
|
214
|
+
actual = crypto.createHash('sha256').update(await fsp.readFile(abs)).digest('hex')
|
|
215
|
+
} catch (error) {
|
|
216
|
+
problems.push({ code: 'batch-invalid', detail: `receipt "${rel}" cannot be read: ${String(error?.message ?? error)}` })
|
|
217
|
+
continue
|
|
218
|
+
}
|
|
219
|
+
if (reported && reported !== actual) {
|
|
220
|
+
problems.push({ code: 'batch-invalid', detail: `receipt "${rel}" hash ${reported.slice(0, 12)} does not match the file (${actual.slice(0, 12)})` })
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
return problems
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
async function interruptRosterAgent(ctx, tower, exec, input) {
|
|
227
|
+
const subagents = ctx.get('subagents')
|
|
228
|
+
if (!subagents || typeof subagents.interrupt !== 'function') {
|
|
229
|
+
throw new TowerToolError('spawn-unavailable', 'subagents service is unavailable; cannot interrupt')
|
|
230
|
+
}
|
|
231
|
+
const view = await tower.rosterView()
|
|
232
|
+
const entry = view.roster.find(
|
|
233
|
+
(row) =>
|
|
234
|
+
(input.agentId !== undefined && row.agentId === String(input.agentId)) ||
|
|
235
|
+
(input.to !== undefined && row.name === input.to),
|
|
236
|
+
)
|
|
237
|
+
if (!entry) throw new TowerToolError('invalid-argument', `unknown roster agent "${input.to ?? input.agentId}"`)
|
|
238
|
+
const { activity } = await observeActivity(ctx, tower)
|
|
239
|
+
const previousActivity = activity[entry.agentId] ?? ROSTER_ACTIVITY.unknown
|
|
240
|
+
if (!isRunningActivity(previousActivity)) {
|
|
241
|
+
return { interrupted: false, reason: `agent is ${previousActivity}, not running`, entry }
|
|
242
|
+
}
|
|
243
|
+
subagents.interrupt(entry.agentId, { kind: 'ancestor', agent: exec.agent })
|
|
244
|
+
const updated = await tower.recordInterrupt({
|
|
245
|
+
agentId: entry.agentId,
|
|
246
|
+
name: entry.name,
|
|
247
|
+
reason: input.reason ?? 'interrupted by tower',
|
|
248
|
+
})
|
|
249
|
+
return { interrupted: true, previousActivity, entry: updated }
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** autoGitInit support: Tower.open needs a repository, so prepare it first.
|
|
253
|
+
* Only absolute, existing directories are eligible; a typo must not git-init the
|
|
254
|
+
* DSH process cwd or create a repository at an unintended path. */
|
|
255
|
+
async function ensureRepository(repoRootInput, autoGitInit) {
|
|
256
|
+
try {
|
|
257
|
+
return await resolveRepo(repoRootInput)
|
|
258
|
+
} catch (error) {
|
|
259
|
+
if (autoGitInit !== true) throw error
|
|
260
|
+
if (typeof repoRootInput !== 'string' || !path.isAbsolute(repoRootInput)) {
|
|
261
|
+
throw new TowerToolError('repo-invalid', `autoGitInit requires an absolute directory path: ${repoRootInput}`)
|
|
262
|
+
}
|
|
263
|
+
const resolved = path.resolve(repoRootInput)
|
|
264
|
+
let stat = null
|
|
265
|
+
try {
|
|
266
|
+
stat = await fsp.stat(resolved)
|
|
267
|
+
} catch {
|
|
268
|
+
stat = null
|
|
269
|
+
}
|
|
270
|
+
if (!stat || !stat.isDirectory()) {
|
|
271
|
+
throw new TowerToolError('repo-invalid', `autoGitInit requires an existing directory: ${resolved}`)
|
|
272
|
+
}
|
|
273
|
+
if (resolved === path.parse(resolved).root) {
|
|
274
|
+
throw new TowerToolError('repo-invalid', 'autoGitInit refuses to initialize a filesystem root')
|
|
275
|
+
}
|
|
276
|
+
await git(resolved, ['init'])
|
|
277
|
+
const repo = await resolveRepo(resolved)
|
|
278
|
+
if (!(await hasAnyCommit(repo.root))) {
|
|
279
|
+
await git(repo.root, [
|
|
280
|
+
'-c',
|
|
281
|
+
'user.name=dsh-tower',
|
|
282
|
+
'-c',
|
|
283
|
+
'user.email=dsh-tower@localhost',
|
|
284
|
+
'commit',
|
|
285
|
+
'--allow-empty',
|
|
286
|
+
'-m',
|
|
287
|
+
'tower: initial empty commit',
|
|
288
|
+
])
|
|
289
|
+
}
|
|
290
|
+
return repo
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
const OPERATIONAL_TOOLS = new Map([
|
|
295
|
+
['tower_batch', 'exposeBatch'],
|
|
296
|
+
['tower_resource', 'exposeLeases'],
|
|
297
|
+
['tower_import_legacy', 'exposeImport'],
|
|
298
|
+
['tower_legacy_map', 'exposeImport'],
|
|
299
|
+
['tower_recover', 'exposeRecovery'],
|
|
300
|
+
['tower_test', 'exposeTests'],
|
|
301
|
+
['tower_doctor', 'exposeDoctor'],
|
|
302
|
+
])
|
|
303
|
+
|
|
304
|
+
function plainSchema(value) {
|
|
305
|
+
return JSON.parse(JSON.stringify(value))
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* Register one tool. The TypeBox schema is the single source of truth for the
|
|
310
|
+
* runtime JSON Schema and the handler's argument type.
|
|
311
|
+
*
|
|
312
|
+
* @template {import('@sinclair/typebox').TSchema} S
|
|
313
|
+
* @param {any} ctx
|
|
314
|
+
* @param {{
|
|
315
|
+
* name: string,
|
|
316
|
+
* description: string,
|
|
317
|
+
* parameters: S,
|
|
318
|
+
* execute: (args: import('@sinclair/typebox').Static<S>, exec: any) => Promise<unknown>,
|
|
319
|
+
* aliases?: any,
|
|
320
|
+
* requiresMissionId?: any,
|
|
321
|
+
* board?: any,
|
|
322
|
+
* output?: any,
|
|
323
|
+
* }} definition
|
|
324
|
+
*/
|
|
325
|
+
function define(ctx, definition) {
|
|
326
|
+
const { aliases, requiresMissionId, board, execute, ...rest } = definition
|
|
327
|
+
const toolsConfig = runtimeConfig().tools ?? {}
|
|
328
|
+
const flag = OPERATIONAL_TOOLS.get(rest.name)
|
|
329
|
+
if (flag && (toolsConfig.exposeOperational === false || toolsConfig[flag] === false)) return
|
|
330
|
+
ctx.effect(() =>
|
|
331
|
+
ctx.tools.register({
|
|
332
|
+
...rest,
|
|
333
|
+
parameters: plainSchema(rest.parameters),
|
|
334
|
+
async execute(args, exec) {
|
|
335
|
+
const normalized = normalizeToolArgs(args, aliases)
|
|
336
|
+
if (requiresMissionId) {
|
|
337
|
+
const id = normalized.missionId
|
|
338
|
+
if (typeof id !== 'string' || id.trim().length === 0) {
|
|
339
|
+
throw new TowerToolError('invalid-argument', `${rest.name} requires missionId (alias: id)`)
|
|
340
|
+
}
|
|
341
|
+
normalized.missionId = id.trim()
|
|
342
|
+
}
|
|
343
|
+
let value
|
|
344
|
+
try {
|
|
345
|
+
if (runtimeState.workspaceHook && typeof normalized.repoRoot === 'string' && rest.name !== 'tower_init') {
|
|
346
|
+
await runtimeState.workspaceHook.ensure(normalized.repoRoot, exec, rest.name)
|
|
347
|
+
}
|
|
348
|
+
value = await execute(normalized, exec)
|
|
349
|
+
} catch (error) {
|
|
350
|
+
throw classifyError(error)
|
|
351
|
+
}
|
|
352
|
+
if (typeof normalized.repoRoot === 'string') watchRepo(normalized.repoRoot)
|
|
353
|
+
const event = rest.name.startsWith('tower_') ? rest.name.slice('tower_'.length) : null
|
|
354
|
+
if (event) {
|
|
355
|
+
try {
|
|
356
|
+
ctx.emit?.(`tower/${event}`, {
|
|
357
|
+
tool: rest.name,
|
|
358
|
+
repoRoot: typeof normalized.repoRoot === 'string' ? normalized.repoRoot : null,
|
|
359
|
+
value: value ?? null,
|
|
360
|
+
})
|
|
361
|
+
} catch {
|
|
362
|
+
// events are an optional side channel; never fail a tool for them
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
if (board === true && typeof normalized.repoRoot === 'string') {
|
|
366
|
+
try {
|
|
367
|
+
const opened = await Tower.open(normalized.repoRoot)
|
|
368
|
+
await opened.writeBoard()
|
|
369
|
+
} catch {
|
|
370
|
+
// board output is best effort
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
return value
|
|
374
|
+
},
|
|
375
|
+
}),
|
|
376
|
+
)
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
function textOf(blocks) {
|
|
380
|
+
if (!Array.isArray(blocks)) return null
|
|
381
|
+
return blocks
|
|
382
|
+
.map((block) => (block && block.type === 'text' ? block.text : `[${block && block.type}]`))
|
|
383
|
+
.join('\n')
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/** Read `package.json tower.testDiscovery` and run it for one mission worktree.
|
|
387
|
+
* Returns `{configured:false}` when the repository has no discovery block. */
|
|
388
|
+
async function runMissionDiscovery(tower, mission) {
|
|
389
|
+
const testsConfig = tower.config.tests ?? {}
|
|
390
|
+
if (testsConfig.enabled === false || testsConfig.allowPackageDiscovery === false) {
|
|
391
|
+
return { configured: false }
|
|
392
|
+
}
|
|
393
|
+
const worktreePath = mission?.worktree?.path
|
|
394
|
+
if (!worktreePath) return { configured: false, error: 'mission has no worktree' }
|
|
395
|
+
let packageText
|
|
396
|
+
try {
|
|
397
|
+
packageText = await fsp.readFile(path.join(worktreePath, 'package.json'), 'utf8')
|
|
398
|
+
} catch (error) {
|
|
399
|
+
if (error?.code === 'ENOENT') return { configured: false }
|
|
400
|
+
throw error
|
|
401
|
+
}
|
|
402
|
+
const read = readDiscoveryConfig(packageText)
|
|
403
|
+
if (!read.configured) {
|
|
404
|
+
if (read.error) throw new TowerToolError('invalid-argument', `package.json test discovery is invalid: ${read.error}`)
|
|
405
|
+
return { configured: false, error: null }
|
|
406
|
+
}
|
|
407
|
+
const normalized = normalizeDiscovery(read.config)
|
|
408
|
+
if (!normalized.ok) {
|
|
409
|
+
throw new TowerToolError('invalid-argument', `invalid tower.testDiscovery: ${normalized.error}`)
|
|
410
|
+
}
|
|
411
|
+
const receipt = await runDiscovery({
|
|
412
|
+
worktreePath,
|
|
413
|
+
config: read.config,
|
|
414
|
+
timeoutMs: Number(testsConfig.timeoutMs) || 60000,
|
|
415
|
+
maxBytes: Number(testsConfig.maxBytes) || 16 * 1024 * 1024,
|
|
416
|
+
missionId: mission.id,
|
|
417
|
+
diagnosticsDir: path.join(tower.paths.dir, 'test-runs', `${mission.id}-${Date.now()}`),
|
|
418
|
+
})
|
|
419
|
+
const stored = await tower.recordTestRun({ missionId: mission.id, receipt })
|
|
420
|
+
const commands = Array.isArray(stored.commands) ? stored.commands : []
|
|
421
|
+
const check = {
|
|
422
|
+
name: 'tests',
|
|
423
|
+
command: commands.map((command) => `${command.id}: ${command.command}`).join('; ') || 'tower.testDiscovery',
|
|
424
|
+
exitCode: commands.every((command) => command.exitCode === 0) ? 0 : (commands.find((command) => command.exitCode !== 0)?.exitCode ?? 1),
|
|
425
|
+
evidence: stored.ok
|
|
426
|
+
? `${stored.tests?.length ?? 0} test(s) passed at ${String(stored.treeTip ?? '').slice(0, 12)}; receipt ${stored.receiptPath ?? stored.diagnosticsPath}`
|
|
427
|
+
: `test discovery failed: ${String(stored.error ?? 'unknown').slice(0, 300)}`,
|
|
428
|
+
}
|
|
429
|
+
return { configured: true, receipt: stored, check }
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
async function observeActivity(ctx, tower) {
|
|
433
|
+
const view = await tower.rosterView()
|
|
434
|
+
const subagents = ctx.get('subagents')
|
|
435
|
+
const activity = {}
|
|
436
|
+
const parentStatus = {}
|
|
437
|
+
if (!subagents) return { activity, parentStatus }
|
|
438
|
+
const parents = [...new Set(view.roster.map((entry) => entry.parentSessionId).filter(Boolean))]
|
|
439
|
+
for (const parent of parents) {
|
|
440
|
+
try {
|
|
441
|
+
const rows = await subagents.listDescendants(parent)
|
|
442
|
+
for (const row of rows) activity[String(row.id)] = row.activity ?? ROSTER_ACTIVITY.unknown
|
|
443
|
+
parentStatus[parent] = PARENT_STATUS_OK
|
|
444
|
+
} catch (error) {
|
|
445
|
+
parentStatus[parent] = `unavailable: ${String(error?.message ?? error)}`
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
return { activity, parentStatus }
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
export function registerTowerTools(ctx, options = {}) {
|
|
452
|
+
runtimeState.workspaceHook = options.workspace ?? null
|
|
453
|
+
runtimeState.hmrBridge = options.hmrBridge ?? null
|
|
454
|
+
// A lightweight roster watchdog: the authoritative activity comes from the
|
|
455
|
+
// subagents catalog; a missing observation after spawn.staleMs is marked
|
|
456
|
+
// stale (never silently treated as stopped) and blocks merge via the gate.
|
|
457
|
+
const watchMs = Math.max(1000, Number(runtimeConfig().mode?.heartbeatMs) || 10000)
|
|
458
|
+
const timer = setInterval(() => {
|
|
459
|
+
void (async () => {
|
|
460
|
+
try {
|
|
461
|
+
if (!ctx.get('subagents')) return
|
|
462
|
+
for (const repoRoot of [...WATCHED_REPOS]) {
|
|
463
|
+
const tower = await Tower.open(repoRoot)
|
|
464
|
+
const view = await tower.rosterView()
|
|
465
|
+
const live = view.roster.filter((entry) => !ROSTER_INACTIVE_STATES.includes(entry.state))
|
|
466
|
+
if (live.length === 0) { WATCHED_REPOS.delete(repoRoot); continue }
|
|
467
|
+
const parents = [...new Set(live.map((entry) => entry.parentSessionId).filter(Boolean))]
|
|
468
|
+
if (parents.length === 0) continue
|
|
469
|
+
const { activity, parentStatus } = await observeActivity(ctx, tower)
|
|
470
|
+
const authoritative = parents.every((parent) => parentStatus[parent] === PARENT_STATUS_OK)
|
|
471
|
+
await tower.sweepRoster({
|
|
472
|
+
staleMs: Number(tower.config.spawn?.staleMs) || 0,
|
|
473
|
+
activity,
|
|
474
|
+
authoritative,
|
|
475
|
+
})
|
|
476
|
+
}
|
|
477
|
+
} catch {
|
|
478
|
+
// watchdog failures are best effort; tools still re-check on demand
|
|
479
|
+
}
|
|
480
|
+
})()
|
|
481
|
+
}, watchMs)
|
|
482
|
+
timer.unref?.()
|
|
483
|
+
ctx.effect(() => () => clearInterval(timer))
|
|
484
|
+
registerInitTool(ctx, { define, actorFor, requireTowerActor, workspaceHook: runtimeState.workspaceHook, ensureRepository })
|
|
485
|
+
|
|
486
|
+
registerPlanTool(ctx, { define, actorFor, requireTowerActor })
|
|
487
|
+
|
|
488
|
+
registerMissionTool(ctx, { define, actorFor, requireKnownActor })
|
|
489
|
+
|
|
490
|
+
registerStatusTool(ctx, { define, workspaceHook: runtimeState.workspaceHook, spawnBackoffView, observeActivity })
|
|
491
|
+
|
|
492
|
+
registerSpawnTool(ctx, { define, actorFor, requireTowerActor, enqueueSpawn, observeActivity, spawnBackoffState, spawnBackoffView, recordSpawnThrottle, recordSpawnSuccess, runMissionDiscovery, textOf })
|
|
493
|
+
|
|
494
|
+
registerRosterTool(ctx, { define, workspaceHook: runtimeState.workspaceHook, observeActivity, actorFor, requireTowerActor, interruptRosterAgent })
|
|
495
|
+
|
|
496
|
+
registerInterruptTool(ctx, { define, actorFor, requireTowerActor, interruptRosterAgent })
|
|
497
|
+
|
|
498
|
+
registerDiffTool(ctx, { define, actorFor, requireTowerActor })
|
|
499
|
+
|
|
500
|
+
registerAdoptTool(ctx, { define, actorFor, requireTowerActor })
|
|
501
|
+
|
|
502
|
+
registerSendTool(ctx, { define, actorFor, checkSendRate })
|
|
503
|
+
|
|
504
|
+
registerInboxTool(ctx, { define })
|
|
505
|
+
|
|
506
|
+
registerCompleteTool(ctx, { define, runMissionDiscovery })
|
|
507
|
+
|
|
508
|
+
registerFindingTool(ctx, { define, actorFor, requireKnownActor })
|
|
509
|
+
|
|
510
|
+
registerFindingsTool(ctx, { define, actorFor, requireKnownActor })
|
|
511
|
+
|
|
512
|
+
registerReviewTool(ctx, { define, actorFor })
|
|
513
|
+
|
|
514
|
+
registerPreflightTool(ctx, { define, observeActivity })
|
|
515
|
+
|
|
516
|
+
registerMergeTool(ctx, { define, actorFor, requireTowerActor, observeActivity })
|
|
517
|
+
|
|
518
|
+
registerRebaseTool(ctx, { define, actorFor, requireTowerActor, observeActivity })
|
|
519
|
+
|
|
520
|
+
registerTeardownTool(ctx, { define, actorFor, requireTowerActor, observeActivity })
|
|
521
|
+
|
|
522
|
+
registerDoctorTool(ctx, define)
|
|
523
|
+
|
|
524
|
+
registerRecoverTool(ctx, { define, actorFor, requireTowerActor })
|
|
525
|
+
|
|
526
|
+
registerTestTool(ctx, { define, actorFor, requireKnownActor, runMissionDiscovery })
|
|
527
|
+
|
|
528
|
+
registerBatchTool(ctx, { define, actorFor, requireTowerActor, summarizeBatch, verifyBatchReceiptFiles })
|
|
529
|
+
|
|
530
|
+
registerResourceTool(ctx, { define, actorFor, requireTowerActor })
|
|
531
|
+
|
|
532
|
+
registerImportLegacyTool(ctx, { define, actorFor, requireTowerActor })
|
|
533
|
+
|
|
534
|
+
registerLegacyMapTool(ctx, { define, actorFor, requireTowerActor })
|
|
535
|
+
|
|
536
|
+
registerReloadTool(ctx, { define, actorFor, requireTowerActor, sharedHmrBridge: runtimeState.hmrBridge })
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
export const __testSpawnBackoff = {
|
|
540
|
+
spawnBackoffState,
|
|
541
|
+
recordSpawnThrottle,
|
|
542
|
+
recordSpawnSuccess,
|
|
543
|
+
spawnBackoffView,
|
|
544
|
+
}
|
package/lib/workspaceLock.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// `mode.workspaceLock` is enabled. Same-user trusted model, like the rest of
|
|
3
3
|
// the Tower store: it prevents accidental double control, not a malicious
|
|
4
4
|
// writer.
|
|
5
|
-
import fsp from '
|
|
5
|
+
import { fsp } from '../adapters/store-fs.js'
|
|
6
6
|
import path from 'node:path'
|
|
7
7
|
import crypto from 'node:crypto'
|
|
8
8
|
|
|
@@ -67,7 +67,7 @@ async function removeLockIfMatches(file, expectedToken, expectedRaw) {
|
|
|
67
67
|
|
|
68
68
|
/**
|
|
69
69
|
* Try to become the repository's Tower session.
|
|
70
|
-
* @returns {{acquired:boolean, record?:object, holder?:object|null, tookOver?:boolean, corrupt?:boolean}}
|
|
70
|
+
* @returns {Promise<{acquired:boolean, record?:object, holder?:object|null, tookOver?:boolean, corrupt?:boolean}>}
|
|
71
71
|
*/
|
|
72
72
|
export async function acquireWorkspaceLock(commonDir, options) {
|
|
73
73
|
const { token, sessionId, agentId, staleMs = 300000, now = Date.now() } = options
|
package/lib/worktreeOwnership.js
CHANGED
|
@@ -6,12 +6,13 @@
|
|
|
6
6
|
// and must still be the Git worktree of the recorded branch in the recorded
|
|
7
7
|
// repository. Every mismatch is rejected with an actionable message; nothing
|
|
8
8
|
// is repaired or recreated here.
|
|
9
|
-
import fsp from '
|
|
9
|
+
import { fs, fsp } from '../adapters/store-fs.js'
|
|
10
10
|
import path from 'node:path'
|
|
11
11
|
|
|
12
12
|
import { git } from './git.js'
|
|
13
13
|
|
|
14
14
|
function fail(code, message) {
|
|
15
|
+
/** @type {Error & {code?: string}} */
|
|
15
16
|
const error = new Error(message)
|
|
16
17
|
error.code = code
|
|
17
18
|
throw error
|
|
@@ -21,18 +22,29 @@ function isNonEmptyString(value) {
|
|
|
21
22
|
return typeof value === 'string' && value.trim().length > 0
|
|
22
23
|
}
|
|
23
24
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
25
|
+
// Path identity follows the injected platform's semantics so both branches are
|
|
26
|
+
// testable on any OS (#86); on the real platform this is exactly `path`.
|
|
27
|
+
export function compareKey(target, platform = process.platform) {
|
|
28
|
+
const pathImpl = platform === 'win32' ? path.win32 : path.posix
|
|
29
|
+
const resolved = pathImpl.resolve(target)
|
|
30
|
+
return platform === 'win32' ? resolved.toLowerCase() : resolved
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function nativeRealpath(target) {
|
|
34
|
+
try {
|
|
35
|
+
return fs.realpathSync.native(target)
|
|
36
|
+
} catch {
|
|
37
|
+
return path.resolve(target)
|
|
38
|
+
}
|
|
27
39
|
}
|
|
28
40
|
|
|
29
41
|
function samePath(a, b) {
|
|
30
|
-
return compareKey(a) === compareKey(b)
|
|
42
|
+
return compareKey(nativeRealpath(a)) === compareKey(nativeRealpath(b))
|
|
31
43
|
}
|
|
32
44
|
|
|
33
45
|
async function realpathOrResolve(target) {
|
|
34
46
|
try {
|
|
35
|
-
return
|
|
47
|
+
return fs.realpathSync.native(target)
|
|
36
48
|
} catch (error) {
|
|
37
49
|
if (error?.code === 'ENOENT') return path.resolve(target)
|
|
38
50
|
throw error
|
|
@@ -47,7 +59,7 @@ export async function canonicalPath(target) {
|
|
|
47
59
|
if (!isNonEmptyString(target)) fail('invalid-argument', 'path is required')
|
|
48
60
|
if (!path.isAbsolute(target)) fail('invalid-argument', `path must be an absolute path: ${target}`)
|
|
49
61
|
try {
|
|
50
|
-
return
|
|
62
|
+
return fs.realpathSync.native(target)
|
|
51
63
|
} catch (error) {
|
|
52
64
|
if (error?.code === 'ENOENT') fail('invalid-transition', `path does not exist: ${path.resolve(target)}`)
|
|
53
65
|
throw error
|
|
@@ -74,7 +86,7 @@ async function gitTop(resolved, label) {
|
|
|
74
86
|
* @param {string} [input.missionId] mission id, used in error messages
|
|
75
87
|
* @returns {Promise<{path:string, realPath:string}>}
|
|
76
88
|
*/
|
|
77
|
-
export async function assertOwnership(input = {}) {
|
|
89
|
+
export async function assertOwnership(input = /** @type {any} */ ({})) {
|
|
78
90
|
const storedPath = input?.storedPath
|
|
79
91
|
const storedRealPath = input?.storedRealPath
|
|
80
92
|
const branch = input?.branch
|
|
@@ -138,7 +150,7 @@ export async function assertOwnership(input = {}) {
|
|
|
138
150
|
} catch (error) {
|
|
139
151
|
fail('invalid-transition', `${label} ${resolved} has no readable HEAD: ${error.message}`)
|
|
140
152
|
}
|
|
141
|
-
const actualBranch = head === 'HEAD' ? null : head.trim()
|
|
153
|
+
const actualBranch = head === 'HEAD' ? null : String(head).trim()
|
|
142
154
|
if (actualBranch !== branch) {
|
|
143
155
|
fail(
|
|
144
156
|
'invalid-transition',
|
|
@@ -146,7 +158,7 @@ export async function assertOwnership(input = {}) {
|
|
|
146
158
|
)
|
|
147
159
|
}
|
|
148
160
|
|
|
149
|
-
let commonRaw
|
|
161
|
+
let commonRaw = ''
|
|
150
162
|
try {
|
|
151
163
|
commonRaw = await git(resolved, ['rev-parse', '--git-common-dir'])
|
|
152
164
|
} catch (error) {
|
package/locale/en.json
CHANGED
|
@@ -1,6 +1,28 @@
|
|
|
1
1
|
{
|
|
2
2
|
"meta": {
|
|
3
3
|
"title": "Tower",
|
|
4
|
-
"description": "Mission planning, isolated worktrees, and review-gated merges
|
|
4
|
+
"description": "Mission planning, isolated worktrees, worker/reviewer agents, and review-gated merges."
|
|
5
|
+
},
|
|
6
|
+
"components": {
|
|
7
|
+
"tower-bundle": {
|
|
8
|
+
"title": "Tower",
|
|
9
|
+
"summary": "Mission planning, isolated worktrees, worker/reviewer agents, and merge gates.",
|
|
10
|
+
"disables": "Turns off the whole Tower bundle; no orphan preset or skill is left behind."
|
|
11
|
+
},
|
|
12
|
+
"tower-skills": {
|
|
13
|
+
"title": "Tower skills",
|
|
14
|
+
"summary": "Playbooks for worker/reviewer agents and recovery after a blocked merge.",
|
|
15
|
+
"disables": "Internal section; toggled with the Tower bundle."
|
|
16
|
+
},
|
|
17
|
+
"preset-tower-worker": {
|
|
18
|
+
"title": "Tower worker",
|
|
19
|
+
"summary": "Writes code, runs verification, and commits only the files assigned to its mission.",
|
|
20
|
+
"disables": "Internal section; a missing preset is rejected before planning/mode entry."
|
|
21
|
+
},
|
|
22
|
+
"preset-tower-reviewer": {
|
|
23
|
+
"title": "Tower reviewer",
|
|
24
|
+
"summary": "Read-only checks for scope and acceptance, returning a clean verdict or findings.",
|
|
25
|
+
"disables": "Internal section; a missing preset is rejected before planning/mode entry when review is required."
|
|
26
|
+
}
|
|
5
27
|
}
|
|
6
28
|
}
|